From dfe9febeb5bb258f8ae4fa5ce03ee169eb3a3877 Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:55:09 -0400 Subject: [PATCH 1/9] Add configurable meter drivers and metering provider settings --- assets/stylesheets/sparkmeter.scss | 49 + .../0.84_add_meter_driver_selection.py | 26 + sparkmeter/config/configparameter.py | 6 +- sparkmeter/config/configparametertypes.py | 24 +- sparkmeter/config/configviews.py | 161 ++- sparkmeter/config/provider_settings.py | 943 ++++++++++++++ sparkmeter/config/providerform.py | 229 ++++ .../config-meter-driver-config-editor.html | 55 + .../templates/config-meter-driver-form.html | 97 ++ .../templates/config-meter-driver-list.html | 150 +++ .../config/tests/test_configparametertypes.py | 26 +- sparkmeter/config/tests/test_configviews.py | 370 ++++++ .../config/tests/test_provider_settings.py | 1148 +++++++++++++++++ sparkmeter/config/tests/test_providerform.py | 285 ++++ sparkmeter/database/schemas/meterschema.sql | 10 +- sparkmeter/meter/meterdomain.py | 16 +- sparkmeter/meter/meterform.py | 52 + sparkmeter/meter/templates/meter-form.html | 196 +++ sparkmeter/meter/tests/test_meterdomain.py | 18 + sparkmeter/meter/tests/test_meterform.py | 142 ++ sparkmeter/templates/base.html | 5 + sparkmeter/web/unittestutils.py | 7 + ...-ConfigTest.test_meter_driver_add_get.page | 264 ++++ ...nfigTest.test_meter_driver_config_get.page | 243 ++++ ...meter_driver_config_post_config_error.page | 244 ++++ ...t_meter_driver_config_post_init_error.page | 244 ++++ ...ConfigTest.test_meter_driver_edit_get.page | 265 ++++ ...test_meter_driver_with_saved_provider.page | 285 ++++ 28 files changed, 5548 insertions(+), 12 deletions(-) create mode 100644 sparkmeter/alembic/versions/0.84_add_meter_driver_selection.py create mode 100644 sparkmeter/config/provider_settings.py create mode 100644 sparkmeter/config/providerform.py create mode 100644 sparkmeter/config/templates/config-meter-driver-config-editor.html create mode 100644 sparkmeter/config/templates/config-meter-driver-form.html create mode 100644 sparkmeter/config/templates/config-meter-driver-list.html create mode 100644 sparkmeter/config/tests/test_provider_settings.py create mode 100644 sparkmeter/config/tests/test_providerform.py create mode 100644 sparkmeter/meter/tests/test_meterform.py create mode 100644 test-data/config/test_configviews-ConfigTest.test_meter_driver_add_get.page create mode 100644 test-data/config/test_configviews-ConfigTest.test_meter_driver_config_get.page create mode 100644 test-data/config/test_configviews-ConfigTest.test_meter_driver_config_post_config_error.page create mode 100644 test-data/config/test_configviews-ConfigTest.test_meter_driver_config_post_init_error.page create mode 100644 test-data/config/test_configviews-ConfigTest.test_meter_driver_edit_get.page create mode 100644 test-data/config/test_configviews-ConfigTest.test_meter_driver_with_saved_provider.page diff --git a/assets/stylesheets/sparkmeter.scss b/assets/stylesheets/sparkmeter.scss index d7facfe..bfa6ff9 100644 --- a/assets/stylesheets/sparkmeter.scss +++ b/assets/stylesheets/sparkmeter.scss @@ -308,3 +308,52 @@ dd.tags { #override-modal a { color: #ffffff; } + +/* Chromium/WebView fallback for old Font Awesome 3 glyphs. + * Some local dev environments render the legacy font as empty squares + * even though the files are present. Re-map the most common TC icons + * to Bootstrap glyphicons, which are already shipped in this repo. + */ +.icon-home:before, +.icon-user:before, +.icon-th:before, +.icon-book:before, +.icon-bar-chart:before, +.icon-dashboard:before, +.icon-table:before, +.icon-money:before, +.icon-credit-card:before, +.icon-group:before, +.icon-key:before, +.icon-signin:before, +.icon-comment:before, +.icon-download:before, +.icon-download-alt:before, +.icon-cog:before, +.icon-edit:before, +.icon-plus:before, +.icon-align-justify:before, +.icon-caret-down:before { + font-family: 'Glyphicons Halflings'; +} + +.icon-home:before { content: "\e021"; } +.icon-user:before { content: "\e008"; } +.icon-th:before { content: "\e011"; } +.icon-book:before { content: "\e043"; } +.icon-bar-chart:before { content: "\e185"; } +.icon-dashboard:before { content: "\e141"; } +.icon-table:before { content: "\e185"; } +.icon-money:before { content: "\e148"; } +.icon-credit-card:before { content: "\e177"; } +.icon-group:before { content: "\e008"; } +.icon-key:before { content: "\e033"; } +.icon-signin:before { content: "\e161"; } +.icon-comment:before { content: "\e111"; } +.icon-download:before { content: "\e026"; } +.icon-download-alt:before { content: "\e025"; } +.icon-cog:before { content: "\e019"; } +.icon-edit:before { content: "\e065"; } +.icon-plus:before { content: "\2b"; } +.icon-align-justify:before { content: "\e056"; } +.icon-caret-down:before { content: "\e114"; } diff --git a/sparkmeter/alembic/versions/0.84_add_meter_driver_selection.py b/sparkmeter/alembic/versions/0.84_add_meter_driver_selection.py new file mode 100644 index 0000000..1d5364a --- /dev/null +++ b/sparkmeter/alembic/versions/0.84_add_meter_driver_selection.py @@ -0,0 +1,26 @@ +# Copyright (C) 2013-2026 SparkMeter, Inc. +# All Rights Reserved. +"""add meter driver selection. + +Revision ID: 0.84 +Revises: 0.83 +Create Date: 2026-07-11 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0.84" +down_revision = "0.83" + + +def upgrade(): + """Upgrade the database schema from 0.83 to 0.84.""" + op.add_column("meter", sa.Column("provider_id", sa.String(), nullable=True)) + + +def downgrade(): # pragma: nocoverage + """Downgrade the database schema from 0.84 to 0.83.""" + op.execute("DROP VIEW IF EXISTS meter_view") + op.drop_column("meter", "provider_id") diff --git a/sparkmeter/config/configparameter.py b/sparkmeter/config/configparameter.py index e5c0a24..43ec43a 100644 --- a/sparkmeter/config/configparameter.py +++ b/sparkmeter/config/configparameter.py @@ -4,7 +4,7 @@ """Configuration parameter.""" from sparkmeter.config.configdict import config -from sparkmeter.config.configparametertypes import Bool, Percent, Voltage +from sparkmeter.config.configparametertypes import Bool, Percent, String, Voltage from sparkmeter.database.alchemy import sql from sparkmeter.misc.pythonutils import ClassInittable, unset @@ -179,6 +179,10 @@ class ParameterObject(ClassInittable): tooltip="Nominal voltage of current flowing through meters.", ) + METERING_PROVIDERS = ParameterAttribute( + String, default="[]", label="Meter drivers", tooltip="JSON-encoded list of configured meter drivers." + ) + @classmethod def __class_init__(cls): """Update all attributes with their names.""" diff --git a/sparkmeter/config/configparametertypes.py b/sparkmeter/config/configparametertypes.py index 0340b4f..f9183bb 100644 --- a/sparkmeter/config/configparametertypes.py +++ b/sparkmeter/config/configparametertypes.py @@ -131,7 +131,7 @@ def _validate_value(self, value): msg = "value cannot be less than {}, defaulting to {}." logger.warning(msg.format(self.min_value, retval)) if self.max_value is not None and value > self.max_value: - retval = self.min_value + retval = self.max_value msg = "value cannot be more than {}, defaulting to {}." logger.warning(msg.format(self.max_value, retval)) return retval @@ -185,3 +185,25 @@ def _validate_value(self, value): if retval not in self.allowed: raise TypeError("value must be one of: {}".format(", ".join(str(val) for val in self.allowed))) return retval + + +class String(ParameterType): + """A string parameter.""" + + type_name = "string" + python_type = str + + def to_python(self, value): + """Convert a database value to python.""" + if value is None: + return "" + return str(value) + + def from_python(self, value): + """Convert a python value to database.""" + if value is None: + return "" + if not isinstance(value, str): + msg = "string parameters must be strings, not {!r}." + raise TypeError(msg.format(type(value).__name__)) + return value diff --git a/sparkmeter/config/configviews.py b/sparkmeter/config/configviews.py index d1984cf..13f3ecf 100644 --- a/sparkmeter/config/configviews.py +++ b/sparkmeter/config/configviews.py @@ -3,16 +3,58 @@ # All Rights Reserved. """Config views.""" -from flask import request +import http.client + +from flask import abort, flash, request +from flask.helpers import redirect, url_for from flask.templating import render_template +from flask_babel import lazy_gettext as _ from flask_security import roles_accepted +from sparkmeter.config.provider_settings import ( + DriverConfigError, + DriverInitializationError, + get_live_interface_details, + get_provider, + get_provider_config_abspath, + get_provider_init_status, + get_runtime_status, + get_saved_providers, +) +from sparkmeter.config.providerform import MeterDriverConfigEditorForm, MeterDriverSettingsForm from sparkmeter.event.eventviews import format_event_type +from sparkmeter.meter.meterdomain import Meter +from sparkmeter.metering.lifespan import activate_metering_runtime_in_process from sparkmeter.web.blueprint import AuthBlueprint config = AuthBlueprint("config", __name__) +def _live_metering_activation_flash(activation_error): + """Translate low-level runtime activation errors into operator-friendly UI text.""" + if activation_error == "main event loop is not available": + if Meter.query.count() == 0: + return ( + _( + "Driver config saved and init succeeded. No meters are registered yet, " + "so live metering will start after you add a meter." + ), + "info", + ) + return ( + _( + "Driver config saved and init succeeded. Live metering is not active in this " + "screen yet, but it will start the next time TC2.0 starts." + ), + "info", + ) + + return ( + _("Driver config saved and init succeeded. Live metering is not active yet."), + "info", + ) + + @config.route("/config/billing") @roles_accepted("operator") def billing(): @@ -41,3 +83,120 @@ def sms_template_help(): def meters(): """Meter configuration page.""" return render_template("config-meters.html") + + +@config.route("/config/meter-driver", methods=["GET", "POST"]) +@roles_accepted("operator") +def meter_driver(): + """Meter driver list page.""" + providers = get_saved_providers() + provider_statuses = {} + provider_contracts = {} + provider_config_paths = {} + provider_init_statuses = {} + + for provider in providers: + provider_init_statuses[provider["id"]] = get_provider_init_status(provider) + provider_contracts[provider["id"]] = get_live_interface_details( + provider["base_url"], + selected_interface=provider["selected_interface"], + ) + provider_statuses[provider["id"]] = get_runtime_status( + provider["base_url"], + include_gateway_status=bool(provider_init_statuses[provider["id"]].get("has_successful_init")), + ) + provider_config_paths[provider["id"]] = get_provider_config_abspath(provider) + + return render_template( + "config-meter-driver-list.html", + providers=providers, + provider_statuses=provider_statuses, + provider_contracts=provider_contracts, + provider_config_paths=provider_config_paths, + provider_init_statuses=provider_init_statuses, + ) + + +@config.route("/config/meter-driver/add", methods=["GET", "POST"]) +@roles_accepted("operator") +def meter_driver_add(): + """Register a meter driver.""" + form = MeterDriverSettingsForm( + formdata=request.form if request.method == "POST" else None, + ) + + if request.method == "POST" and form.validate(): + form.save() + flash(form.notification_message(), "success") + return redirect(url_for("config.meter_driver")) + + return form.render() + + +@config.route("/config/meter-driver//edit", methods=["GET", "POST"]) +@roles_accepted("operator") +def meter_driver_edit(provider_id): + """Edit the configured meter driver.""" + provider = get_provider(provider_id) + if provider is None: + abort(http.client.NOT_FOUND) + + provider_details = get_live_interface_details( + provider["base_url"], + selected_interface=provider["selected_interface"], + ) + + form = MeterDriverSettingsForm( + formdata=request.form if request.method == "POST" else None, + provider_details=provider_details, + provider=provider, + ) + + if request.method == "POST" and form.validate(): + form.save() + flash(form.notification_message(), "success") + return redirect(url_for("config.meter_driver")) + + return form.render(provider=provider) + + +@config.route("/config/meter-driver//config", methods=["GET", "POST"]) +@roles_accepted("operator") +def meter_driver_config(provider_id): + """Edit the generated JSON config for a meter driver and attempt init.""" + provider = get_provider(provider_id) + if provider is None: + abort(http.client.NOT_FOUND) + + provider_details = get_live_interface_details( + provider["base_url"], + selected_interface=provider["selected_interface"], + ) + form = MeterDriverConfigEditorForm( + formdata=request.form if request.method == "POST" else None, + provider=provider, + provider_details=provider_details, + ) + + if request.method == "POST": + if form.cancel_button.data: + return redirect(url_for("config.meter_driver")) + if form.save_button.data and form.validate(): + try: + form.save_and_init() + except DriverConfigError as exc: + flash(str(exc), "danger") + return form.render(provider=provider, provider_details=provider_details) + except DriverInitializationError as exc: + flash(str(exc), "danger") + return form.render(provider=provider, provider_details=provider_details) + activated, activation_error = activate_metering_runtime_in_process( + skip_provider_init=True, + ) + if not activated and activation_error: + message, category = _live_metering_activation_flash(activation_error) + flash(message, category) + flash(_("Driver config saved and init succeeded."), "success") + return redirect(url_for("config.meter_driver")) + + return form.render(provider=provider, provider_details=provider_details) diff --git a/sparkmeter/config/provider_settings.py b/sparkmeter/config/provider_settings.py new file mode 100644 index 0000000..0e80203 --- /dev/null +++ b/sparkmeter/config/provider_settings.py @@ -0,0 +1,943 @@ +# -*- coding: utf-8 -*- +"""Meter driver settings and validation helpers.""" + +import json +import logging +import uuid +from pathlib import Path +from urllib.parse import urlparse + +import httpx + +from sparkmeter.config.configdomain import ConfigParameter +from sparkmeter.config.configparameter import ParameterObject +from sparkmeter.database.alchemy import sql + + +class ProviderRegistrationError(ValueError): + """Raised when a meter driver URL fails validation.""" + + +class DriverConfigError(ValueError): + """Raised when a driver JSON config file is invalid.""" + + +class DriverInitializationError(ValueError): + """Raised when driver initialization fails.""" + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_METER_DRIVER_CONFIG_DIR = _REPO_ROOT / "meter_driver_configs" +logger = logging.getLogger(__name__) + + +def _resolve_local_ref(spec, ref): + """Resolve a local JSON Pointer reference within an OpenAPI document.""" + if not ref or not ref.startswith("#/"): + return None + + node = spec + for part in ref[2:].split("/"): + node = node.get(part.replace("~1", "/").replace("~0", "~")) + if node is None: + return None + return node + + +def _resolve_schema(spec, schema): + """Resolve a schema object or local ref to a plain dict.""" + if not isinstance(schema, dict): + return {} + if "$ref" in schema: + return _resolve_local_ref(spec, schema["$ref"]) or {} + return schema + + +def _command_type_values(spec, schema): + """Extract the possible command_type discriminator values from a schema.""" + resolved = _resolve_schema(spec, schema) + properties = resolved.get("properties") or {} + command_type = _resolve_schema(spec, properties.get("command_type") or {}) + values = [] + if "const" in command_type: + values.append(command_type["const"]) + if "enum" in command_type and isinstance(command_type["enum"], list): + values.extend(command_type["enum"]) + return {str(value).strip().lower() for value in values if str(value).strip()} + + +def _find_configure_provider_schema(spec): + """Locate the configure-provider command schema in the OpenAPI document.""" + request_schema = ( + (((spec.get("paths") or {}).get("/v1/commands") or {}).get("post") or {}) + .get("requestBody", {}) + .get("content", {}) + .get("application/json", {}) + .get("schema", {}) + ) + request_schema = _resolve_schema(spec, request_schema) + + for candidate in request_schema.get("oneOf") or []: + values = _command_type_values(spec, candidate) + if "configure_provider" in values: + return _resolve_schema(spec, candidate) + + components = (spec.get("components") or {}).get("schemas") or {} + for schema in components.values(): + values = _command_type_values(spec, schema) + if "configure_provider" in values: + return _resolve_schema(spec, schema) + + return {} + + +def _field_spec(name, schema, required): + """Normalize a vendor-option field description for the form layer.""" + field_type = str(schema.get("type") or "string").strip().lower() or "string" + return { + "name": name, + "label": str(schema.get("title") or name.replace("_", " ").title()), + "type": field_type, + "required": bool(required), + "description": str(schema.get("description") or "").strip(), + "pattern": str(schema.get("pattern") or "").strip(), + "minimum": schema.get("minimum"), + "maximum": schema.get("maximum"), + "default": schema.get("default"), + } + + +def _extract_vendor_option_fields(spec): + """Extract configure-provider vendor option requirements from OpenAPI.""" + command_schema = _find_configure_provider_schema(spec) + properties = command_schema.get("properties") or {} + vendor_schema = _resolve_schema(spec, properties.get("vendor_options") or {}) + + fields = [] + required_fields = set(vendor_schema.get("required") or []) + for field_name, field_schema in (vendor_schema.get("properties") or {}).items(): + resolved_field = _resolve_schema(spec, field_schema) + fields.append(_field_spec(field_name, resolved_field, field_name in required_fields)) + + return fields + + +def _vendor_option_field_map(spec): + """Return vendor-option fields keyed by API field name.""" + return {field["name"]: field for field in _extract_vendor_option_fields(spec)} + + +def _requirements_url(service_url): + """Build the requirements endpoint URL from a service URL.""" + return normalize_base_url(service_url) + "/v1/requirements" + + +def _fetch_requirements_payload(service_url, timeout=10.0): + """Fetch the optional driver requirements payload.""" + response = httpx.get(_requirements_url(service_url), timeout=timeout) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ProviderRegistrationError("driver requirements response must be a JSON object") + return payload + + +def _iter_candidate_requirement_schemas(spec): + """Yield object schemas that may describe driver requirement fields.""" + components = (spec.get("components") or {}).get("schemas") or {} + for schema in components.values(): + resolved = _resolve_schema(spec, schema) + if isinstance(resolved, dict) and (resolved.get("properties") or {}): + yield resolved + + for path_item in (spec.get("paths") or {}).values(): + if not isinstance(path_item, dict): + continue + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + schema = ( + ((operation.get("requestBody") or {}).get("content") or {}) + .get("application/json", {}) + .get("schema", {}) + ) + resolved = _resolve_schema(spec, schema) + if isinstance(resolved, dict) and (resolved.get("properties") or {}): + yield resolved + + +def _best_matching_requirements_schema(spec, required_fields): + """Find the schema whose properties best match the reported required fields.""" + required_names = set(required_fields or []) + best_schema = {} + best_score = 0 + for schema in _iter_candidate_requirement_schemas(spec): + properties = set((schema.get("properties") or {}).keys()) + score = len(required_names & properties) + if score > best_score: + best_schema = schema + best_score = score + return best_schema if best_score else {} + + +def _extract_fields_from_requirements(spec, required_fields): + """Build normalized field specs from a requirements list plus OpenAPI schema hints.""" + schema = _best_matching_requirements_schema(spec, required_fields) + properties = schema.get("properties") or {} + schema_required = set(schema.get("required") or []) + fields = [] + for name in required_fields: + resolved = _resolve_schema(spec, properties.get(name) or {}) + fields.append( + _field_spec( + name, + resolved, + name in schema_required or name in set(required_fields), + ) + ) + return fields + + +def _extract_driver_requirement_fields(base_url, spec, timeout=10.0): + """Discover required driver fields from /v1/requirements, else OpenAPI.""" + try: + payload = _fetch_requirements_payload(base_url, timeout=timeout) + required_fields = payload.get("required_fields") or [] + if isinstance(required_fields, list) and required_fields: + normalized = [str(name).strip() for name in required_fields if str(name).strip()] + if normalized: + return _extract_fields_from_requirements(spec, normalized) + except (httpx.HTTPError, ValueError, ProviderRegistrationError): + pass + + return _extract_vendor_option_fields(spec) + + +def _get_parameter(name): + """Return a config parameter by name.""" + return ConfigParameter.get_by_name(name) + + +def _create_parameter_if_missing(name): + """Create a known config parameter on demand for existing databases.""" + parameter = _get_parameter(name) + if parameter is not None: + return parameter + + for attribute in ParameterObject.attributes: + if attribute.name == name: + parameter = ConfigParameter.create_with_default(attribute) + sql.session.add(parameter) + sql.session.flush() + return parameter + + raise RuntimeError("unknown config parameter: {}".format(name)) + + +def _providers_parameter(): + """Return the backing config parameter for the providers list.""" + return _create_parameter_if_missing("metering-providers") + + +def get_saved_providers(): + """Return configured meter drivers.""" + parameter = _providers_parameter() + raw_value = (parameter.value or "").strip() + if not raw_value: + raw_value = "[]" + + try: + providers = json.loads(raw_value) + except ValueError: + providers = [] + + normalized = [] + for provider in providers: + if not isinstance(provider, dict): + continue + normalized.append( + { + "id": str(provider.get("id") or uuid.uuid4().hex), + "name": str(provider.get("name") or "Meter driver"), + "base_url": str(provider.get("base_url") or "").strip(), + "openapi_url": str(provider.get("openapi_url") or "").strip(), + "service_version": str(provider.get("service_version") or ""), + "selected_interface": str(provider.get("selected_interface") or "http").strip().lower() + or "http", + "selected_interface_target": str(provider.get("selected_interface_target") or "").strip(), + "enabled": bool(provider.get("enabled", True)), + } + ) + return normalized + + +def save_providers(providers): + """Persist the full providers list.""" + parameter = _providers_parameter() + parameter.value = json.dumps(providers, sort_keys=True) + _cleanup_orphaned_driver_config_files(providers) + + +def get_provider(provider_id): + """Return a configured provider by id.""" + for provider in get_saved_providers(): + if provider["id"] == provider_id: + return provider + return None + + +def get_enabled_provider(): + """Return the provider currently selected for runtime use.""" + providers = get_saved_providers() + for provider in providers: + if provider.get("enabled"): + return provider + return providers[0] if providers else None + + +def _default_config_path(provider_id): + """Return the repo-relative JSON path for a driver config.""" + return "meter_driver_configs/{}.json".format(provider_id) + + +def _config_abspath(config_path): + """Convert a repo-relative config path into an absolute path.""" + return _REPO_ROOT / config_path + + +def _cleanup_orphaned_driver_config_files(providers): + """Delete driver JSON files whose provider ids are no longer saved.""" + expected_filenames = { + "{}.json".format(str((provider or {}).get("id") or "").strip()) + for provider in (providers or []) + if str((provider or {}).get("id") or "").strip() + } + + if not _METER_DRIVER_CONFIG_DIR.exists(): + return + + for config_path in _METER_DRIVER_CONFIG_DIR.glob("*.json"): + if config_path.name in expected_filenames: + continue + try: + config_path.unlink() + except OSError: + # Best-effort cleanup only; persistence of the saved provider + # list should not fail because a stale JSON file could not be removed. + pass + + +def _load_existing_driver_config(config_path): + """Load an existing driver JSON file, if present and valid.""" + if not config_path: + return {} + + try: + with _config_abspath(config_path).open() as handle: + payload = json.load(handle) + except (OSError, ValueError): + return {} + + return payload if isinstance(payload, dict) else {} + + +def _field_values_payload(details, existing_payload): + """Build the editable field_values section from advertised requirements.""" + previous = (existing_payload or {}).get("field_values") or ( + (((existing_payload or {}).get("configure_provider") or {}).get("vendor_options")) or {} + ) + field_values = {} + for field in details.get("driver_requirement_fields") or []: + name = field["name"] + default = field.get("default") + field_values[name] = previous.get(name, default if default is not None else "") + return field_values + + +def _write_driver_config_file(provider_record, details): + """Create or refresh the per-driver JSON config file.""" + config_path = _default_config_path(provider_record["id"]) + existing_payload = _load_existing_driver_config(config_path) + + payload = { + "driver": { + "id": provider_record["id"], + "name": details["name"], + "base_url": details["base_url"], + "openapi_url": details["openapi_url"], + "service_version": details["service_version"], + "selected_interface": provider_record["selected_interface"], + "selected_interface_target": provider_record.get("selected_interface_target", ""), + "enabled": bool(provider_record.get("enabled", True)), + }, + "field_values": _field_values_payload(details, existing_payload), + "required_fields": details.get("driver_requirement_fields") or [], + "init_status": (existing_payload or {}).get("init_status") + or { + "has_successful_init": False, + "last_init_succeeded": False, + "last_init_error": "", + }, + } + + config_abspath = _config_abspath(config_path) + config_abspath.parent.mkdir(parents=True, exist_ok=True) + with config_abspath.open("w") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def get_provider_config_abspath(provider): + """Return the absolute JSON config path for a saved provider.""" + provider_id = str((provider or {}).get("id") or "").strip() + if not provider_id: + return "" + return str(_config_abspath(_default_config_path(provider_id))) + + +def load_provider_runtime_settings(provider): + """Load runtime vendor settings from the provider's JSON file.""" + provider_id = str((provider or {}).get("id") or "").strip() + if not provider_id: + return {} + return _load_existing_driver_config(_default_config_path(provider_id)) + + +def load_provider_config_text(provider): + """Return the editable JSON text for a provider config file.""" + provider_id = str((provider or {}).get("id") or "").strip() + if not provider_id: + return "{}\n" + + config_path = _default_config_path(provider_id) + config_abspath = _config_abspath(config_path) + if config_abspath.exists(): + return config_abspath.read_text() + + payload = { + "driver": { + "id": provider["id"], + "name": provider.get("name", ""), + "base_url": provider.get("base_url", ""), + "openapi_url": provider.get("openapi_url", ""), + "service_version": provider.get("service_version", ""), + "selected_interface": provider.get("selected_interface", "http"), + "selected_interface_target": provider.get("selected_interface_target", ""), + "enabled": bool(provider.get("enabled", True)), + }, + "field_values": {}, + "required_fields": [], + "init_status": { + "has_successful_init": False, + "last_init_succeeded": False, + "last_init_error": "", + }, + } + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def _dump_provider_config_text(payload): + """Serialize a provider config payload in canonical form.""" + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def _normalize_init_status(payload): + """Return a normalized init-status payload.""" + status = (payload or {}).get("init_status") or {} + if not isinstance(status, dict): + status = {} + return { + "has_successful_init": bool(status.get("has_successful_init")), + "last_init_succeeded": bool(status.get("last_init_succeeded")), + "last_init_error": str(status.get("last_init_error") or ""), + } + + +def parse_provider_config_text(config_text): + """Parse driver JSON text into a dict.""" + try: + payload = json.loads(config_text) + except ValueError as exc: + raise DriverConfigError("config JSON is invalid") from exc + if not isinstance(payload, dict): + raise DriverConfigError("config JSON must be an object") + return payload + + +def _required_field_names(payload): + """Return the required field names from a config payload.""" + names = payload.get("required_fields") or [] + if not isinstance(names, list): + raise DriverConfigError("required_fields must be a list") + normalized = [] + for entry in names: + if isinstance(entry, dict): + name = str(entry.get("name") or "").strip() + else: + name = str(entry).strip() + if name: + normalized.append(name) + return normalized + + +def _field_values(payload): + """Return the field_values mapping from a config payload.""" + values = payload.get("field_values") or {} + if not isinstance(values, dict): + raise DriverConfigError("field_values must be an object") + return values + + +def _required_field_specs(payload): + """Return required field metadata keyed by field name.""" + names = payload.get("required_fields") or [] + if not isinstance(names, list): + raise DriverConfigError("required_fields must be a list") + + specs = {} + for entry in names: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "").strip() + if name: + specs[name] = entry + return specs + + +def _coerce_field_value(name, value, spec): + """Coerce a raw JSON field value to the type required by the driver.""" + field_type = str((spec or {}).get("type") or "string").strip().lower() + if field_type == "integer": + try: + return int(value) + except (TypeError, ValueError) as exc: + raise DriverConfigError("field {!r} must be an integer".format(name)) from exc + if field_type == "number": + try: + return float(value) + except (TypeError, ValueError) as exc: + raise DriverConfigError("field {!r} must be a number".format(name)) from exc + if field_type == "boolean": + if isinstance(value, bool): + return value + normalized = str(value).strip().lower() + if normalized in ("true", "1", "yes", "on"): + return True + if normalized in ("false", "0", "no", "off"): + return False + raise DriverConfigError("field {!r} must be a boolean".format(name)) + return value + + +def validate_provider_config_payload(payload): + """Validate required field presence in a driver config payload.""" + required_fields = _required_field_names(payload) + required_field_specs = _required_field_specs(payload) + field_values = _field_values(payload) + missing = [ + name for name in required_fields if name not in field_values or field_values[name] in (None, "") + ] + if missing: + raise DriverConfigError("required fields are missing values: {}".format(", ".join(missing))) + coerced_field_values = { + name: _coerce_field_value(name, value, required_field_specs.get(name)) + for name, value in field_values.items() + } + return { + "required_fields": required_fields, + "field_values": coerced_field_values, + } + + +def save_provider_config_text(provider, config_text): + """Persist edited JSON text for a provider.""" + payload = parse_provider_config_text(config_text) + validated = validate_provider_config_payload(payload) + payload["init_status"] = _normalize_init_status(payload) + config_abspath = Path(get_provider_config_abspath(provider)) + config_abspath.parent.mkdir(parents=True, exist_ok=True) + config_abspath.write_text(_dump_provider_config_text(payload)) + return payload, validated + + +def init_provider_from_payload(provider, payload, timeout=10.0): + """Attempt driver initialization using the saved field_values payload.""" + validated = validate_provider_config_payload(payload) + init_status = _normalize_init_status(payload) + config_abspath = Path(get_provider_config_abspath(provider)) + provider_name = ( + str((provider or {}).get("name") or "").strip() + or str((provider or {}).get("base_url") or "").strip() + or "meter driver" + ) + try: + from sparkmeter.metering.runtime_client import initialize_provider_sync + + provider_details = get_live_interface_details( + provider["base_url"], + selected_interface=provider.get("selected_interface"), + timeout=timeout, + ) + initialize_provider_sync( + provider, + validated["field_values"], + provider_details=provider_details, + ) + except Exception as exc: + init_status["last_init_succeeded"] = False + detail = str(exc) + init_status["last_init_error"] = detail + payload["init_status"] = init_status + config_abspath.write_text(_dump_provider_config_text(payload)) + if detail: + raise DriverInitializationError("driver init failed: {}".format(detail)) from exc + raise DriverInitializationError("driver init failed") from exc + init_status["has_successful_init"] = True + init_status["last_init_succeeded"] = True + init_status["last_init_error"] = "" + payload["init_status"] = init_status + config_abspath.write_text(_dump_provider_config_text(payload)) + logger.info("meter driver init succeeded for %s; gateway initialized", provider_name) + + +def get_provider_init_status(provider): + """Return persisted init status for a provider.""" + payload = load_provider_runtime_settings(provider) + return _normalize_init_status(payload) + + +def initialize_configured_providers_on_startup(timeout=10.0): + """Attempt init for every saved provider with a complete JSON config.""" + results = [] + for provider in get_saved_providers(): + payload = load_provider_runtime_settings(provider) + if not payload: + results.append( + { + "provider": provider, + "attempted": False, + "success": False, + "reason": "missing config payload", + } + ) + continue + + try: + validate_provider_config_payload(payload) + except DriverConfigError as exc: + results.append( + { + "provider": provider, + "attempted": False, + "success": False, + "reason": str(exc), + } + ) + continue + + try: + init_provider_from_payload(provider, payload, timeout=timeout) + except DriverInitializationError as exc: + results.append( + { + "provider": provider, + "attempted": True, + "success": False, + "reason": str(exc), + } + ) + continue + + results.append( + { + "provider": provider, + "attempted": True, + "success": True, + "reason": "", + } + ) + + return results + + +def save_provider_settings( + service_url, selected_interface, enabled=True, provider_id=None, aes_key="", channel="" +): + """Persist a meter driver entry and return its id.""" + details = validate_contract(service_url) + selected_interface = (selected_interface or details["default_interface"]).strip().lower() + valid_interfaces = {interface["type"] for interface in details.get("interfaces") or []} + if selected_interface not in valid_interfaces: + selected_interface = details["default_interface"] + + selected_interface_details = next( + ( + interface + for interface in details.get("interfaces") or [] + if interface.get("type") == selected_interface + ), + {}, + ) + + providers = get_saved_providers() + saved_provider_id = provider_id or uuid.uuid4().hex + provider_record = { + "id": saved_provider_id, + "name": details["name"], + "base_url": details["base_url"], + "openapi_url": details["openapi_url"], + "service_version": details["service_version"], + "selected_interface": selected_interface, + "selected_interface_target": str( + (selected_interface_details.get("target") or selected_interface_details.get("address") or "") + ).strip(), + "enabled": bool(enabled), + } + + replaced = False + updated = [] + for provider in providers: + if provider["id"] == saved_provider_id: + updated.append(provider_record) + replaced = True + else: + updated.append(provider) + if not replaced: + updated.append(provider_record) + + _write_driver_config_file(provider_record, details) + save_providers(updated) + return saved_provider_id + + +def normalize_base_url(service_url): + """Normalize a service URL or openapi URL to the provider base URL.""" + url = (service_url or "").strip().rstrip("/") + if not url: + raise ProviderRegistrationError("driver service URL is required") + if url.endswith("/openapi.json"): + url = url[: -len("/openapi.json")] + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + raise ProviderRegistrationError("driver service URL must include scheme and host") + return url + + +def get_openapi_url(service_url): + """Build the OpenAPI URL from a service URL.""" + return normalize_base_url(service_url) + "/openapi.json" + + +def _normalize_interface_metadata(base_url, spec): + """Extract the advertised interface inventory from a provider contract.""" + extension = spec.get("x-open-thunder") or {} + interface_entries = extension.get("interfaces") or [] + + interfaces = [] + seen_types = set() + for entry in interface_entries: + if not isinstance(entry, dict): + continue + + interface_type = str(entry.get("type") or "").strip().lower() + if not interface_type or interface_type in seen_types: + continue + + interface = { + "type": interface_type, + "label": str(entry.get("label") or interface_type.upper()), + } + if entry.get("base_url"): + interface["base_url"] = str(entry["base_url"]) + interface["address"] = interface["base_url"] + elif entry.get("target"): + interface["target"] = str(entry["target"]) + interface["address"] = interface["target"] + else: + interface["address"] = "" + + interfaces.append(interface) + seen_types.add(interface_type) + + if "http" not in seen_types: + interfaces.insert( + 0, + { + "type": "http", + "label": "HTTP API", + "base_url": base_url, + "address": base_url, + }, + ) + seen_types.add("http") + + default_interface = str(extension.get("default_interface") or "").strip().lower() + if default_interface not in seen_types: + default_interface = ( + "http" if "http" in seen_types else (interfaces[0]["type"] if interfaces else "http") + ) + + return { + "interfaces": interfaces, + "default_interface": default_interface, + } + + +def _apply_selected_interface(details, selected_interface=None): + """Attach the selected interface and its live details to a metadata dict.""" + interface_map = {interface["type"]: interface for interface in details.get("interfaces") or []} + selected = str(selected_interface or "").strip().lower() + if not selected: + selected = details.get("default_interface") or "" + + if selected not in interface_map: + selected = details.get("default_interface") or next(iter(interface_map), "http") + + details["selected_interface"] = selected + details["selected_interface_details"] = interface_map.get(selected) + return details + + +def _fallback_interface_metadata(base_url, selected_interface=None): + """Return a conservative interface inventory when live discovery fails.""" + interfaces = [ + { + "type": "http", + "label": "HTTP API", + "base_url": base_url, + "address": base_url, + } + ] + normalized_selected = str(selected_interface or "").strip().lower() + if normalized_selected and normalized_selected != "http": + interfaces.append( + { + "type": normalized_selected, + "label": normalized_selected.upper(), + "address": "", + } + ) + + return _apply_selected_interface( + { + "name": "", + "base_url": base_url, + "openapi_url": get_openapi_url(base_url), + "service_version": "", + "driver_requirement_fields": [], + "driver_requirement_field_map": {}, + "vendor_option_fields": [], + "vendor_option_field_map": {}, + "interfaces": interfaces, + "default_interface": "http", + }, + selected_interface=selected_interface, + ) + + +def validate_contract(service_url, timeout=10.0): + """Fetch and validate the provider's OpenAPI contract.""" + base_url = normalize_base_url(service_url) + openapi_url = get_openapi_url(service_url) + try: + response = httpx.get(openapi_url, timeout=timeout) + response.raise_for_status() + except httpx.HTTPError as exc: + raise ProviderRegistrationError("could not fetch driver OpenAPI contract") from exc + + try: + spec = response.json() + except ValueError as exc: + raise ProviderRegistrationError("driver returned invalid JSON") from exc + + info = spec.get("info") or {} + paths = spec.get("paths") or {} + required_paths = ("/v1/commands", "/v1/events") + missing_paths = [path for path in required_paths if path not in paths] + if missing_paths: + raise ProviderRegistrationError( + "driver contract missing required paths: {}".format(", ".join(missing_paths)) + ) + + name = info.get("title") + if not name: + raise ProviderRegistrationError("driver contract missing info.title") + + driver_requirement_fields = _extract_driver_requirement_fields(base_url, spec, timeout=timeout) + + return { + "name": str(name), + "base_url": base_url, + "openapi_url": openapi_url, + "service_version": str(info.get("version") or ""), + "driver_requirement_fields": driver_requirement_fields, + "driver_requirement_field_map": {field["name"]: field for field in driver_requirement_fields}, + "vendor_option_fields": _extract_vendor_option_fields(spec), + "vendor_option_field_map": _vendor_option_field_map(spec), + **_normalize_interface_metadata(base_url, spec), + } + + +def get_live_interface_details(service_url, selected_interface=None, timeout=2.0): + """Fetch the current interface inventory advertised by the provider.""" + base_url = normalize_base_url(service_url) + try: + provider_data = validate_contract(base_url, timeout=timeout) + except ProviderRegistrationError as exc: + details = _fallback_interface_metadata( + base_url, + selected_interface=selected_interface, + ) + details["error"] = str(exc) + return details + + return _apply_selected_interface( + provider_data, + selected_interface=selected_interface, + ) + + +def get_runtime_status(service_url, timeout=2.0, include_gateway_status=True): + """Check whether the provider service is currently reachable.""" + base_url = normalize_base_url(service_url) + healthz_url = base_url.rstrip("/") + "/v1/healthz" + legacy_health_url = base_url.rstrip("/") + "/health" + status_url = base_url.rstrip("/") + "/v1/status" + urls = (healthz_url, legacy_health_url) + last_error = None + for url in urls: + try: + response = httpx.get(url, timeout=timeout) + response.raise_for_status() + status = { + "online": True, + "message": "online", + "checked_url": url, + "gateway_checked": bool(include_gateway_status), + } + if not include_gateway_status: + status["gateway_active"] = False + status["gateway_type"] = None + return status + try: + gateway_response = httpx.get(status_url, timeout=timeout) + gateway_response.raise_for_status() + gateway_data = gateway_response.json() + status["gateway_active"] = bool(gateway_data.get("connected")) + status["gateway_type"] = gateway_data.get("gateway_type") + except (httpx.HTTPError, ValueError): + status["gateway_checked"] = True + status["gateway_active"] = False + status["gateway_type"] = None + return status + except httpx.HTTPError as exc: + last_error = exc + + return { + "online": False, + "message": str(last_error) if last_error is not None else "unreachable", + "checked_url": healthz_url, + "gateway_checked": bool(include_gateway_status), + "gateway_active": False, + "gateway_type": None, + } diff --git a/sparkmeter/config/providerform.py b/sparkmeter/config/providerform.py new file mode 100644 index 0000000..55dedad --- /dev/null +++ b/sparkmeter/config/providerform.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +"""Forms for meter driver configuration.""" + +from flask.helpers import url_for +from flask_babel import lazy_gettext as _ +from werkzeug.utils import redirect +from wtforms.fields import BooleanField, SelectField, StringField, SubmitField, TextAreaField +from wtforms.validators import ValidationError + +from sparkmeter.config import provider_settings +from sparkmeter.database.alchemy import sql +from sparkmeter.web.forms import BaseForm + + +class MeterDriverSettingsForm(BaseForm): + """Configure the standalone meter driver URL and interface.""" + + template_filename = "config-meter-driver-form.html" + mode = "add" + + service_url = StringField(_("Driver service URL")) + aes_key = StringField(_("AES key")) + channel = StringField(_("Channel")) + selected_interface = SelectField(_("Selected interface"), choices=[], validate_choice=False) + enabled = BooleanField(_("Enabled"), default=True) + save_button = SubmitField(_("Save")) + + def __init__(self, *args, **kwargs): + self.provider_details = kwargs.pop("provider_details", None) + self.provider = kwargs.pop("provider", None) + super(MeterDriverSettingsForm, self).__init__(*args, **kwargs) + self.mode = "edit" if self.provider is not None else "add" + + if self.provider is not None and not self.service_url.data: + self.service_url.data = self.provider["base_url"] + self.aes_key.data = self.provider.get("aes_key", "") + self.channel.data = self.provider.get("channel", "") + self.enabled.data = bool(self.provider.get("enabled", True)) + + if self.provider_details is None and self.service_url.data: + self.provider_details = provider_settings.get_live_interface_details( + self.service_url.data, + selected_interface=( + self.provider["selected_interface"] if self.provider is not None else None + ), + ) + + if self.provider_details is not None: + self._set_interface_choices(self.provider_details) + self._apply_vendor_option_labels() + + if not self.selected_interface.data: + self.selected_interface.data = ( + self.provider["selected_interface"] if self.provider is not None else None + ) or self._default_selected_interface() + + def vendor_option_spec(self, name): + """Return the normalized driver-requirement spec for a known field.""" + provider_data = self.provider_details or {} + return (provider_data.get("driver_requirement_field_map") or {}).get(name) + + def supports_vendor_option(self, name): + """Whether the validated provider advertises the given vendor option.""" + return self.vendor_option_spec(name) is not None + + def vendor_option_description(self, name): + """Return help text for a vendor option field.""" + spec = self.vendor_option_spec(name) or {} + return spec.get("description") or "" + + def vendor_option_required(self, name): + """Whether the vendor option is required by the contract.""" + spec = self.vendor_option_spec(name) or {} + return bool(spec.get("required")) + + def vendor_option_fields(self): + """Return the contract-advertised driver requirement field list.""" + provider_data = self.provider_details or {} + return provider_data.get("driver_requirement_fields") or [] + + def config_directory(self): + """Return the absolute directory where driver JSON files are created.""" + path = provider_settings.get_provider_config_abspath({"id": "example"}) + return path.rsplit("/", 1)[0] if path else "" + + def config_file_path(self): + """Return the absolute JSON config path for an existing provider.""" + if self.provider is None: + return "" + return provider_settings.get_provider_config_abspath(self.provider) + + def _interface_choice_label(self, interface): + """Format a display label for an advertised interface.""" + address = interface.get("base_url") or interface.get("target") or interface.get("address") or "" + if address: + return "{} ({})".format(interface.get("label") or interface["type"], address) + return interface.get("label") or interface["type"] + + def _set_interface_choices(self, provider_data): + """Populate the interface selector from live provider metadata.""" + interfaces = (provider_data or {}).get("interfaces") or [] + self.selected_interface.choices = [ + (interface["type"], self._interface_choice_label(interface)) for interface in interfaces + ] + + def _default_selected_interface(self): + """Return the preferred interface from the current provider metadata.""" + provider_data = self.provider_details or {} + if provider_data.get("selected_interface"): + return provider_data["selected_interface"] + if provider_data.get("default_interface"): + return provider_data["default_interface"] + if self.selected_interface.choices: + return self.selected_interface.choices[0][0] + return "http" + + def _apply_vendor_option_labels(self): + """Update known vendor-option labels from the validated contract.""" + aes_key_spec = self.vendor_option_spec("aes_key") + if aes_key_spec and aes_key_spec.get("label"): + self.aes_key.label.text = aes_key_spec["label"] + + channel_spec = self.vendor_option_spec("channel") + if channel_spec and channel_spec.get("label"): + self.channel.label.text = channel_spec["label"] + + def validate_service_url(self, field): + if not (field.data or "").strip(): + self.provider_details = None + self.selected_interface.choices = [("http", "HTTP API")] + return + + try: + self.provider_details = provider_settings.validate_contract(field.data) + self._set_interface_choices(self.provider_details) + self._apply_vendor_option_labels() + except provider_settings.ProviderRegistrationError as exc: + raise ValidationError(str(exc)) + + def validate_selected_interface(self, field): + if not (self.service_url.data or "").strip(): + return + + provider_data = getattr(self, "provider_details", None) + if not provider_data or not field.data: + return + + valid_interfaces = {interface["type"] for interface in provider_data.get("interfaces") or []} + if field.data not in valid_interfaces: + raise ValidationError(_("Selected interface is not available from this driver.")) + + def validate_aes_key(self, field): + field.data = "" + + def validate_channel(self, field): + field.data = "" + + def save(self): + """Persist the current meter driver settings.""" + if not (self.service_url.data or "").strip(): + raise RuntimeError("service_url is required for meter drivers") + + selected_interface = self.selected_interface.data or self._default_selected_interface() + provider_id = provider_settings.save_provider_settings( + self.service_url.data, + selected_interface, + enabled=self.enabled.data, + provider_id=self.provider["id"] if self.provider is not None else None, + ) + self.saved_provider_id = provider_id + sql.session.commit() + return provider_id + + def notification_message(self): + """Return the success message after saving.""" + config_path = provider_settings.get_provider_config_abspath( + { + "id": getattr(self, "saved_provider_id", None), + } + ) + if self.mode == "edit": + return _( + "Meter driver updated. Edit %(path)s to fill the required driver fields.", path=config_path + ) + return _( + "Meter driver registered. Edit %(path)s to fill the required driver fields.", path=config_path + ) + + def redirect(self): + """Return to the meter drivers list page.""" + return redirect(url_for("config.meter_driver")) + + +class MeterDriverConfigEditorForm(BaseForm): + """Edit the generated JSON config for a meter driver.""" + + template_filename = "config-meter-driver-config-editor.html" + + config_text = TextAreaField(_("Driver config JSON")) + save_button = SubmitField(_("Save")) + cancel_button = SubmitField(_("Cancel")) + + def __init__(self, *args, **kwargs): + self.provider = kwargs.pop("provider") + self.provider_details = kwargs.pop("provider_details") + super(MeterDriverConfigEditorForm, self).__init__(*args, **kwargs) + + if not self.config_text.data: + self.config_text.data = provider_settings.load_provider_config_text(self.provider) + + def required_fields(self): + """Return the driver-required field list.""" + return (self.provider_details or {}).get("driver_requirement_fields") or [] + + def config_file_path(self): + """Return the absolute config path for the current provider.""" + return provider_settings.get_provider_config_abspath(self.provider) + + def save_and_init(self): + """Persist the JSON file and initialize the driver.""" + payload, _validated = provider_settings.save_provider_config_text( + self.provider, + self.config_text.data or "{}", + ) + provider_settings.init_provider_from_payload(self.provider, payload) + + def redirect(self): + """Return to the meter driver list page.""" + return redirect(url_for("config.meter_driver")) diff --git a/sparkmeter/config/templates/config-meter-driver-config-editor.html b/sparkmeter/config/templates/config-meter-driver-config-editor.html new file mode 100644 index 0000000..dd6d397 --- /dev/null +++ b/sparkmeter/config/templates/config-meter-driver-config-editor.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{%- from "_macros.html" import form_error %} + +{% block title %}{{ icon('config') }} {{ _('Edit driver config') }}{% endblock %} + +{% block breadcrumbs %} + {% call breadcrumb() %} + {{ icon('settings') }} {{ _('Settings') }} + {% endcall %} + {% call breadcrumb(url=url_for('config.meter_driver')) %} + {{ icon('config') }} {{ _('Meter drivers') }} + {% endcall %} + {% call breadcrumb() %} + {{ icon('edit') }} {{ _('Edit driver config') }} + {% endcall %} +{% endblock breadcrumbs %} + +{% block content %} +
+
+ {% call render_box(_('Edit driver config JSON'), box_content_class='padded') %} +

{{ _('Update the generated JSON file, then save to write it and immediately attempt driver init.') }}

+

{{ _('Driver') }}: {{ provider_details.name if provider_details and provider_details.name else provider.name }}

+

{{ _('File') }}: {{ form.config_file_path() }}

+ + {% if form.required_fields() %} +

{{ _('Required fields') }}:

+
    + {% for option in form.required_fields() %} +
  • + {{ option.label }} + {% if option.description %} + : {{ option.description }} + {% endif %} +
  • + {% endfor %} +
+ {% endif %} + +
+ {{ form.hidden_tag() }} +
+ {{ form.config_text.label(class="control-label") }} + {{ form.config_text(class="form-control", rows="22", style="font-family: monospace;") }} + {{ form_error(form.config_text) }} +
+
+ {{ form.save_button(class="btn btn-primary") }} + {{ form.cancel_button(class="btn btn-default") }} +
+
+ {% endcall %} +
+
+{% endblock %} diff --git a/sparkmeter/config/templates/config-meter-driver-form.html b/sparkmeter/config/templates/config-meter-driver-form.html new file mode 100644 index 0000000..7dc8429 --- /dev/null +++ b/sparkmeter/config/templates/config-meter-driver-form.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{%- from "_macros.html" import form_error %} + +{% block title %}{{ icon('config') }} {{ _('Edit meter driver') if form.mode == 'edit' else _('Register meter driver') }}{% endblock %} + +{% block breadcrumbs %} + {% call breadcrumb() %} + {{ icon('settings') }} {{ _('Settings') }} + {% endcall %} + {% call breadcrumb(url=url_for('config.meter_driver')) %} + {{ icon('config') }} {{ _('Meter drivers') }} + {% endcall %} + {% call breadcrumb() %} + {% if form.mode == 'edit' %} + {{ icon('edit') }} {{ _('Edit meter driver') }} + {% else %} + {{ icon('add') }} {{ _('Register meter driver') }} + {% endif %} + {% endcall %} +{% endblock breadcrumbs %} + +{% block content %} +
+
+ {% call render_box(_('Edit meter driver') if form.mode == 'edit' else _('Register meter driver'), box_content_class='padded') %} +

{{ _('Paste the base URL for a running meter driver service, for example SparkNet-Http. TC2.0 will validate its OpenAPI contract before saving it.') }}

+
+ {{ form.hidden_tag() }} + {{ addon_form_field(form.service_url, form_type="horizontal", horizontal_columns=('lg', 2, 10)) }} + {% if form.vendor_option_fields() %} +
+ +
+ {% if form.config_file_path() %} +

+ {{ _('Edit this file after saving:') }}
+ {{ form.config_file_path() }} +

+ {% else %} +

+ {{ _('After saving, TC2.0 will create a JSON template under:') }}
+ {{ form.config_directory() }} +

+ {% endif %} +
+
+
+ +
+ {% for option in form.vendor_option_fields() %} +
+ {{ option.label }} + {% if option.required %} + ({{ _('required') }}) + {% else %} + ({{ _('optional') }}) + {% endif %} + {% if option.description %} +
{{ option.description }}
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + {% if form.selected_interface.choices %} +
+ {{ form.selected_interface.label(class="control-label col-lg-2") }} +
+ {{ form.selected_interface(class="form-control") }} +
+
+
+ {{ form_error(form.selected_interface) }} +
+ {% else %} +
+ +
+

+ {{ _('Available interfaces will be detected from the driver after validation. HTTP is used by default until you choose another interface.') }} +

+
+
+ {% endif %} +
+ {{ form.enabled.label(class="control-label col-lg-2") }} +
+ {{ form.enabled(class="form-control") }} +
+
+ {{ addon_form_field(form.save_button, form_type="horizontal", horizontal_columns=('lg', 2, 10), button_map={'save_button': 'primary'}) }} +
+ {% endcall %} +
+
+{% endblock %} diff --git a/sparkmeter/config/templates/config-meter-driver-list.html b/sparkmeter/config/templates/config-meter-driver-list.html new file mode 100644 index 0000000..0e4d57c --- /dev/null +++ b/sparkmeter/config/templates/config-meter-driver-list.html @@ -0,0 +1,150 @@ +{% extends "base.html" %} + +{% block title %}{{ icon('config') }} {{ _('Meter drivers') }}{% endblock %} + +{% block breadcrumbs %} + {% call breadcrumb() %} + {{ icon('settings') }} {{ _('Settings') }} + {% endcall %} + {% call breadcrumb() %} + {{ icon('config') }} {{ _('Meter drivers') }} + {% endcall %} +{% endblock breadcrumbs %} + +{% block content %} +
+
+ {% call render_box(_('Meter drivers')) %} +
+

{{ _('Register a standalone meter driver such as SparkNet-Http by entering the base URL of its HTTP service.') }}

+

+ + {{ _('Register driver') }} + +

+ + + + + + + + + + + + + + + + + + {% for provider in providers %} + {% set status = provider_statuses.get(provider.id) %} + {% set contract = provider_contracts.get(provider.id) %} + {% set config_path = provider_config_paths.get(provider.id) %} + {% set init_status = provider_init_statuses.get(provider.id) %} + + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ _('Init') }}{{ _('Driver name') }}{{ _('Base URL') }}{{ _('Config JSON') }}{{ _('Version') }}{{ _('Available interfaces') }}{{ _('Selected interface') }}{{ _('Enabled') }}{{ _('Status') }}{{ _('Gateway') }}{{ _('Actions') }}
+ {% if init_status and init_status.has_successful_init %} + + + + {% else %} + + + + {% if init_status and init_status.last_init_error %} +
{{ init_status.last_init_error }}
+ {% endif %} + {% endif %} +
{{ contract.name if contract and contract.name else provider.name }}{{ provider.base_url }} + {% if config_path %} + + {{ config_path }} + + {% else %} + {{ _('Unavailable') }} + {% endif %} + {{ contract.service_version if contract and contract.service_version else provider.service_version }} + {% if contract and contract.interfaces %} + {% for interface in contract.interfaces %} +
+ {{ interface.label }} + ({{ interface.type }}) + {% if interface.address %} +
{{ interface.address }}
+ {% endif %} +
+ {% endfor %} + {% else %} + {{ _('Unavailable') }} + {% endif %} +
+ {% if contract and contract.selected_interface_details %} + {{ contract.selected_interface_details.label }} + ({{ contract.selected_interface_details.type }}) + {% if contract.selected_interface_details.address %} +
{{ contract.selected_interface_details.address }}
+ {% endif %} + {% elif contract and contract.selected_interface %} + {{ contract.selected_interface|upper }} + {% else %} + {{ _('Default') }} + {% endif %} +
{{ _('Yes') if provider.enabled else _('No') }} + {% if status and status.online %} + {{ _('Online') }} + {% else %} + {{ _('Offline') }} + {% if status %} +
{{ status.message }}
+ {% endif %} + {% endif %} +
+ {% if status and not status.gateway_checked %} + + {{ _('Not initialized') }} + + {% elif status and status.gateway_active %} + + + {{ _('Gateway Active') }} + + {% if status.gateway_type %} +
{{ status.gateway_type }}
+ {% endif %} + {% else %} + + + {{ _('Gateway Inactive') }} + + {% endif %} +
+ + {{ _('Edit') }} + +
{{ _('No meter drivers registered yet.') }}
+
+ {% endcall %} +
+
+{% endblock %} diff --git a/sparkmeter/config/tests/test_configparametertypes.py b/sparkmeter/config/tests/test_configparametertypes.py index 1df1ecf..a93fec1 100644 --- a/sparkmeter/config/tests/test_configparametertypes.py +++ b/sparkmeter/config/tests/test_configparametertypes.py @@ -4,7 +4,7 @@ import pytest from testfixtures import LogCapture -from sparkmeter.config.configparametertypes import Bool, Float, ParameterType, Percent, Voltage +from sparkmeter.config.configparametertypes import Bool, Float, ParameterType, Percent, String, Voltage def test_abstract(): @@ -72,12 +72,13 @@ def test_float_to_python(): def test_percent(): p = Percent() - for v, msg in [ - (-1, "value cannot be less than 0.0, defaulting to 0.0."), - (101, "value cannot be more than 100.0, defaulting to 0.0."), + # Below the minimum clamps up to 0; above the maximum clamps down to 100. + for v, expected, msg in [ + (-1, "0.0", "value cannot be less than 0.0, defaulting to 0.0."), + (101, "100.0", "value cannot be more than 100.0, defaulting to 100.0."), ]: with LogCapture("sparkmeter.config.configparametertypes") as log: - assert p.from_python(v) == "0.0" + assert p.from_python(v) == expected log.check(("sparkmeter.config.configparametertypes", "WARNING", msg)) log.clear() @@ -90,3 +91,18 @@ def test_voltage(): v.to_python("113") with pytest.raises(TypeError): v.from_python("113") + + +def test_string(): + s = String() + assert s.to_python(None) == "" + assert s.to_python(123) == "123" + assert s.from_python(None) == "" + assert s.from_python("hello") == "hello" + with pytest.raises(TypeError) as exc: + s.from_python(123) + assert str(exc.value) == "string parameters must be strings, not 'int'." + # bool is an int subclass but still not a str, so it is rejected too. + with pytest.raises(TypeError) as exc: + s.from_python(True) + assert str(exc.value) == "string parameters must be strings, not 'bool'." diff --git a/sparkmeter/config/tests/test_configviews.py b/sparkmeter/config/tests/test_configviews.py index cca1d5a..ceff2c8 100644 --- a/sparkmeter/config/tests/test_configviews.py +++ b/sparkmeter/config/tests/test_configviews.py @@ -3,7 +3,74 @@ # All Rights Reserved. """Config views unittest.""" +import http.client + +from sparkmeter.config import configviews, provider_settings +from sparkmeter.config.configviews import _live_metering_activation_flash from sparkmeter.tests.base import WebViewTestCaseBase +from sparkmeter.tests.test_data_factory import MeterFactory + + +def _provider_record(provider_id="driver-1"): + """A saved-provider dict shaped like get_saved_providers() output.""" + return { + "id": provider_id, + "name": "SparkNet-Http", + "base_url": "http://127.0.0.1:18080", + "openapi_url": "http://127.0.0.1:18080/openapi.json", + "service_version": "1.2.3", + "selected_interface": "http", + "selected_interface_target": "", + "enabled": True, + } + + +def _interface_details(): + """A deterministic get_live_interface_details() payload with no driver fields.""" + return { + "name": "SparkNet-Http", + "base_url": "http://127.0.0.1:18080", + "openapi_url": "http://127.0.0.1:18080/openapi.json", + "service_version": "1.2.3", + "driver_requirement_fields": [], + "driver_requirement_field_map": {}, + "vendor_option_fields": [], + "vendor_option_field_map": {}, + "interfaces": [ + { + "type": "http", + "label": "HTTP API", + "base_url": "http://127.0.0.1:18080", + "address": "http://127.0.0.1:18080", + } + ], + "default_interface": "http", + "selected_interface": "http", + "selected_interface_details": { + "type": "http", + "label": "HTTP API", + "address": "http://127.0.0.1:18080", + }, + } + + +class LiveMeteringFlashTest(WebViewTestCaseBase): + def test_no_loop_without_meters_defers_to_first_meter(self, session): + message, category = _live_metering_activation_flash("main event loop is not available") + assert category == "info" + assert "add a meter" in str(message) + + def test_no_loop_with_meters_defers_to_next_start(self, session): + MeterFactory() + session.commit() + message, category = _live_metering_activation_flash("main event loop is not available") + assert category == "info" + assert "next time" in str(message) + + def test_other_activation_error_is_generic(self, session): + message, category = _live_metering_activation_flash("some unexpected error") + assert category == "info" + assert "not active yet" in str(message) class ConfigTest(WebViewTestCaseBase): @@ -31,3 +98,306 @@ def test_meters(self, client, config): path = "/config/meters" response = client.get(path) self.verify_response(response) + + def test_meter_driver(self, client, config): + client.login_as(self.user) + config["HEROKU"] = True + path = "/config/meter-driver" + response = client.get(path) + self.verify_response(response) + + def test_meter_driver_with_saved_provider(self, client, config, monkeypatch): + # Exercises the per-provider status loop (configviews lines 99-108). + config["HEROKU"] = True + provider = _provider_record() + monkeypatch.setattr(configviews, "get_saved_providers", lambda: [provider]) + monkeypatch.setattr( + configviews, + "get_provider_init_status", + lambda p: { + "has_successful_init": True, + "last_init_succeeded": True, + "last_init_error": "", + }, + ) + monkeypatch.setattr( + configviews, + "get_live_interface_details", + lambda base_url, selected_interface=None: _interface_details(), + ) + seen = {} + + def fake_runtime_status(base_url, include_gateway_status=True): + seen["include_gateway_status"] = include_gateway_status + return { + "online": True, + "message": "online", + "gateway_checked": True, + "gateway_active": True, + "gateway_type": "sparknet", + } + + monkeypatch.setattr(configviews, "get_runtime_status", fake_runtime_status) + monkeypatch.setattr( + configviews, + "get_provider_config_abspath", + lambda p: "/srv/meter_driver_configs/driver-1.json", + ) + + response = client.get("/config/meter-driver") + + # has_successful_init is True, so gateway status must be requested. + assert seen["include_gateway_status"] is True + self.verify_response(response) + + def test_meter_driver_add_get(self, client, config): + # Renders the empty registration form (configviews lines 124-126, 133). + config["HEROKU"] = True + response = client.get("/config/meter-driver/add") + self.verify_response(response) + + def test_meter_driver_add_post_saves(self, client, config, monkeypatch): + # POST with a valid contract saves and redirects (configviews lines 128-131). + config["HEROKU"] = True + details = _interface_details() + monkeypatch.setattr( + provider_settings, + "get_live_interface_details", + lambda service_url, selected_interface=None, timeout=2.0: details, + ) + monkeypatch.setattr( + provider_settings, + "validate_contract", + lambda service_url, timeout=10.0: details, + ) + saved = {} + + def fake_save( + service_url, selected_interface, enabled=True, provider_id=None, aes_key="", channel="" + ): + saved["service_url"] = service_url + saved["selected_interface"] = selected_interface + saved["enabled"] = enabled + saved["provider_id"] = provider_id + return "new-driver-id" + + monkeypatch.setattr(provider_settings, "save_provider_settings", fake_save) + + response = client.post( + "/config/meter-driver/add", + data={ + "service_url": "http://127.0.0.1:18080", + "selected_interface": "http", + "enabled": "y", + "save_button": "Save", + }, + ) + + assert response.status_code == http.client.FOUND + assert response.headers["Location"].endswith("/config/meter-driver") + assert saved["service_url"] == "http://127.0.0.1:18080" + assert saved["selected_interface"] == "http" + assert saved["enabled"] is True + assert saved["provider_id"] is None + + def test_meter_driver_edit_get(self, client, config, monkeypatch): + # Renders the edit form for a saved provider (configviews lines 140-160). + config["HEROKU"] = True + provider = _provider_record() + monkeypatch.setattr( + configviews, + "get_provider", + lambda provider_id: provider if provider_id == provider["id"] else None, + ) + monkeypatch.setattr( + configviews, + "get_live_interface_details", + lambda base_url, selected_interface=None: _interface_details(), + ) + + response = client.get("/config/meter-driver/driver-1/edit") + self.verify_response(response) + + def test_meter_driver_edit_not_found(self, client, config): + # Unknown provider ids abort with 404 (configviews lines 141-142). + config["HEROKU"] = True + response = client.get("/config/meter-driver/does-not-exist/edit") + assert response.status_code == http.client.NOT_FOUND + + def test_meter_driver_config_get(self, client, config, monkeypatch): + # Renders the JSON config editor (configviews lines 167-179, 202). + config["HEROKU"] = True + provider = _provider_record("config-driver") + monkeypatch.setattr( + configviews, + "get_provider", + lambda provider_id: provider if provider_id == provider["id"] else None, + ) + monkeypatch.setattr( + configviews, + "get_live_interface_details", + lambda base_url, selected_interface=None: _interface_details(), + ) + monkeypatch.setattr( + provider_settings, + "get_provider_config_abspath", + lambda p: "/srv/meter_driver_configs/config-driver.json", + ) + monkeypatch.setattr( + provider_settings, + "load_provider_config_text", + lambda p: '{\n "field_values": {}\n}\n', + ) + + response = client.get("/config/meter-driver/config-driver/config") + self.verify_response(response) + + def test_meter_driver_config_not_found(self, client, config): + # Unknown provider ids abort with 404 (configviews lines 168-169). + config["HEROKU"] = True + response = client.get("/config/meter-driver/nope/config") + assert response.status_code == http.client.NOT_FOUND + + def _patch_config_editor(self, monkeypatch, provider): + monkeypatch.setattr( + configviews, + "get_provider", + lambda provider_id: provider if provider_id == provider["id"] else None, + ) + monkeypatch.setattr( + configviews, + "get_live_interface_details", + lambda base_url, selected_interface=None: _interface_details(), + ) + monkeypatch.setattr( + provider_settings, + "get_provider_config_abspath", + lambda p: "/srv/meter_driver_configs/config-driver.json", + ) + monkeypatch.setattr( + provider_settings, + "load_provider_config_text", + lambda p: '{\n "field_values": {}\n}\n', + ) + + def test_meter_driver_config_post_cancel(self, client, config, monkeypatch): + # Cancel button redirects to the list without persisting (configviews 181-183). + config["HEROKU"] = True + provider = _provider_record("config-driver") + self._patch_config_editor(monkeypatch, provider) + save_calls = [] + monkeypatch.setattr( + provider_settings, + "save_provider_config_text", + lambda p, text: save_calls.append((p, text)) or ({}, {}), + ) + + response = client.post( + "/config/meter-driver/config-driver/config", + data={"cancel_button": "Cancel"}, + ) + assert response.status_code == http.client.FOUND + assert response.headers["Location"].endswith("/config/meter-driver") + # Cancel must not write anything. + assert save_calls == [] + + def test_meter_driver_config_post_save_and_init(self, client, config, monkeypatch): + # Successful save + init redirects to the list (configviews 184-186, 193-195, 199-200). + config["HEROKU"] = True + provider = _provider_record("config-driver") + self._patch_config_editor(monkeypatch, provider) + save_calls, init_calls, activate_calls = [], [], [] + + def fake_save(p, text): + save_calls.append((p, text)) + return ({"driver": {"id": p["id"]}}, {}) + + def fake_init(p, payload): + init_calls.append((p, payload)) + + def fake_activate(skip_provider_init=True): + activate_calls.append(skip_provider_init) + return (True, None) + + monkeypatch.setattr(provider_settings, "save_provider_config_text", fake_save) + monkeypatch.setattr(provider_settings, "init_provider_from_payload", fake_init) + monkeypatch.setattr(configviews, "activate_metering_runtime_in_process", fake_activate) + + response = client.post( + "/config/meter-driver/config-driver/config", + data={"save_button": "Save", "config_text": '{"field_values": {}}'}, + follow_redirects=True, + ) + + # The edited JSON is persisted, and the payload save returns is exactly + # what flows into init (not the raw text or the validated dict). + assert save_calls == [(provider, '{"field_values": {}}')] + assert init_calls == [(provider, {"driver": {"id": "config-driver"}})] + assert activate_calls == [True] + # Activation succeeded → only the success flash, no deferral notice. + assert b"Driver config saved and init succeeded." in response.data + assert b"add a meter" not in response.data + + def test_meter_driver_config_post_activation_deferred(self, client, config, monkeypatch): + # Save succeeds but live activation is deferred (configviews 196-198). + config["HEROKU"] = True + provider = _provider_record("config-driver") + self._patch_config_editor(monkeypatch, provider) + monkeypatch.setattr(provider_settings, "save_provider_config_text", lambda p, text: ({}, {})) + monkeypatch.setattr(provider_settings, "init_provider_from_payload", lambda p, payload: None) + activate_calls = [] + + def fake_activate(skip_provider_init=True): + activate_calls.append(skip_provider_init) + return (False, "main event loop is not available") + + monkeypatch.setattr(configviews, "activate_metering_runtime_in_process", fake_activate) + + response = client.post( + "/config/meter-driver/config-driver/config", + data={"save_button": "Save", "config_text": '{"field_values": {}}'}, + follow_redirects=True, + ) + + assert activate_calls == [True] + # The deferred-activation branch flashes the extra "add a meter" notice + # in addition to the success message. + assert b"add a meter" in response.data + assert b"Driver config saved and init succeeded." in response.data + + def test_meter_driver_config_post_config_error(self, client, config, monkeypatch): + # A DriverConfigError re-renders the editor with a danger flash (configviews lines 187-189). + config["HEROKU"] = True + provider = _provider_record("config-driver") + self._patch_config_editor(monkeypatch, provider) + + def boom(p, text): + raise provider_settings.DriverConfigError("config JSON is invalid") + + monkeypatch.setattr(provider_settings, "save_provider_config_text", boom) + + response = client.post( + "/config/meter-driver/config-driver/config", + data={"save_button": "Save", "config_text": "not json"}, + ) + # The whole editor page is re-rendered with the danger flash; snapshot it. + self.verify_response(response) + + def test_meter_driver_config_post_init_error(self, client, config, monkeypatch): + # A DriverInitializationError re-renders the editor with a danger flash (configviews lines 190-192). + config["HEROKU"] = True + provider = _provider_record("config-driver") + self._patch_config_editor(monkeypatch, provider) + monkeypatch.setattr(provider_settings, "save_provider_config_text", lambda p, text: ({}, {})) + + def boom(p, payload): + raise provider_settings.DriverInitializationError("driver init failed: nope") + + monkeypatch.setattr(provider_settings, "init_provider_from_payload", boom) + + response = client.post( + "/config/meter-driver/config-driver/config", + data={"save_button": "Save", "config_text": '{"field_values": {}}'}, + ) + # The whole editor page is re-rendered with the danger flash; snapshot it. + self.verify_response(response) diff --git a/sparkmeter/config/tests/test_provider_settings.py b/sparkmeter/config/tests/test_provider_settings.py new file mode 100644 index 0000000..990317e --- /dev/null +++ b/sparkmeter/config/tests/test_provider_settings.py @@ -0,0 +1,1148 @@ +# -*- coding: utf-8 -*- +"""Meter driver settings tests.""" + +import json + +import httpx +import pytest + +from sparkmeter.config import provider_settings +from sparkmeter.metering.provider_config import configured_provider_url + + +class FakeResponse(object): + """Minimal HTTPX-like response test double.""" + + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + """Pretend the response was successful.""" + + def json(self): + """Return the configured JSON payload.""" + return self._payload + + +def _fake_openapi_get(url, timeout): + """A minimal valid driver OpenAPI, for tests that only need a saved provider.""" + return FakeResponse( + { + "info": {"title": "SparkNet-Http", "version": "1.2.3"}, + "paths": {"/v1/commands": {}, "/v1/events": {}}, + "x-open-thunder": {"default_interface": "http", "interfaces": []}, + } + ) + + +def test_validate_contract_discovers_interfaces(monkeypatch): + def fake_get(url, timeout): + assert url == "http://127.0.0.1:18080/openapi.json" + assert timeout == 10.0 + return FakeResponse( + { + "info": { + "title": "SparkNet-Http", + "version": "1.2.3", + }, + "paths": { + "/v1/commands": {}, + "/v1/events": {}, + }, + "x-open-thunder": { + "default_interface": "grpc", + "interfaces": [ + { + "type": "grpc", + "label": "gRPC", + "target": "127.0.0.1:19090", + }, + ], + }, + } + ) + + monkeypatch.setattr(provider_settings.httpx, "get", fake_get) + + details = provider_settings.validate_contract("http://127.0.0.1:18080") + + assert details["name"] == "SparkNet-Http" + assert details["base_url"] == "http://127.0.0.1:18080" + assert details["openapi_url"] == "http://127.0.0.1:18080/openapi.json" + assert details["default_interface"] == "grpc" + assert [interface["type"] for interface in details["interfaces"]] == ["http", "grpc"] + + +def test_validate_contract_discovers_vendor_option_requirements(monkeypatch): + def fake_get(url, timeout): + if url.endswith("/v1/requirements"): + return FakeResponse( + { + "required_fields": ["aes_key", "channel", "heartbeat_period_duration"], + } + ) + return FakeResponse( + { + "openapi": "3.1.0", + "info": { + "title": "SparkNet-Http", + "version": "1.2.3", + }, + "paths": { + "/v1/commands": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/ConfigureProviderCommand"}, + ], + }, + }, + }, + }, + }, + }, + "/v1/events": {}, + }, + "components": { + "schemas": { + "ConfigureProviderCommand": { + "type": "object", + "properties": { + "command_type": { + "type": "string", + "enum": ["configure_provider"], + }, + "vendor_options": { + "type": "object", + "required": ["aes_key", "channel"], + "properties": { + "aes_key": { + "type": "string", + "title": "AES key", + "description": "32-character hex network key.", + "pattern": "[0-9a-fA-F]{32}", + }, + "channel": { + "type": "integer", + "title": "Channel", + "description": "Radio channel to configure.", + "minimum": 11, + "maximum": 26, + }, + }, + }, + }, + }, + }, + }, + "x-open-thunder": { + "default_interface": "http", + "interfaces": [], + }, + } + ) + + monkeypatch.setattr(provider_settings.httpx, "get", fake_get) + + details = provider_settings.validate_contract("http://127.0.0.1:18080") + + fields = details["driver_requirement_field_map"] + assert fields["aes_key"]["required"] is True + assert fields["aes_key"]["pattern"] == "[0-9a-fA-F]{32}" + assert fields["channel"]["required"] is True + assert fields["channel"]["minimum"] == 11 + assert fields["channel"]["maximum"] == 26 + assert fields["heartbeat_period_duration"]["required"] is True + assert fields["heartbeat_period_duration"]["type"] == "integer" + + +def test_validate_contract_falls_back_to_openapi_requirements(monkeypatch): + def fake_get(url, timeout): + if url.endswith("/v1/requirements"): + raise httpx.HTTPStatusError( + "missing", + request=httpx.Request("GET", url), + response=httpx.Response(404, request=httpx.Request("GET", url)), + ) + return FakeResponse( + { + "openapi": "3.1.0", + "info": { + "title": "SparkNet-Http", + "version": "1.2.3", + }, + "paths": { + "/v1/commands": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/ConfigureProviderCommand"}, + ], + }, + }, + }, + }, + }, + }, + "/v1/events": {}, + }, + "components": { + "schemas": { + "ConfigureProviderCommand": { + "type": "object", + "properties": { + "command_type": { + "type": "string", + "enum": ["configure_provider"], + }, + "vendor_options": { + "type": "object", + "required": ["aes_key", "channel"], + "properties": { + "aes_key": { + "type": "string", + "pattern": "[0-9a-fA-F]{32}", + }, + "channel": { + "type": "integer", + "minimum": 11, + "maximum": 26, + }, + }, + }, + }, + }, + }, + }, + "x-open-thunder": { + "default_interface": "http", + "interfaces": [], + }, + } + ) + + monkeypatch.setattr(provider_settings.httpx, "get", fake_get) + + details = provider_settings.validate_contract("http://127.0.0.1:18080") + + fields = details["driver_requirement_field_map"] + assert fields["aes_key"]["required"] is True + assert fields["channel"]["required"] is True + + +def test_validate_contract_rejects_missing_paths(monkeypatch): + def fake_get(url, timeout): + return FakeResponse( + { + "info": { + "title": "SparkNet-Http", + }, + "paths": { + "/v1/commands": {}, + }, + } + ) + + monkeypatch.setattr(provider_settings.httpx, "get", fake_get) + + try: + provider_settings.validate_contract("http://127.0.0.1:18080") + except provider_settings.ProviderRegistrationError as exc: + assert "missing required paths" in str(exc) + else: # pragma: no cover + raise AssertionError("expected ProviderRegistrationError") + + +def test_configured_provider_url_uses_saved_setting(session, monkeypatch): + monkeypatch.setattr(provider_settings.httpx, "get", _fake_openapi_get) + provider_settings.save_provider_settings("http://127.0.0.1:18080", "http") + session.commit() + + assert configured_provider_url(default="") == "http://127.0.0.1:18080" + + +def test_configured_provider_url_ignores_env_override(session, monkeypatch): + monkeypatch.setattr(provider_settings.httpx, "get", _fake_openapi_get) + provider_settings.save_provider_settings("http://127.0.0.1:18080", "http") + session.commit() + monkeypatch.setenv("METERING_PROVIDER_URL", "http://127.0.0.1:28080") + + assert configured_provider_url(default="") == "http://127.0.0.1:18080" + + +def test_init_provider_from_payload_coerces_integer_fields(monkeypatch, tmp_path): + import sparkmeter.metering.runtime_client as runtime_client + + captured = {} + + def fake_initialize_provider_sync(provider, field_values, provider_details=None): + captured["field_values"] = field_values + + monkeypatch.setattr(runtime_client, "initialize_provider_sync", fake_initialize_provider_sync) + monkeypatch.setattr(provider_settings, "get_live_interface_details", lambda *a, **k: {}) + monkeypatch.setattr( + provider_settings, "get_provider_config_abspath", lambda provider: str(tmp_path / "driver.json") + ) + + provider_settings.init_provider_from_payload( + {"base_url": "http://127.0.0.1:18080"}, + { + "field_values": { + "aes_key": "00112233445566778899aabbccddeeff", + "channel": "26", + "heartbeat_period_duration": "60", + }, + "required_fields": [ + {"name": "aes_key", "type": "string", "required": True}, + {"name": "channel", "type": "integer", "required": True}, + {"name": "heartbeat_period_duration", "type": "integer", "required": True}, + ], + }, + timeout=7.5, + ) + + # The integer fields are coerced before being handed to the init transport. + assert captured["field_values"] == { + "aes_key": "00112233445566778899aabbccddeeff", + "channel": 26, + "heartbeat_period_duration": 60, + } + + +def _openapi_spec(**overrides): + """A minimal contract that passes validate_contract's required checks.""" + spec = { + "info": {"title": "SparkNet-Http", "version": "1.2.3"}, + "paths": {"/v1/commands": {}, "/v1/events": {}}, + "x-open-thunder": {"default_interface": "http", "interfaces": []}, + } + spec.update(overrides) + return spec + + +def _use_temp_config_root(monkeypatch, tmp_path): + """Redirect the module's config directory globals at a temp location.""" + monkeypatch.setattr(provider_settings, "_REPO_ROOT", tmp_path) + monkeypatch.setattr(provider_settings, "_METER_DRIVER_CONFIG_DIR", tmp_path / "meter_driver_configs") + + +# --------------------------------------------------------------------------- +# JSON-pointer / schema resolution helpers +# --------------------------------------------------------------------------- + + +def test_resolve_local_ref_rejects_non_local_refs(): + assert provider_settings._resolve_local_ref({}, "") is None + assert provider_settings._resolve_local_ref({}, "https://x/y") is None + + +def test_resolve_local_ref_walks_and_unescapes_tokens(): + spec = {"components": {"sch~emas": {"a/b": {"leaf": 1}}}} + # ~0 -> "~" and ~1 -> "/" per RFC 6901. + assert provider_settings._resolve_local_ref(spec, "#/components/sch~0emas/a~1b") == {"leaf": 1} + + +def test_resolve_local_ref_returns_none_for_missing_node(): + assert provider_settings._resolve_local_ref({"a": {}}, "#/a/missing") is None + + +def test_resolve_schema_handles_non_dict_ref_and_plain(): + assert provider_settings._resolve_schema({}, "not-a-dict") == {} + spec = {"components": {"schemas": {"Foo": {"type": "object"}}}} + assert provider_settings._resolve_schema(spec, {"$ref": "#/components/schemas/Foo"}) == {"type": "object"} + assert provider_settings._resolve_schema({}, {"$ref": "#/nope"}) == {} + assert provider_settings._resolve_schema({}, {"type": "string"}) == {"type": "string"} + + +def test_command_type_values_reads_const_and_enum(): + assert provider_settings._command_type_values({}, {"properties": {"command_type": {"const": "Foo"}}}) == { + "foo" + } + values = provider_settings._command_type_values( + {}, {"properties": {"command_type": {"enum": ["A", " b ", ""]}}} + ) + assert values == {"a", "b"} + + +def test_find_configure_provider_schema_falls_back_to_components(): + spec = { + "paths": {}, + "components": { + "schemas": { + "Other": {"properties": {"command_type": {"const": "noop"}}}, + "Cfg": { + "properties": { + "command_type": {"const": "configure_provider"}, + "vendor_options": {"type": "object"}, + } + }, + } + }, + } + schema = provider_settings._find_configure_provider_schema(spec) + assert "vendor_options" in schema.get("properties", {}) + + +def test_find_configure_provider_schema_returns_empty_when_absent(): + assert provider_settings._find_configure_provider_schema({"paths": {}, "components": {}}) == {} + + +# --------------------------------------------------------------------------- +# Requirement discovery helpers +# --------------------------------------------------------------------------- + + +def test_fetch_requirements_payload_rejects_non_object(monkeypatch): + monkeypatch.setattr(provider_settings.httpx, "get", lambda url, timeout: FakeResponse(["nope"])) + with pytest.raises(provider_settings.ProviderRegistrationError): + provider_settings._fetch_requirements_payload("http://127.0.0.1:18080") + + +def test_iter_candidate_requirement_schemas_skips_non_dicts_and_bounds_depth(): + # Nest object schemas deeper than the depth-6 walk bound: the marker + # property sits at nesting depth 7 and must NOT be yielded, while the + # wrapper one level shallower (depth 6) still is. + nested = {"properties": {"deep_marker": {"type": "string"}}} + for level in range(7): + nested = {"properties": {"wrap_{}".format(level): nested}} + + spec = { + "components": {"schemas": {"Root": nested}}, + "paths": { + "/v1/commands": "not-a-dict", # non-dict path item is skipped + "/v1/events": {"post": "not-a-dict"}, # non-dict operation is skipped + }, + } + + property_sets = [ + set(s.get("properties") or {}) for s in provider_settings._iter_candidate_requirement_schemas(spec) + ] + + # The innermost wrapper (holding "wrap_0") is yielded at depth 6. + assert {"wrap_0"} in property_sets + # The schema one level deeper (holding "deep_marker") is beyond the bound. + assert not any("deep_marker" in properties for properties in property_sets) + + +def test_iter_candidate_requirement_schemas_skips_ref_to_non_dict(): + # A component schema whose $ref resolves to a non-dict (a list here) must + # be skipped by the walk rather than treated as a properties-bearing schema. + spec = { + "components": {"schemas": {"Bad": {"$ref": "#/x/list"}}}, + "x": {"list": [1, 2, 3]}, + "paths": {}, + } + assert list(provider_settings._iter_candidate_requirement_schemas(spec)) == [] + + +def test_extract_fields_from_requirements_uses_standard_type_hints(): + # No component schema describes these, so the standard hints supply types. + fields = provider_settings._extract_fields_from_requirements( + {"components": {}, "paths": {}}, ["channel", "aes_key"] + ) + by_name = {field["name"]: field for field in fields} + assert by_name["channel"]["type"] == "integer" + assert by_name["aes_key"]["type"] == "string" + assert by_name["channel"]["required"] is True + + +def test_extract_driver_requirement_fields_returns_empty_without_vendor_options(monkeypatch): + # A contract with no configure-provider schema advertises no requirements. + assert provider_settings._extract_driver_requirement_fields("http://x", {"paths": {}}) == [] + + +def test_extract_driver_requirement_fields_falls_back_on_requirements_error(monkeypatch): + spec = { + "paths": { + "/v1/commands": { + "post": { + "requestBody": { + "content": { + "application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/Cfg"}]}} + } + } + } + } + }, + "components": { + "schemas": { + "Cfg": { + "properties": { + "command_type": {"const": "configure_provider"}, + "vendor_options": { + "required": ["aes_key"], + "properties": {"aes_key": {"type": "string"}}, + }, + } + } + } + }, + } + + def boom(url, timeout): + raise httpx.ConnectError("down") + + monkeypatch.setattr(provider_settings.httpx, "get", boom) + fields = provider_settings._extract_driver_requirement_fields("http://x", spec) + assert [field["name"] for field in fields] == ["aes_key"] + + +# --------------------------------------------------------------------------- +# URL normalization +# --------------------------------------------------------------------------- + + +def test_normalize_base_url_strips_openapi_and_trailing_slash(): + assert provider_settings.normalize_base_url("http://h:1/openapi.json") == "http://h:1" + assert provider_settings.normalize_base_url("http://h:1/") == "http://h:1" + + +def test_normalize_base_url_requires_url_and_scheme(): + with pytest.raises(provider_settings.ProviderRegistrationError): + provider_settings.normalize_base_url(" ") + with pytest.raises(provider_settings.ProviderRegistrationError): + provider_settings.normalize_base_url("127.0.0.1:8080") + + +def test_get_openapi_url_appends_suffix(): + assert provider_settings.get_openapi_url("http://h:1/") == "http://h:1/openapi.json" + + +# --------------------------------------------------------------------------- +# Interface metadata +# --------------------------------------------------------------------------- + + +def test_normalize_interface_metadata_injects_http_and_dedups(): + spec = { + "x-open-thunder": { + "default_interface": "grpc", + "interfaces": [ + "not-a-dict", + {"type": ""}, + {"type": "grpc", "target": "10.0.0.1:9090"}, + {"type": "grpc", "target": "dup"}, + {"type": "mqtt", "base_url": "mqtt://host"}, + ], + } + } + details = provider_settings._normalize_interface_metadata("http://base", spec) + by_type = {interface["type"]: interface for interface in details["interfaces"]} + # http is always synthesized and placed first. + assert details["interfaces"][0]["type"] == "http" + assert by_type["grpc"]["address"] == "10.0.0.1:9090" + assert by_type["mqtt"]["address"] == "mqtt://host" + assert details["default_interface"] == "grpc" + + +def test_normalize_interface_metadata_blank_address_without_target_or_base_url(): + # An advertised interface that declares neither base_url nor target still + # appears, but with an empty address. + spec = { + "x-open-thunder": { + "default_interface": "http", + "interfaces": [{"type": "mqtt", "label": "MQTT"}], + } + } + details = provider_settings._normalize_interface_metadata("http://base", spec) + by_type = {interface["type"]: interface for interface in details["interfaces"]} + assert by_type["mqtt"]["address"] == "" + assert "base_url" not in by_type["mqtt"] + assert "target" not in by_type["mqtt"] + + +def test_normalize_interface_metadata_defaults_to_http_when_unknown(): + spec = {"x-open-thunder": {"default_interface": "carrier-pigeon", "interfaces": []}} + details = provider_settings._normalize_interface_metadata("http://base", spec) + assert details["default_interface"] == "http" + + +def test_apply_selected_interface_falls_back_to_default_when_invalid(): + details = { + "interfaces": [{"type": "http"}, {"type": "grpc"}], + "default_interface": "grpc", + } + applied = provider_settings._apply_selected_interface(dict(details), selected_interface="carrier") + assert applied["selected_interface"] == "grpc" + assert applied["selected_interface_details"]["type"] == "grpc" + + +def test_apply_selected_interface_uses_explicit_selection(): + details = {"interfaces": [{"type": "http"}, {"type": "grpc"}], "default_interface": "http"} + applied = provider_settings._apply_selected_interface(dict(details), selected_interface="grpc") + assert applied["selected_interface"] == "grpc" + + +def test_fallback_interface_metadata_includes_non_http_selection(): + details = provider_settings._fallback_interface_metadata("http://base", selected_interface="grpc") + types = [interface["type"] for interface in details["interfaces"]] + assert types == ["http", "grpc"] + assert details["selected_interface"] == "grpc" + assert details["openapi_url"] == "http://base/openapi.json" + + +# --------------------------------------------------------------------------- +# validate_contract error paths +# --------------------------------------------------------------------------- + + +def test_validate_contract_wraps_http_errors(monkeypatch): + def boom(url, timeout): + raise httpx.ConnectError("refused") + + monkeypatch.setattr(provider_settings.httpx, "get", boom) + with pytest.raises(provider_settings.ProviderRegistrationError) as exc: + provider_settings.validate_contract("http://127.0.0.1:18080") + assert "could not fetch" in str(exc.value) + + +def test_validate_contract_rejects_invalid_json(monkeypatch): + class BadJSON(FakeResponse): + def json(self): + raise ValueError("bad") + + monkeypatch.setattr(provider_settings.httpx, "get", lambda url, timeout: BadJSON({})) + with pytest.raises(provider_settings.ProviderRegistrationError) as exc: + provider_settings.validate_contract("http://127.0.0.1:18080") + assert "invalid JSON" in str(exc.value) + + +def test_validate_contract_requires_info_title(monkeypatch): + spec = _openapi_spec(info={}) + monkeypatch.setattr(provider_settings.httpx, "get", lambda url, timeout: FakeResponse(spec)) + with pytest.raises(provider_settings.ProviderRegistrationError) as exc: + provider_settings.validate_contract("http://127.0.0.1:18080") + assert "info.title" in str(exc.value) + + +# --------------------------------------------------------------------------- +# Live interface details / runtime status +# --------------------------------------------------------------------------- + + +def test_get_live_interface_details_falls_back_on_registration_error(monkeypatch): + def boom(url, timeout): + raise httpx.ConnectError("down") + + monkeypatch.setattr(provider_settings.httpx, "get", boom) + details = provider_settings.get_live_interface_details( + "http://127.0.0.1:18080", selected_interface="grpc" + ) + assert details["error"] + assert details["selected_interface"] == "grpc" + + +def test_get_live_interface_details_applies_selection_on_success(monkeypatch): + monkeypatch.setattr(provider_settings.httpx, "get", _fake_openapi_get) + details = provider_settings.get_live_interface_details("http://127.0.0.1:18080") + assert details["selected_interface"] == "http" + + +def test_get_runtime_status_reports_gateway_when_connected(monkeypatch): + def fake_get(url, timeout): + if url.endswith("/v1/status"): + return FakeResponse({"connected": True, "gateway_type": "sparknet"}) + return FakeResponse({}) + + monkeypatch.setattr(provider_settings.httpx, "get", fake_get) + status = provider_settings.get_runtime_status("http://127.0.0.1:18080") + assert status["online"] is True + assert status["gateway_active"] is True + assert status["gateway_type"] == "sparknet" + assert status["checked_url"].endswith("/v1/healthz") + + +def test_get_runtime_status_can_skip_gateway_probe(monkeypatch): + monkeypatch.setattr(provider_settings.httpx, "get", lambda url, timeout: FakeResponse({})) + status = provider_settings.get_runtime_status("http://127.0.0.1:18080", include_gateway_status=False) + assert status["online"] is True + assert status["gateway_active"] is False + assert status["gateway_checked"] is False + + +def test_get_runtime_status_tolerates_gateway_probe_failure(monkeypatch): + def fake_get(url, timeout): + if url.endswith("/v1/status"): + raise httpx.ConnectError("no status") + return FakeResponse({}) + + monkeypatch.setattr(provider_settings.httpx, "get", fake_get) + status = provider_settings.get_runtime_status("http://127.0.0.1:18080") + assert status["online"] is True + assert status["gateway_active"] is False + assert status["gateway_checked"] is True + + +def test_get_runtime_status_falls_back_to_legacy_health(monkeypatch): + def fake_get(url, timeout): + if url.endswith("/v1/healthz"): + raise httpx.ConnectError("no healthz") + if url.endswith("/health"): + return FakeResponse({}) + if url.endswith("/v1/status"): + return FakeResponse({"connected": False}) + return FakeResponse({}) + + monkeypatch.setattr(provider_settings.httpx, "get", fake_get) + status = provider_settings.get_runtime_status("http://127.0.0.1:18080") + assert status["online"] is True + assert status["checked_url"].endswith("/health") + + +def test_get_runtime_status_reports_offline_when_unreachable(monkeypatch): + def fake_get(url, timeout): + raise httpx.ConnectError("nothing here") + + monkeypatch.setattr(provider_settings.httpx, "get", fake_get) + status = provider_settings.get_runtime_status("http://127.0.0.1:18080") + assert status["online"] is False + assert status["gateway_active"] is False + assert "nothing here" in status["message"] + + +# --------------------------------------------------------------------------- +# Driver config file helpers +# --------------------------------------------------------------------------- + + +def test_cleanup_orphaned_driver_config_files_removes_unknown(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + config_dir = tmp_path / "meter_driver_configs" + config_dir.mkdir() + (config_dir / "keep.json").write_text("{}") + (config_dir / "orphan.json").write_text("{}") + + provider_settings._cleanup_orphaned_driver_config_files([{"id": "keep"}]) + + assert (config_dir / "keep.json").exists() + assert not (config_dir / "orphan.json").exists() + + +def test_cleanup_orphaned_driver_config_files_swallows_unlink_errors(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + config_dir = tmp_path / "meter_driver_configs" + config_dir.mkdir() + (config_dir / "orphan.json").write_text("{}") + + def boom(self, *args, **kwargs): + raise OSError("permission denied") + + monkeypatch.setattr(provider_settings.Path, "unlink", boom) + # A failure to remove a stale file is swallowed; the call returns cleanly. + provider_settings._cleanup_orphaned_driver_config_files([]) + assert (config_dir / "orphan.json").exists() + + +def test_cleanup_orphaned_driver_config_files_noop_without_directory(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + # Directory does not exist; the call must simply return. + provider_settings._cleanup_orphaned_driver_config_files([{"id": "x"}]) + + +def test_load_existing_driver_config_variants(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + assert provider_settings._load_existing_driver_config("") == {} + assert provider_settings._load_existing_driver_config("meter_driver_configs/missing.json") == {} + + config_dir = tmp_path / "meter_driver_configs" + config_dir.mkdir() + (config_dir / "bad.json").write_text("{not json") + assert provider_settings._load_existing_driver_config("meter_driver_configs/bad.json") == {} + (config_dir / "list.json").write_text("[1, 2]") + assert provider_settings._load_existing_driver_config("meter_driver_configs/list.json") == {} + (config_dir / "ok.json").write_text('{"a": 1}') + assert provider_settings._load_existing_driver_config("meter_driver_configs/ok.json") == {"a": 1} + + +def test_get_provider_config_abspath_handles_blank_id(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + assert provider_settings.get_provider_config_abspath({"id": ""}) == "" + path = provider_settings.get_provider_config_abspath({"id": "abc"}) + assert path.endswith("meter_driver_configs/abc.json") + + +def test_load_provider_runtime_settings_reads_file(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + assert provider_settings.load_provider_runtime_settings({"id": ""}) == {} + config_dir = tmp_path / "meter_driver_configs" + config_dir.mkdir() + (config_dir / "abc.json").write_text('{"field_values": {"channel": 11}}') + assert provider_settings.load_provider_runtime_settings({"id": "abc"}) == { + "field_values": {"channel": 11} + } + + +def test_load_provider_config_text_synthesizes_when_missing(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + assert provider_settings.load_provider_config_text({"id": ""}) == "{}\n" + + text = provider_settings.load_provider_config_text( + {"id": "abc", "name": "Driver", "base_url": "http://x", "selected_interface": "http"} + ) + payload = json.loads(text) + assert payload["driver"]["id"] == "abc" + assert payload["init_status"]["has_successful_init"] is False + + +def test_load_provider_config_text_returns_existing_file(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + config_dir = tmp_path / "meter_driver_configs" + config_dir.mkdir() + (config_dir / "abc.json").write_text('{"custom": true}\n') + assert provider_settings.load_provider_config_text({"id": "abc"}) == '{"custom": true}\n' + + +def test_write_driver_config_file_merges_previous_field_values(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + config_dir = tmp_path / "meter_driver_configs" + config_dir.mkdir() + (config_dir / "abc.json").write_text( + json.dumps({"field_values": {"channel": 20}, "init_status": {"has_successful_init": True}}) + ) + + provider_record = { + "id": "abc", + "selected_interface": "http", + "selected_interface_target": "", + "enabled": True, + } + details = { + "name": "Driver", + "base_url": "http://x", + "openapi_url": "http://x/openapi.json", + "service_version": "1.0", + "driver_requirement_fields": [ + {"name": "channel", "default": 11}, + {"name": "aes_key", "default": None}, + ], + } + provider_settings._write_driver_config_file(provider_record, details) + + written = json.loads((config_dir / "abc.json").read_text()) + # Previous channel value is preserved; aes_key falls back to "" (no default). + assert written["field_values"] == {"channel": 20, "aes_key": ""} + # A previously-successful init status carries through. + assert written["init_status"]["has_successful_init"] is True + + +# --------------------------------------------------------------------------- +# Config text parsing / validation / coercion +# --------------------------------------------------------------------------- + + +def test_parse_provider_config_text_errors(): + with pytest.raises(provider_settings.DriverConfigError): + provider_settings.parse_provider_config_text("{not json") + with pytest.raises(provider_settings.DriverConfigError): + provider_settings.parse_provider_config_text("[1, 2]") + assert provider_settings.parse_provider_config_text('{"a": 1}') == {"a": 1} + + +def test_required_field_helpers_reject_wrong_types(): + with pytest.raises(provider_settings.DriverConfigError): + provider_settings._required_field_names({"required_fields": "nope"}) + with pytest.raises(provider_settings.DriverConfigError): + provider_settings._field_values({"field_values": "nope"}) + with pytest.raises(provider_settings.DriverConfigError): + provider_settings._required_field_specs({"required_fields": "nope"}) + + +def test_required_field_names_accepts_dicts_and_strings(): + names = provider_settings._required_field_names( + {"required_fields": [{"name": "aes_key"}, "channel", {"name": ""}, " "]} + ) + assert names == ["aes_key", "channel"] + + +def test_coerce_field_value_by_type(): + assert provider_settings._coerce_field_value("c", "26", {"type": "integer"}) == 26 + assert provider_settings._coerce_field_value("r", "1.5", {"type": "number"}) == 1.5 + assert provider_settings._coerce_field_value("b", "yes", {"type": "boolean"}) is True + assert provider_settings._coerce_field_value("b", "off", {"type": "boolean"}) is False + assert provider_settings._coerce_field_value("b", True, {"type": "boolean"}) is True + assert provider_settings._coerce_field_value("s", "kept", {"type": "string"}) == "kept" + + +def test_coerce_field_value_raises_on_bad_input(): + with pytest.raises(provider_settings.DriverConfigError): + provider_settings._coerce_field_value("c", "nope", {"type": "integer"}) + with pytest.raises(provider_settings.DriverConfigError): + provider_settings._coerce_field_value("r", "nope", {"type": "number"}) + with pytest.raises(provider_settings.DriverConfigError): + provider_settings._coerce_field_value("b", "maybe", {"type": "boolean"}) + + +def test_validate_provider_config_payload_reports_missing_and_coerces(): + with pytest.raises(provider_settings.DriverConfigError) as exc: + provider_settings.validate_provider_config_payload( + {"required_fields": ["channel"], "field_values": {"channel": ""}} + ) + assert "missing values" in str(exc.value) + + validated = provider_settings.validate_provider_config_payload( + { + "required_fields": [{"name": "channel", "type": "integer"}], + "field_values": {"channel": "26"}, + } + ) + assert validated["field_values"]["channel"] == 26 + + +def test_normalize_init_status_defaults_for_bad_input(): + assert provider_settings._normalize_init_status({"init_status": "nope"}) == { + "has_successful_init": False, + "last_init_succeeded": False, + "last_init_error": "", + } + assert provider_settings._normalize_init_status( + {"init_status": {"has_successful_init": True, "last_init_error": "x"}} + ) == {"has_successful_init": True, "last_init_succeeded": False, "last_init_error": "x"} + + +def test_save_provider_config_text_writes_normalized_file(monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + (tmp_path / "meter_driver_configs").mkdir() + provider = {"id": "abc"} + config_text = json.dumps( + { + "required_fields": [{"name": "channel", "type": "integer"}], + "field_values": {"channel": "26"}, + } + ) + payload, validated = provider_settings.save_provider_config_text(provider, config_text) + assert validated["field_values"]["channel"] == 26 + assert payload["init_status"]["has_successful_init"] is False + on_disk = json.loads((tmp_path / "meter_driver_configs" / "abc.json").read_text()) + assert on_disk["field_values"]["channel"] == "26" + + +# --------------------------------------------------------------------------- +# Driver initialization +# --------------------------------------------------------------------------- + + +def _init_payload(): + return { + "field_values": {"aes_key": "00112233445566778899aabbccddeeff", "channel": "26"}, + "required_fields": [ + {"name": "aes_key", "type": "string", "required": True}, + {"name": "channel", "type": "integer", "required": True}, + ], + } + + +def test_init_provider_from_payload_records_success(monkeypatch, tmp_path): + import sparkmeter.metering.runtime_client as runtime_client + + config_file = tmp_path / "driver.json" + monkeypatch.setattr(runtime_client, "initialize_provider_sync", lambda *a, **k: None) + monkeypatch.setattr(provider_settings, "get_live_interface_details", lambda *a, **k: {}) + monkeypatch.setattr(provider_settings, "get_provider_config_abspath", lambda provider: str(config_file)) + + provider_settings.init_provider_from_payload({"base_url": "http://x", "name": "Driver"}, _init_payload()) + + status = json.loads(config_file.read_text())["init_status"] + assert status["has_successful_init"] is True + assert status["last_init_succeeded"] is True + assert status["last_init_error"] == "" + + +def test_init_provider_from_payload_records_failure(monkeypatch, tmp_path): + import sparkmeter.metering.runtime_client as runtime_client + + config_file = tmp_path / "driver.json" + + def boom(*a, **k): + raise RuntimeError("transport exploded") + + monkeypatch.setattr(runtime_client, "initialize_provider_sync", boom) + monkeypatch.setattr(provider_settings, "get_live_interface_details", lambda *a, **k: {}) + monkeypatch.setattr(provider_settings, "get_provider_config_abspath", lambda provider: str(config_file)) + + with pytest.raises(provider_settings.DriverInitializationError) as exc: + provider_settings.init_provider_from_payload({"base_url": "http://x"}, _init_payload()) + assert "transport exploded" in str(exc.value) + + status = json.loads(config_file.read_text())["init_status"] + assert status["last_init_succeeded"] is False + assert status["last_init_error"] == "transport exploded" + + +def test_init_provider_from_payload_handles_detailless_failure(monkeypatch, tmp_path): + import sparkmeter.metering.runtime_client as runtime_client + + config_file = tmp_path / "driver.json" + + def boom(*a, **k): + raise RuntimeError("") + + monkeypatch.setattr(runtime_client, "initialize_provider_sync", boom) + monkeypatch.setattr(provider_settings, "get_live_interface_details", lambda *a, **k: {}) + monkeypatch.setattr(provider_settings, "get_provider_config_abspath", lambda provider: str(config_file)) + + with pytest.raises(provider_settings.DriverInitializationError) as exc: + provider_settings.init_provider_from_payload({"base_url": "http://x"}, _init_payload()) + assert str(exc.value) == "driver init failed" + + +def test_get_provider_init_status_reads_persisted_state(monkeypatch): + monkeypatch.setattr( + provider_settings, + "load_provider_runtime_settings", + lambda provider: {"init_status": {"has_successful_init": True}}, + ) + assert provider_settings.get_provider_init_status({"id": "abc"})["has_successful_init"] is True + + +def test_initialize_configured_providers_on_startup_covers_each_outcome(monkeypatch): + providers = [ + {"id": "missing"}, + {"id": "invalid"}, + {"id": "initfail"}, + {"id": "ok"}, + ] + payloads = { + "missing": {}, + "invalid": {"bad": True}, + "initfail": {"ok": True}, + "ok": {"ok": True}, + } + monkeypatch.setattr(provider_settings, "get_saved_providers", lambda: providers) + monkeypatch.setattr( + provider_settings, "load_provider_runtime_settings", lambda provider: payloads[provider["id"]] + ) + + def fake_validate(payload): + if payload == {"bad": True}: + raise provider_settings.DriverConfigError("bad config") + + def fake_init(provider, payload, timeout=10.0): + if provider["id"] == "initfail": + raise provider_settings.DriverInitializationError("init boom") + + monkeypatch.setattr(provider_settings, "validate_provider_config_payload", fake_validate) + monkeypatch.setattr(provider_settings, "init_provider_from_payload", fake_init) + + results = {r["provider"]["id"]: r for r in provider_settings.initialize_configured_providers_on_startup()} + assert results["missing"] == { + "provider": {"id": "missing"}, + "attempted": False, + "success": False, + "reason": "missing config payload", + } + assert results["invalid"]["attempted"] is False + assert results["invalid"]["reason"] == "bad config" + assert results["initfail"]["attempted"] is True + assert results["initfail"]["success"] is False + assert results["ok"]["success"] is True + + +# --------------------------------------------------------------------------- +# Persistence (DB-backed) +# --------------------------------------------------------------------------- + + +def test_create_parameter_if_missing_is_idempotent_and_guards_unknown(session): + first = provider_settings._create_parameter_if_missing("metering-providers") + second = provider_settings._create_parameter_if_missing("metering-providers") + assert first.name == "metering-providers" + assert second.id == first.id + with pytest.raises(RuntimeError): + provider_settings._create_parameter_if_missing("no-such-parameter-xyz") + + +def test_create_parameter_if_missing_creates_known_absent_parameter(session): + from sparkmeter.config.configdomain import ConfigParameter + + # Ensure a known parameter is absent, then confirm it is created on demand. + existing = ConfigParameter.get_by_name("send-broadcast-signal") + if existing is not None: + session.delete(existing) + session.flush() + assert ConfigParameter.get_by_name("send-broadcast-signal") is None + + created = provider_settings._create_parameter_if_missing("send-broadcast-signal") + assert created.name == "send-broadcast-signal" + assert ConfigParameter.get_by_name("send-broadcast-signal") is not None + + +def test_get_saved_providers_handles_blank_invalid_and_non_dict(session): + parameter = provider_settings._providers_parameter() + + parameter.value = " " + session.flush() + assert provider_settings.get_saved_providers() == [] + + parameter.value = "{not json" + session.flush() + assert provider_settings.get_saved_providers() == [] + + parameter.value = json.dumps([42, {"id": "abc", "base_url": "http://x"}]) + session.flush() + saved = provider_settings.get_saved_providers() + assert len(saved) == 1 + assert saved[0]["id"] == "abc" + assert saved[0]["selected_interface"] == "http" + assert saved[0]["enabled"] is True + + +def test_save_and_lookup_providers_roundtrip(session, monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + monkeypatch.setattr(provider_settings.httpx, "get", _fake_openapi_get) + + provider_id = provider_settings.save_provider_settings("http://127.0.0.1:18080", "http") + session.flush() + + assert provider_settings.get_provider(provider_id)["base_url"] == "http://127.0.0.1:18080" + assert provider_settings.get_provider("nonexistent") is None + assert provider_settings.get_enabled_provider()["id"] == provider_id + + +def test_save_provider_settings_replaces_existing_by_id(session, monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + monkeypatch.setattr(provider_settings.httpx, "get", _fake_openapi_get) + + provider_id = provider_settings.save_provider_settings("http://127.0.0.1:18080", "http") + session.flush() + # Saving again with the same id replaces rather than appends. + provider_settings.save_provider_settings("http://127.0.0.1:28080", "http", provider_id=provider_id) + session.flush() + + providers = provider_settings.get_saved_providers() + assert len(providers) == 1 + assert providers[0]["base_url"] == "http://127.0.0.1:28080" + + +def test_save_provider_settings_preserves_other_providers(session, monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + monkeypatch.setattr(provider_settings.httpx, "get", _fake_openapi_get) + + first_id = provider_settings.save_provider_settings("http://127.0.0.1:18080", "http") + session.flush() + second_id = provider_settings.save_provider_settings("http://127.0.0.1:28080", "http") + session.flush() + + # Re-saving the first provider must carry the unrelated second one through. + provider_settings.save_provider_settings("http://127.0.0.1:19090", "http", provider_id=first_id) + session.flush() + + providers = {p["id"]: p for p in provider_settings.get_saved_providers()} + assert set(providers) == {first_id, second_id} + assert providers[first_id]["base_url"] == "http://127.0.0.1:19090" + assert providers[second_id]["base_url"] == "http://127.0.0.1:28080" + + +def test_save_provider_settings_falls_back_for_invalid_interface(session, monkeypatch, tmp_path): + _use_temp_config_root(monkeypatch, tmp_path) + monkeypatch.setattr(provider_settings.httpx, "get", _fake_openapi_get) + + # "grpc" is not advertised by _fake_openapi_get, so it falls back to http. + provider_id = provider_settings.save_provider_settings("http://127.0.0.1:18080", "grpc") + session.flush() + assert provider_settings.get_provider(provider_id)["selected_interface"] == "http" diff --git a/sparkmeter/config/tests/test_providerform.py b/sparkmeter/config/tests/test_providerform.py new file mode 100644 index 0000000..e976155 --- /dev/null +++ b/sparkmeter/config/tests/test_providerform.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +"""Tests for the meter-driver configuration forms.""" + +import pytest +from wtforms.validators import ValidationError + +from sparkmeter.config import provider_settings, providerform + +# A live-interface details payload shaped like get_live_interface_details() +# returns, so the forms never need to reach out over httpx. +_DETAILS = { + "interfaces": [ + { + "type": "http", + "label": "HTTP API", + "base_url": "http://driver:18080", + "address": "http://driver:18080", + }, + {"type": "grpc", "label": "gRPC", "target": "driver:50051", "address": "driver:50051"}, + ], + "default_interface": "http", + "selected_interface": "http", + "driver_requirement_fields": [ + {"name": "aes_key", "label": "AES Key", "description": "hex key", "required": True}, + {"name": "channel", "label": "Radio Channel", "description": "", "required": False}, + ], + "driver_requirement_field_map": { + "aes_key": {"name": "aes_key", "label": "AES Key", "description": "hex key", "required": True}, + "channel": {"name": "channel", "label": "Radio Channel", "description": "", "required": False}, + }, +} + + +class TestMeterDriverSettingsForm: + def test_add_mode_populates_interface_choices_and_labels(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + assert form.mode == "add" + assert form.selected_interface.choices == [ + ("http", "HTTP API (http://driver:18080)"), + ("grpc", "gRPC (driver:50051)"), + ] + # Advertised vendor-option labels are applied to the fields. + assert form.aes_key.label.text == "AES Key" + assert form.channel.label.text == "Radio Channel" + assert form.selected_interface.data == "http" + + def test_edit_mode_seeds_from_provider_and_fetches_details(self, app, monkeypatch): + monkeypatch.setattr(provider_settings, "get_live_interface_details", lambda *a, **k: _DETAILS) + provider = { + "id": "abc", + "base_url": "http://driver:18080", + "selected_interface": "grpc", + "enabled": True, + } + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider=provider) + assert form.mode == "edit" + assert form.service_url.data == "http://driver:18080" + assert form.selected_interface.data == "grpc" + + def test_vendor_option_helpers(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + assert form.supports_vendor_option("aes_key") is True + assert form.supports_vendor_option("nonexistent") is False + assert form.vendor_option_required("aes_key") is True + assert form.vendor_option_required("channel") is False + assert form.vendor_option_description("aes_key") == "hex key" + assert [f["name"] for f in form.vendor_option_fields()] == ["aes_key", "channel"] + + def test_config_paths(self, app, monkeypatch): + monkeypatch.setattr( + provider_settings, + "get_provider_config_abspath", + lambda provider: "/app/meter_driver_configs/{}.json".format(provider["id"]), + ) + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + assert form.config_directory() == "/app/meter_driver_configs" + assert form.config_file_path() == "" # add mode has no provider yet + + provider = {"id": "abc", "base_url": "http://x", "selected_interface": "http"} + edit_form = providerform.MeterDriverSettingsForm( + formdata=None, provider=provider, provider_details=_DETAILS + ) + assert edit_form.config_file_path() == "/app/meter_driver_configs/abc.json" + + def test_default_selected_interface_falls_back_to_http(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm( + formdata=None, provider_details={"interfaces": [], "default_interface": ""} + ) + assert form._default_selected_interface() == "http" + + def test_default_selected_interface_prefers_default_then_first_choice(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + + form.provider_details = {"default_interface": "grpc"} + assert form._default_selected_interface() == "grpc" + + form.provider_details = {} + form.selected_interface.choices = [("mqtt", "MQTT")] + assert form._default_selected_interface() == "mqtt" + + def test_interface_choice_label_without_address(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + assert form._interface_choice_label({"type": "http", "label": "HTTP API"}) == "HTTP API" + + def test_validate_selected_interface_noop_without_details(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + form.service_url.data = "http://driver:18080" + form.provider_details = None + # Service URL present but no discovered details → nothing to check. + assert form.validate_selected_interface(form.selected_interface) is None + + def test_redirect_returns_to_driver_list(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + response = form.redirect() + assert response.status_code == 302 + assert response.headers["Location"].endswith("/config/meter-driver") + + def test_validate_service_url_blank_resets_to_http_only(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + form.service_url.data = " " + form.validate_service_url(form.service_url) + assert form.provider_details is None + assert form.selected_interface.choices == [("http", "HTTP API")] + + def test_validate_service_url_valid_refreshes_details(self, app, monkeypatch): + monkeypatch.setattr(provider_settings, "validate_contract", lambda url: _DETAILS) + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=None) + form.service_url.data = "http://driver:18080" + form.validate_service_url(form.service_url) + assert form.provider_details == _DETAILS + + def test_validate_service_url_invalid_raises(self, app, monkeypatch): + def boom(url): + raise provider_settings.ProviderRegistrationError("bad url") + + monkeypatch.setattr(provider_settings, "validate_contract", boom) + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=None) + form.service_url.data = "http://driver:18080" + with pytest.raises(ValidationError): + form.validate_service_url(form.service_url) + + def test_validate_selected_interface_rejects_unavailable(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + form.service_url.data = "http://driver:18080" + form.selected_interface.data = "carrier-pigeon" + with pytest.raises(ValidationError): + form.validate_selected_interface(form.selected_interface) + + def test_validate_selected_interface_noop_without_service_url(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + form.service_url.data = "" + # No service URL → nothing to validate against; returns without raising. + assert form.validate_selected_interface(form.selected_interface) is None + + def test_validate_aes_key_and_channel_are_cleared(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + form.aes_key.data = "secret" + form.channel.data = "26" + form.validate_aes_key(form.aes_key) + form.validate_channel(form.channel) + assert form.aes_key.data == "" + assert form.channel.data == "" + + def test_save_persists_and_returns_id(self, app, session, monkeypatch): + captured = {} + + def fake_save(service_url, selected_interface, enabled=True, provider_id=None): + captured.update( + service_url=service_url, + selected_interface=selected_interface, + enabled=enabled, + provider_id=provider_id, + ) + return "new-id" + + monkeypatch.setattr(provider_settings, "save_provider_settings", fake_save) + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + form.service_url.data = "http://driver:18080" + form.selected_interface.data = "http" + form.enabled.data = True + + provider_id = form.save() + + assert provider_id == "new-id" + assert form.saved_provider_id == "new-id" + assert captured["service_url"] == "http://driver:18080" + assert captured["provider_id"] is None # add mode + + def test_save_requires_service_url(self, app): + with app.test_request_context(): + form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + form.service_url.data = "" + with pytest.raises(RuntimeError): + form.save() + + def test_notification_message_varies_by_mode(self, app, monkeypatch): + monkeypatch.setattr(provider_settings, "get_provider_config_abspath", lambda provider: "/cfg/x.json") + with app.test_request_context(): + add_form = providerform.MeterDriverSettingsForm(formdata=None, provider_details=_DETAILS) + assert "registered" in str(add_form.notification_message()) + + provider = {"id": "abc", "base_url": "http://x", "selected_interface": "http"} + edit_form = providerform.MeterDriverSettingsForm( + formdata=None, provider=provider, provider_details=_DETAILS + ) + assert "updated" in str(edit_form.notification_message()) + + +class TestMeterDriverConfigEditorForm: + def test_init_loads_config_text_when_absent(self, app, monkeypatch): + monkeypatch.setattr( + provider_settings, "load_provider_config_text", lambda provider: '{"driver": {}}\n' + ) + provider = {"id": "abc"} + with app.test_request_context(): + form = providerform.MeterDriverConfigEditorForm( + formdata=None, provider=provider, provider_details=_DETAILS + ) + assert form.config_text.data == '{"driver": {}}\n' + assert [f["name"] for f in form.required_fields()] == ["aes_key", "channel"] + + def test_config_file_path(self, app, monkeypatch): + monkeypatch.setattr(provider_settings, "load_provider_config_text", lambda provider: "{}\n") + monkeypatch.setattr( + provider_settings, "get_provider_config_abspath", lambda provider: "/cfg/abc.json" + ) + with app.test_request_context(): + form = providerform.MeterDriverConfigEditorForm( + formdata=None, provider={"id": "abc"}, provider_details=_DETAILS + ) + assert form.config_file_path() == "/cfg/abc.json" + + def test_save_and_init_persists_then_initializes(self, app, monkeypatch): + calls = [] + monkeypatch.setattr(provider_settings, "load_provider_config_text", lambda provider: "{}\n") + monkeypatch.setattr( + provider_settings, + "save_provider_config_text", + lambda provider, text: calls.append(("save", text)) or ({"init_status": {}}, {}), + ) + monkeypatch.setattr( + provider_settings, + "init_provider_from_payload", + lambda provider, payload: calls.append(("init", payload)), + ) + with app.test_request_context(): + form = providerform.MeterDriverConfigEditorForm( + formdata=None, provider={"id": "abc"}, provider_details=_DETAILS + ) + form.config_text.data = '{"field_values": {}}' + form.save_and_init() + + # save receives the editor's JSON text; the payload save RETURNS (its + # first tuple element) is exactly what flows into init — not the raw + # text and not the validated dict. + assert calls == [ + ("save", '{"field_values": {}}'), + ("init", {"init_status": {}}), + ] + + def test_redirect_returns_to_driver_list(self, app, monkeypatch): + monkeypatch.setattr(provider_settings, "load_provider_config_text", lambda provider: "{}\n") + with app.test_request_context(): + form = providerform.MeterDriverConfigEditorForm( + formdata=None, provider={"id": "abc"}, provider_details=_DETAILS + ) + response = form.redirect() + assert response.status_code == 302 + assert response.headers["Location"].endswith("/config/meter-driver") diff --git a/sparkmeter/database/schemas/meterschema.sql b/sparkmeter/database/schemas/meterschema.sql index 6eb757e..138ca5d 100644 --- a/sparkmeter/database/schemas/meterschema.sql +++ b/sparkmeter/database/schemas/meterschema.sql @@ -124,6 +124,7 @@ CREATE OR REPLACE VIEW meter_view AS meter_billing.last_plan_payment_date AS last_plan_payment_date, meter_billing.last_plan_expiration_date AS last_plan_expiration_date, meter.meter_type AS meter_type, + meter.provider_id AS provider_id, meter_models.id AS model_id, meter_models.name AS model_name, plan_wallet.value AS plan_value, @@ -165,6 +166,7 @@ CREATE OR REPLACE VIEW meter_view AS meter.serial, meter.code, meter.meter_type, + meter.provider_id, meter_config.hidden, meter_config.subnet, meter_config.state, @@ -239,14 +241,15 @@ BEGIN RETURNING id INTO address_id; - INSERT INTO meter (id, code, serial, meter_type, address_id, ground_id, model_id) + INSERT INTO meter (id, code, serial, meter_type, address_id, ground_id, model_id, provider_id) VALUES (NEW.id, NEW.code, UPPER(NEW.serial), COALESCE(NEW.meter_type, 'customer'), address_id, NEW.ground_id, - NEW.model_id); + NEW.model_id, + NEW.provider_id); if NEW.meter_type = 'customer' THEN INSERT INTO meter_billing (meter_id, tariff_id, last_plan_payment_date, last_plan_expiration_date, @@ -361,6 +364,9 @@ BEGIN IF NEW.model_id IS DISTINCT FROM OLD.model_id THEN RAISE EXCEPTION 'updating meter_view.model_id is currently not supported'; END IF; + IF NEW.provider_id IS DISTINCT FROM OLD.provider_id THEN + UPDATE meter SET provider_id = NEW.provider_id WHERE id = NEW.id; + END IF; IF NEW.active IS DISTINCT FROM OLD.active THEN UPDATE meter_config SET hidden = NOT NEW.active FROM meter WHERE meter_config.meter_id = NEW.id; diff --git a/sparkmeter/meter/meterdomain.py b/sparkmeter/meter/meterdomain.py index 5bce498..516bae1 100644 --- a/sparkmeter/meter/meterdomain.py +++ b/sparkmeter/meter/meterdomain.py @@ -742,6 +742,9 @@ class Meter(BaseDomain): #: The ground this meter belongs to ground_id = Column(UUIDType(binary=False), ForeignKey("ground.id"), nullable=False) + #: Selected meter driver id from the registered driver list + provider_id = Column(String, nullable=True) + model_id = Column(UUIDType(binary=False), ForeignKey("meter_models.id")) # Relationships @@ -1273,6 +1276,7 @@ def _maybe_send_set_config(self, state=None, load_limit=None, nominal_voltage=No command = "disable" balance = 0 low_balance = False + provider_uses_engineering_units = bool(getattr(self, "provider_id", None)) if self.is_customer_meter(): tariff_load_limit = self.tariff.get_current_load_limit() @@ -1280,7 +1284,9 @@ def _maybe_send_set_config(self, state=None, load_limit=None, nominal_voltage=No nominal_voltage = nominal_voltage or parameters.NOMINAL_VOLTAGE continuous_power = self.continuous_current_limit * nominal_voltage - power_limit = old_div(min(continuous_power, tariff_load_limit), self.scalars.power_scalar) + power_limit = min(continuous_power, tariff_load_limit) + if not provider_uses_engineering_units: + power_limit = old_div(power_limit, self.scalars.power_scalar) if self.state_value == MeterConfig.STATE_ON and not override_meter_state: command = "enable" else: @@ -1289,7 +1295,10 @@ def _maybe_send_set_config(self, state=None, load_limit=None, nominal_voltage=No low_balance = self.has_low_balance() # don't allow a current_limit greater than the max possible value - current_limit = min(old_div(self.model.inrush_limit, self.scalars.current_scalar), 65535) + current_limit = self.model.inrush_limit + if not provider_uses_engineering_units: + current_limit = old_div(current_limit, self.scalars.current_scalar) + current_limit = min(current_limit, 65535) send_set_config( mac=self.code, @@ -1600,6 +1609,9 @@ class MeterView(BaseView): #: Ground serial (ground.serial) ground_serial = Column(String, nullable=False) + #: Selected meter driver id (meter.provider_id) + provider_id = Column(String, nullable=True) + model_id = Column(UUIDType(binary=False), ForeignKey("meter_models.id"), nullable=False) model_name = Column(String, nullable=False) diff --git a/sparkmeter/meter/meterform.py b/sparkmeter/meter/meterform.py index bf42e42..af2d61d 100644 --- a/sparkmeter/meter/meterform.py +++ b/sparkmeter/meter/meterform.py @@ -14,6 +14,12 @@ from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField from sparkmeter.config.configdict import config +from sparkmeter.config.provider_settings import ( + DriverConfigError, + get_saved_providers, + load_provider_runtime_settings, + validate_provider_config_payload, +) from sparkmeter.exceptions import MeterError from sparkmeter.ground.grounddomain import Ground from sparkmeter.meter.meterdomain import Meter, MeterTag, MeterView @@ -108,6 +114,11 @@ class BaseMeterForm(BaseForm): allow_blank=True, blank_text=_("Select a tariff"), ) + provider_id = SelectField( + _("Meter driver"), + choices=[], + default="", + ) customer_name = StringField(_("Name"), default="new customer") customer_code = StringField(_("Code")) customer_country_code = CountryCodeField(_("Country code"), default=config["DEFAULT_PHONE_COUNTRY_CODE"]) @@ -150,6 +161,7 @@ def __init__(self, formdata, obj=None, meter_type=None): self.meter_type = meter_type self.customer = getattr(obj, "customer", None) self.view = obj + self.provider_id.choices = self._provider_choices() if meter_type == Meter.TYPE_TOTALIZER: del self["customer_name"] @@ -159,6 +171,37 @@ def __init__(self, formdata, obj=None, meter_type=None): del self["state"] del self["tariff"] + def _provider_choices(self): + """Return eligible meter-driver choices for the form.""" + choices = [("", _("Select a meter driver"))] + providers = [] + for provider in get_saved_providers(): + if not provider.get("enabled"): + continue + payload = load_provider_runtime_settings(provider) + if not payload: + continue + try: + validate_provider_config_payload(payload) + except DriverConfigError: + continue + init_status = (payload.get("init_status") or {}) if isinstance(payload, dict) else {} + if not bool(init_status.get("has_successful_init")): + continue + providers.append(provider) + + providers.sort(key=lambda provider: str(provider.get("name") or "").lower()) + choices.extend( + [ + ( + str(provider.get("id") or ""), + str(provider.get("name") or provider.get("base_url") or "Meter driver"), + ) + for provider in providers + ] + ) + return choices + def validate_customer_national_number(self, field): """Validate the national number part of the form.""" if not field.data: @@ -185,6 +228,15 @@ def validate_tariff(self, field): if field.data is None: raise ValidationError(_("Please select a tariff or add a new one.")) + def validate_provider_id(self, field): + """Ensure the selected meter driver is one of the currently eligible choices.""" + available_values = [value for value, _label in self.provider_id.choices if value] + if available_values and not field.data: + raise ValidationError(_("Please select a meter driver.")) + valid_values = {value for value, _label in self.provider_id.choices} + if field.data not in valid_values: + raise ValidationError(_("Please select a valid meter driver.")) + def save(self, view): """Save content of meter form to database.""" if self.meter_type == Meter.TYPE_CUSTOMER and self.customer_national_number.data: diff --git a/sparkmeter/meter/templates/meter-form.html b/sparkmeter/meter/templates/meter-form.html index 99fc1aa..7e5902e 100644 --- a/sparkmeter/meter/templates/meter-form.html +++ b/sparkmeter/meter/templates/meter-form.html @@ -16,6 +16,193 @@ {%- if form.mode == 'edit' -%} {% block subtitle %}{{ _('Edit') }}{% endblock %} {%- endif -%} +{% block scripts %} + {{ super() }} + {% if meter_type == 'customer' %} + + {% endif %} +{% endblock scripts %} {% block breadcrumbs %} {% call breadcrumb(role='operator', url=url_for('ground.index')) %} @@ -57,6 +244,15 @@ {%- if form.mode == 'add' -%} {{ addon_form_field(form.serial, form_type="horizontal", horizontal_columns=('lg', 2, 10)) }} {%- endif -%} +
+ {{ form.provider_id.label(class="control-label col-lg-2") }} +
+ {{ form.provider_id(class="form-control") }} +
+
+
+ {{ form_error(form.provider_id) }} +
{{ form.tags.label(class_="control-label col-lg-2") }}
diff --git a/sparkmeter/meter/tests/test_meterdomain.py b/sparkmeter/meter/tests/test_meterdomain.py index 2db0011..4de4036 100644 --- a/sparkmeter/meter/tests/test_meterdomain.py +++ b/sparkmeter/meter/tests/test_meterdomain.py @@ -221,6 +221,24 @@ def test_send_set_config_unconditionally(self, config, send_set_config): firmware_version="abc1234", ) + def test_send_set_config_unconditionally_with_provider_uses_engineering_units( + self, config, send_set_config + ): + config["HEROKU"] = False + meter = MeterFactory(config__subnet=255, provider_id="sparknet-http") + meter.send_set_config_unconditionally() + self.session.commit() + send_set_config.assert_called_once_with( + load_limit=100.0, + subnet=255, + current_limit=20.0, + command="disable", + mac="1", + balance=0, + low_balance=False, + firmware_version="abc1234", + ) + def test_send_set_config_unconditionally_same_state(self, send_set_config): meter = MeterFactory(system_info__current_state=0, config__state=0) self.session.commit() diff --git a/sparkmeter/meter/tests/test_meterform.py b/sparkmeter/meter/tests/test_meterform.py new file mode 100644 index 0000000..ab432ba --- /dev/null +++ b/sparkmeter/meter/tests/test_meterform.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# Copyright © 2013-2018 SparkMeter, Inc. +# All Rights Reserved. +"""Tests for the meter web-interface forms.""" + +import pytest +from wtforms.validators import ValidationError + +from sparkmeter.config.provider_settings import DriverConfigError +from sparkmeter.meter.meterdomain import Meter + + +@pytest.fixture +def meterform(app): + """Import meterform after the app has populated config. + + meterform's class body reads config['DEFAULT_PHONE_COUNTRY_CODE'] at import + time, which is only available once the app is bootstrapped — so the import + is deferred into this app-dependent fixture rather than done at module top. + """ + from sparkmeter.meter import meterform as module + + return module + + +def _payload(has_successful_init, invalid=False): + """Build a runtime-settings payload shaped like the form inspects.""" + payload = {"init_status": {"has_successful_init": has_successful_init}} + if invalid: + payload["invalid"] = True + return payload + + +class TestProviderChoices: + def test_provider_choices_filters_ineligible_providers(self, meterform, app, session, monkeypatch): + # One provider for each way _provider_choices() rejects a provider, + # plus one that survives every filter. + providers = [ + {"id": "disabled", "name": "Disabled", "enabled": False}, + {"id": "nopayload", "name": "NoPayload", "enabled": True}, + {"id": "invalid", "name": "Invalid", "enabled": True}, + {"id": "noinit", "name": "NoInit", "enabled": True}, + {"id": "good", "name": "Good Driver", "enabled": True}, + ] + + runtime = { + "nopayload": {}, + "invalid": _payload(has_successful_init=True, invalid=True), + "noinit": _payload(has_successful_init=False), + "good": _payload(has_successful_init=True), + } + + def fake_runtime_settings(provider): + return runtime.get(provider["id"], {}) + + def fake_validate(payload): + if isinstance(payload, dict) and payload.get("invalid"): + raise DriverConfigError("required fields are missing values: aes_key") + return payload + + monkeypatch.setattr(meterform, "get_saved_providers", lambda: providers) + monkeypatch.setattr(meterform, "load_provider_runtime_settings", fake_runtime_settings) + monkeypatch.setattr(meterform, "validate_provider_config_payload", fake_validate) + + with app.test_request_context(): + form = meterform.MeterAddForm(formdata=None, meter_type=Meter.TYPE_CUSTOMER) + + # Only the fully eligible provider survives; the blank sentinel leads. + assert form.provider_id.choices == [ + ("", "Select a meter driver"), + ("good", "Good Driver"), + ] + + def test_provider_choices_sorted_by_name_with_label_fallback(self, meterform, app, session, monkeypatch): + providers = [ + {"id": "z", "name": "Zeta", "enabled": True}, + {"id": "a", "name": "alpha", "enabled": True}, + # No name: label falls back to base_url. + {"id": "m", "base_url": "http://mid:9000", "enabled": True}, + ] + + monkeypatch.setattr(meterform, "get_saved_providers", lambda: providers) + monkeypatch.setattr( + meterform, + "load_provider_runtime_settings", + lambda provider: _payload(has_successful_init=True), + ) + monkeypatch.setattr(meterform, "validate_provider_config_payload", lambda payload: payload) + + with app.test_request_context(): + form = meterform.MeterAddForm(formdata=None, meter_type=Meter.TYPE_CUSTOMER) + + # Sort key is the (lowercased) name only, so the nameless provider's + # empty key sorts it first; its label still falls back to base_url. + assert form.provider_id.choices == [ + ("", "Select a meter driver"), + ("m", "http://mid:9000"), + ("a", "alpha"), + ("z", "Zeta"), + ] + + +class TestValidateProviderId: + def _make_form(self, meterform, app, monkeypatch): + """Build a meter form with no auto-discovered drivers.""" + monkeypatch.setattr(meterform, "get_saved_providers", lambda: []) + with app.test_request_context(): + return meterform.MeterAddForm(formdata=None, meter_type=Meter.TYPE_CUSTOMER) + + def test_requires_selection_when_drivers_available(self, meterform, app, session, monkeypatch): + form = self._make_form(meterform, app, monkeypatch) + form.provider_id.choices = [("", "Select a meter driver"), ("p1", "Driver One")] + form.provider_id.data = "" + + with pytest.raises(ValidationError) as exc: + form.validate_provider_id(form.provider_id) + assert "Please select a meter driver." in str(exc.value) + + def test_rejects_selection_not_in_choices(self, meterform, app, session, monkeypatch): + form = self._make_form(meterform, app, monkeypatch) + form.provider_id.choices = [("", "Select a meter driver"), ("p1", "Driver One")] + form.provider_id.data = "bogus" + + with pytest.raises(ValidationError) as exc: + form.validate_provider_id(form.provider_id) + assert "Please select a valid meter driver." in str(exc.value) + + def test_accepts_eligible_selection(self, meterform, app, session, monkeypatch): + form = self._make_form(meterform, app, monkeypatch) + form.provider_id.choices = [("", "Select a meter driver"), ("p1", "Driver One")] + form.provider_id.data = "p1" + + # A selection that is among the choices passes without raising. + assert form.validate_provider_id(form.provider_id) is None + + def test_no_drivers_allows_empty_selection(self, meterform, app, session, monkeypatch): + form = self._make_form(meterform, app, monkeypatch) + # Only the blank sentinel: an empty selection is "no driver", allowed. + form.provider_id.choices = [("", "Select a meter driver")] + form.provider_id.data = "" + + assert form.validate_provider_id(form.provider_id) is None diff --git a/sparkmeter/templates/base.html b/sparkmeter/templates/base.html index b10429c..4e2f395 100644 --- a/sparkmeter/templates/base.html +++ b/sparkmeter/templates/base.html @@ -111,6 +111,11 @@ {{ _('Meters') }} +
  • + + {{ _('Meter Drivers') }} + +
  • diff --git a/sparkmeter/web/unittestutils.py b/sparkmeter/web/unittestutils.py index 142ad5d..90c5846 100644 --- a/sparkmeter/web/unittestutils.py +++ b/sparkmeter/web/unittestutils.py @@ -166,6 +166,13 @@ def verify(self, content): """Verify a file on disk.""" content = content.lstrip() + # Normalize CRLF/CR to LF so content round-trips through the golden file. + # The expected file is read back with universal-newline translation + # (open(...).read() turns "\r\n" and "\r" into "\n"), so any literal + # carriage return in the fresh content — e.g. WTForms' TextArea widget + # emits " + +
  • +
    + + +
    + + +
    + + + + + + + + + diff --git a/test-data/config/test_configviews-ConfigTest.test_meter_driver_config_post_config_error.page b/test-data/config/test_configviews-ConfigTest.test_meter_driver_config_post_config_error.page new file mode 100644 index 0000000..d349721 --- /dev/null +++ b/test-data/config/test_configviews-ConfigTest.test_meter_driver_config_post_config_error.page @@ -0,0 +1,244 @@ +> POST /config/meter-driver/config-driver/config HTTP/1.1 +> Content-Length: 37 +> Content-Type: application/x-www-form-urlencoded +> Cookie: session=%% SESSION %% +> Host: localhost +> User-Agent: Unittest/1.0 +> +> save_button=Save&config_text=not+json + +< HTTP/1.1 200 OK +< Content-Type: text/html; charset=utf-8 +< Set-Cookie: %% COOKIE %% +< Set-Cookie: %% COOKIE %% +< Vary: Cookie +< + + + + + + + + + + + + + + + SparkMeter ThunderCloud + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +

    + Edit driver config

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    config JSON is invalid
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + Edit driver config JSON +
    +
    +

    Update the generated JSON file, then save to write it and immediately attempt driver init.

    +

    Driver: SparkNet-Http

    +

    File: /srv/meter_driver_configs/config-driver.json

    + + +
    + +
    + + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    + + diff --git a/test-data/config/test_configviews-ConfigTest.test_meter_driver_config_post_init_error.page b/test-data/config/test_configviews-ConfigTest.test_meter_driver_config_post_init_error.page new file mode 100644 index 0000000..1a0850a --- /dev/null +++ b/test-data/config/test_configviews-ConfigTest.test_meter_driver_config_post_init_error.page @@ -0,0 +1,244 @@ +> POST /config/meter-driver/config-driver/config HTTP/1.1 +> Content-Length: 61 +> Content-Type: application/x-www-form-urlencoded +> Cookie: session=%% SESSION %% +> Host: localhost +> User-Agent: Unittest/1.0 +> +> save_button=Save&config_text=%7B%22field_values%22%3A+%7B%7D%7D + +< HTTP/1.1 200 OK +< Content-Type: text/html; charset=utf-8 +< Set-Cookie: %% COOKIE %% +< Set-Cookie: %% COOKIE %% +< Vary: Cookie +< + + + + + + + + + + + + + + + SparkMeter ThunderCloud + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +

    + Edit driver config

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    driver init failed: nope
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + Edit driver config JSON +
    +
    +

    Update the generated JSON file, then save to write it and immediately attempt driver init.

    +

    Driver: SparkNet-Http

    +

    File: /srv/meter_driver_configs/config-driver.json

    + + +
    + +
    + + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    + + diff --git a/test-data/config/test_configviews-ConfigTest.test_meter_driver_edit_get.page b/test-data/config/test_configviews-ConfigTest.test_meter_driver_edit_get.page new file mode 100644 index 0000000..ac8fdc0 --- /dev/null +++ b/test-data/config/test_configviews-ConfigTest.test_meter_driver_edit_get.page @@ -0,0 +1,265 @@ +> GET /config/meter-driver/driver-1/edit HTTP/1.1 +> Cookie: session=%% SESSION %% +> Host: localhost +> User-Agent: Unittest/1.0 +> + +< HTTP/1.1 200 OK +< Content-Type: text/html; charset=utf-8 +< Set-Cookie: %% COOKIE %% +< Set-Cookie: %% COOKIE %% +< Vary: Cookie +< + + + + + + + + + + + + + + + SparkMeter ThunderCloud + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +

    + Edit meter driver

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + Edit meter driver +
    +
    +

    Paste the base URL for a running meter driver service, for example SparkNet-Http. TC2.0 will validate its OpenAPI contract before saving it.

    +
    + + +
    + + +
    + + + + + + + +
    +
    + +
    + +
    + +
    +
    +
    + +
    +
    + +
    + +
    +
    + +
    + + + +
    + +
    + +
    +
    +
    +
    +
    +
    + +
    + + diff --git a/test-data/config/test_configviews-ConfigTest.test_meter_driver_with_saved_provider.page b/test-data/config/test_configviews-ConfigTest.test_meter_driver_with_saved_provider.page new file mode 100644 index 0000000..0f7bdc2 --- /dev/null +++ b/test-data/config/test_configviews-ConfigTest.test_meter_driver_with_saved_provider.page @@ -0,0 +1,285 @@ +> GET /config/meter-driver HTTP/1.1 +> Cookie: session=%% SESSION %% +> Host: localhost +> User-Agent: Unittest/1.0 +> + +< HTTP/1.1 200 OK +< Content-Type: text/html; charset=utf-8 +< Set-Cookie: %% COOKIE %% +< Set-Cookie: %% COOKIE %% +< Vary: Cookie +< + + + + + + + + + + + + + + + SparkMeter ThunderCloud + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +

    + Meter drivers

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + Meter drivers +
    +
    +
    +

    Register a standalone meter driver such as SparkNet-Http by entering the base URL of its HTTP service.

    +

    + + Register driver + +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    InitDriver nameBase URLConfig JSONVersionAvailable interfacesSelected interfaceEnabledStatusGatewayActions
    + + + + SparkNet-Httphttp://127.0.0.1:18080 + + /srv/meter_driver_configs/driver-1.json + + 1.2.3 +
    + HTTP API + (http) +
    http://127.0.0.1:18080
    +
    +
    + HTTP API + (http) +
    http://127.0.0.1:18080
    +
    Yes + Online + + + + Gateway Active + +
    sparknet
    +
    + + Edit + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    + + From 740f82f648b2da5b9e75e977b59f8b53a5ad81f9 Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:55:10 -0400 Subject: [PATCH 2/9] Support skipping legacy meter-scalar scaling on readings --- sparkmeter/controller.py | 13 +++-- sparkmeter/reading/js/latest-readings.js | 37 ++++++++++++-- sparkmeter/reading/js/latest-readings.spec.js | 18 +++++++ sparkmeter/reading/readingdomain.py | 2 + sparkmeter/reading/readingview.py | 26 +++++++++- sparkmeter/reading/tests/test_readingviews.py | 51 +++++++++++++++++++ sparkmeter/tests/test_controllers.py | 40 +++++++++++++++ 7 files changed, 177 insertions(+), 10 deletions(-) diff --git a/sparkmeter/controller.py b/sparkmeter/controller.py index a5a7293..4534407 100644 --- a/sparkmeter/controller.py +++ b/sparkmeter/controller.py @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) -def add_reading(data, update_meter_state=True): +def add_reading(data, update_meter_state=True, apply_meter_scalars=True): """ Save a raw reading to the database and process it. @@ -41,16 +41,18 @@ def add_reading(data, update_meter_state=True): :type data: dict :param update_meter_state: enable sending of config packets :type update_meter_state: bool + :param apply_meter_scalars: apply legacy meter IC scalars before persisting + :type apply_meter_scalars: bool :return: reading id """ with session_scope() as session: - reading, meter = save_raw_reading(data, session) + reading, meter = save_raw_reading(data, session, apply_meter_scalars=apply_meter_scalars) with session_scope() as session: reading_id = process_reading(reading, meter, session, update_meter_state) return reading_id -def save_raw_reading(data, session): +def save_raw_reading(data, session, apply_meter_scalars=True): """ Save a raw reading to the database. @@ -65,6 +67,8 @@ def save_raw_reading(data, session): :type data: dict :param session: The database session to use :type Session: + :param apply_meter_scalars: apply legacy meter IC scalars before persisting + :type apply_meter_scalars: bool :return: (reading, meter) """ # First check if there are any existing readings with the same heartbeat @@ -80,7 +84,8 @@ def save_raw_reading(data, session): ) meter = session.query(Meter).filter_by(code=data["meter"]).one() - data = meter.apply_scalars(data) + if apply_meter_scalars: + data = meter.apply_scalars(data) snapshot = Snapshot.get_or_create_meter_snapshot(code=str(data["meter"]), session=session) session.add(snapshot) reading = Reading( diff --git a/sparkmeter/reading/js/latest-readings.js b/sparkmeter/reading/js/latest-readings.js index 3912dda..cea634a 100644 --- a/sparkmeter/reading/js/latest-readings.js +++ b/sparkmeter/reading/js/latest-readings.js @@ -23,6 +23,17 @@ tlite(function(el) { exports.LatestReadings = LatestReadings; +function formatDecimalPlaces(value, places) { + if (value === "" || value === null || value === undefined) { + return ""; + } + var number = Number(value); + if (isNaN(number)) { + return value; + } + return number.toFixed(places); +} + LatestReadings.prototype = { heartbeat_seconds: 900, // 15 min default refresh_interval: null, @@ -54,21 +65,39 @@ LatestReadings.prototype = { { title: "Frequency (Hz)", data: "frequency", - type: "num" + type: "num", + render: function(data, type) { + if (type === "display") { + return formatDecimalPlaces(data, 2); + } + return data; + } }, { title: "Avg Voltage (V)", data: "voltage_avg", - type: "num" + type: "num", + render: function(data, type) { + if (type === "display") { + return formatDecimalPlaces(data, 2); + } + return data; + } }, { title: "Avg Current (A)", data: "current_avg", - type: "num" + type: "num", + render: function(data, type) { + if (type === "display") { + return formatDecimalPlaces(data, 3); + } + return data; + } }, { title: "True Power (W)", - data: "true_power_inst", + data: "true_power_avg", type: "num" }, { diff --git a/sparkmeter/reading/js/latest-readings.spec.js b/sparkmeter/reading/js/latest-readings.spec.js index 8715190..39ba762 100644 --- a/sparkmeter/reading/js/latest-readings.spec.js +++ b/sparkmeter/reading/js/latest-readings.spec.js @@ -32,6 +32,7 @@ const latest_readings = [ "heartbeat_end": "2013-01-01T01:00:15", "serial": "SM15R-01-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "1000", "user_power_limit": "", @@ -54,6 +55,7 @@ const latest_readings = [ "heartbeat_end": "2013-01-01T01:00:15", "serial": "SM15R-01-00000002", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "1000", "user_power_limit": "", @@ -76,6 +78,7 @@ const latest_readings = [ "heartbeat_end": "", "serial": "SM15R-01-00000003", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -160,6 +163,21 @@ describe('LastestReadings', () => { describe('Tooltip tests', () => { backend.mockCall('getLatestReadings', readings); let latestReadings = new LatestReadings.LatestReadings(); + test('Should format frequency display to two decimals', () => { + expect(latestReadings.columns[4].render(49.959999084472656, "display")).toBe( + "49.96" + ); + }); + test('Should format voltage display to two decimals', () => { + expect(latestReadings.columns[5].render(240.1599884033203, "display")).toBe( + "240.16" + ); + }); + test('Should format current display to three decimals', () => { + expect(latestReadings.columns[6].render(0.09800000488758087, "display")).toBe( + "0.098" + ); + }); test('Should format totalizer meter serial tooltip property', () => { expect(latestReadings._formatTooltipForField("serial", {"customer_name": null, "customer_code": "123"})).toBe( diff --git a/sparkmeter/reading/readingdomain.py b/sparkmeter/reading/readingdomain.py index 6338f47..e757172 100644 --- a/sparkmeter/reading/readingdomain.py +++ b/sparkmeter/reading/readingdomain.py @@ -45,6 +45,7 @@ class ReadingViewResult(object): heartbeat_end = None # type: datetime.datetime reading_id = None # type: uuid.UUID reading_state = None # type: str + true_power_avg = None # type: float true_power_inst = None # type: float uptime = None # type: float user_power_limit = None # type: float @@ -250,6 +251,7 @@ def get_latest_reading_view(cls, ground=None, user=None): cls.heartbeat_end, cls.id.label("reading_id"), cls.state.label("reading_state"), # Conflicts w/ address.state + cls.true_power_avg, cls.true_power_inst, cls.uptime, cls.user_power_limit, diff --git a/sparkmeter/reading/readingview.py b/sparkmeter/reading/readingview.py index 014bb3d..da4956e 100644 --- a/sparkmeter/reading/readingview.py +++ b/sparkmeter/reading/readingview.py @@ -28,6 +28,26 @@ ReadingViewResult = ReadingViewResult # noqa +def _latest_readings_heartbeat_seconds(): + """Return the heartbeat interval the UI should use for latest readings.""" + try: + from sparkmeter.config.provider_settings import ( + get_enabled_provider, + load_provider_runtime_settings, + ) + + provider = get_enabled_provider() + payload = load_provider_runtime_settings(provider) + field_values = ((payload or {}).get("field_values")) or {} + heartbeat = field_values.get("heartbeat_period_duration") + if heartbeat not in (None, ""): + return int(heartbeat) + except Exception: # noqa: BLE001 + logger.exception("failed to resolve meter-driver heartbeat for latest readings UI") + + return config["HEARTBEAT_PERIOD"] * 60 + + @reading.route("/readings/latest") @roles_accepted("operator") def latest(): @@ -42,7 +62,7 @@ def latest_data_json(): ground_serial = request.args.get("ground_serial") readings = _query_latest_readings(ground_serial) return jsonify( - heartbeat_seconds=config["HEARTBEAT_PERIOD"] * 60, + heartbeat_seconds=_latest_readings_heartbeat_seconds(), readings=readings, ) @@ -62,7 +82,7 @@ def latest_data_csv(): "frequency", "voltage_avg", "current_avg", - "true_power_inst", + "true_power_avg", "energy", "uptime", "user_power_limit", @@ -107,6 +127,7 @@ def _format_reading_view(result): frequency=result.frequency, heartbeat_end=result.heartbeat_end, state=MeterState.get_state_translation_from_id(result.reading_state), + true_power_avg=int(round(result.true_power_avg)), true_power_inst=int(round(result.true_power_inst)), uptime=result.uptime, user_power_limit=result.user_power_limit, @@ -125,6 +146,7 @@ def _format_reading_view(result): frequency="", heartbeat_end="", state="", + true_power_avg="", true_power_inst="", uptime="", user_power_limit="", diff --git a/sparkmeter/reading/tests/test_readingviews.py b/sparkmeter/reading/tests/test_readingviews.py index b276a66..fe9fed8 100644 --- a/sparkmeter/reading/tests/test_readingviews.py +++ b/sparkmeter/reading/tests/test_readingviews.py @@ -2,6 +2,7 @@ # Copyright © 2013-2017 SparkMeter, Inc. # All Rights Reserved. import datetime +import logging from freezegun import freeze_time @@ -115,3 +116,53 @@ def test_latest_readings_totalizers_present(self, client, config): data = response.json() assert len(data["readings"]) == 2 self.verify_response(response) + + def test_latest_readings_uses_meter_driver_heartbeat(self, client, config, monkeypatch): + config["HEROKU"] = False + path = "/readings/latest.json" + + monkeypatch.setattr( + "sparkmeter.reading.readingview._query_latest_readings", + lambda ground_serial: [], + ) + monkeypatch.setattr( + "sparkmeter.config.provider_settings.get_enabled_provider", + lambda: {"id": "driver-1"}, + ) + monkeypatch.setattr( + "sparkmeter.config.provider_settings.load_provider_runtime_settings", + lambda provider: { + "field_values": { + "heartbeat_period_duration": "60", + } + }, + ) + + response = client.get(path) + + assert response.json()["heartbeat_seconds"] == 60 + + def test_latest_readings_heartbeat_falls_back_on_error(self, client, config, monkeypatch, caplog): + # A provider-lookup failure is logged and falls back to HEARTBEAT_PERIOD (readingview lines 45-46). + config["HEROKU"] = False + config["HEARTBEAT_PERIOD"] = 15 + path = "/readings/latest.json" + + monkeypatch.setattr( + "sparkmeter.reading.readingview._query_latest_readings", + lambda ground_serial: [], + ) + + def boom(): + raise RuntimeError("provider lookup failed") + + monkeypatch.setattr( + "sparkmeter.config.provider_settings.get_enabled_provider", + boom, + ) + + caplog.set_level(logging.ERROR, logger="sparkmeter.reading.readingview") + response = client.get(path) + + assert response.json()["heartbeat_seconds"] == 15 * 60 + assert "failed to resolve meter-driver heartbeat for latest readings UI" in caplog.text diff --git a/sparkmeter/tests/test_controllers.py b/sparkmeter/tests/test_controllers.py index 9d5f9e5..63c51c8 100644 --- a/sparkmeter/tests/test_controllers.py +++ b/sparkmeter/tests/test_controllers.py @@ -442,6 +442,46 @@ def test_add_totalizer_reading(self, config, send_set_config): assert "tariff" not in snapshot_payload assert send_set_config.mock_calls == [] + def test_add_reading_can_skip_legacy_scaling(self, config, send_set_config): + config["HEROKU"] = False + meter = MeterFactory(system_info__last_energy=0, credit_wallet__value=1000) + self.session.commit() + + reading_data = { + "kilowatt_hours": 0, + "kilowatt_hours_period": 0, + "meter": meter.code, + "heartbeat_start": datetime(2013, 1, 1, 1, 0, 1), + "heartbeat_end": datetime(2013, 1, 1, 1, 1, 1), + "frequency": 49.98, + "voltage_min": 240.1, + "voltage_max": 240.2, + "voltage_avg": 240.15, + "current_min": 0.098, + "current_max": 0.100, + "current_avg": 0.098, + "true_power_inst": 22.0, + "true_power_avg": 22.0, + "apparent_power_avg": 22.0, + "power_factor_avg": 0.996, + "energy": 6.195, + "uptime": 100, + "state": "on", + "user_power_limit": 1200, + } + + reading_id = add_reading(reading_data, apply_meter_scalars=False) + + reading = Reading.get_by_id(reading_id) + + assert reading.frequency == reading_data["frequency"] + assert reading.voltage_avg == reading_data["voltage_avg"] + assert reading.current_avg == reading_data["current_avg"] + assert reading.true_power_inst == reading_data["true_power_inst"] + assert reading.energy == reading_data["energy"] + assert reading.user_power_limit == reading_data["user_power_limit"] + assert send_set_config.mock_calls == [] + def test_add_duplicate_reading(self, config, send_set_config): config["HEROKU"] = False meter = MeterFactory(system_info__last_energy=12, credit_wallet__value=1000) From c07e79e2689d3df4fe7b4333ae85374aa3d28538 Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:48:02 -0400 Subject: [PATCH 3/9] Add the metering runtime on meter-driver-spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the metering subsystem — runtime client, command dispatch, event stream, lifespan and reconciliation — talking to a driver over gRPC and HTTP+SSE using the released meter-driver-spec wheel. Delete the vendored sparkmeter/metering/_generated OpenAPI client and the regen-metering-wire.sh script that produced it; the local codegen workflow and its pre-commit and setup.cfg entries go with them. --- .gitattributes | 1 - .github/workflows/ci.yml | 36 - .gitignore | 3 + .pre-commit-config.yaml | 8 +- pyproject.toml | 15 +- scripts/regen-metering-wire.sh | 34 - setup.cfg | 4 - sparkmeter/asgi.py | 11 + sparkmeter/config/provider_settings.py | 43 +- sparkmeter/event/eventspecs.py | 7 + sparkmeter/meter/metercommand.py | 34 - sparkmeter/meter/meterform.py | 15 +- sparkmeter/meter/meterstate.py | 16 + sparkmeter/meter/tests/test_metercommand.py | 76 -- sparkmeter/metering/__init__.py | 8 +- sparkmeter/metering/_generated/__init__.py | 19 - sparkmeter/metering/_generated/client.py | 83 -- sparkmeter/metering/_generated/core/README.md | 21 - .../metering/_generated/core/__init__.py | 42 - .../metering/_generated/core/auth/__init__.py | 10 - .../metering/_generated/core/auth/base.py | 22 - .../metering/_generated/core/auth/plugins.py | 89 -- .../_generated/core/cattrs_converter.py | 950 ------------------ sparkmeter/metering/_generated/core/config.py | 6 - .../_generated/core/exception_aliases.py | 17 - .../metering/_generated/core/exceptions.py | 20 - .../_generated/core/http_transport.py | 222 ---- .../metering/_generated/core/pagination.py | 64 -- sparkmeter/metering/_generated/core/py.typed | 0 .../_generated/core/streaming_helpers.py | 84 -- sparkmeter/metering/_generated/core/utils.py | 520 ---------- .../metering/_generated/endpoints/__init__.py | 2 - .../metering/_generated/endpoints/default.py | 132 --- .../metering/_generated/endpoints/py.typed | 0 .../metering/_generated/mocks/__init__.py | 24 - .../_generated/mocks/endpoints/__init__.py | 11 - .../mocks/endpoints/mock_default.py | 63 -- .../metering/_generated/mocks/mock_client.py | 56 -- .../metering/_generated/models/__init__.py | 321 ------ .../models/associate_meter_command.py | 56 -- .../associate_meter_command_vendor_options.py | 91 -- .../models/associate_meter_params.py | 47 - .../associate_meter_params_set_aes_key.py | 8 - .../associate_meter_params_set_channel.py | 7 - .../models/associate_meter_params_set_mac.py | 7 - .../models/cancel_firmware_update_command.py | 47 - ..._firmware_update_command_vendor_options.py | 91 -- .../models/cancel_firmware_update_params.py | 24 - .../_generated/models/capabilities_event.py | 61 -- .../capabilities_event_vendor_capabilities.py | 91 -- .../_generated/models/capability_flag.py | 53 - .../_generated/models/command_accepted.py | 30 - .../models/command_accepted_event.py | 44 - .../models/command_accepted_event_result.py | 7 - .../models/command_applied_event.py | 42 - .../models/command_applied_event_result.py | 7 - .../_generated/models/command_cached_event.py | 37 - .../_generated/models/command_failed_event.py | 47 - .../models/command_failed_event_detail.py | 7 - .../models/command_rejected_event.py | 46 - .../models/command_rejected_event_detail.py | 7 - .../models/command_timed_out_event.py | 36 - .../configure_high_capacity_meter_command.py | 48 - ...h_capacity_meter_command_vendor_options.py | 91 -- .../configure_high_capacity_meter_params.py | 32 - .../models/configure_meter_command.py | 55 - .../configure_meter_command_vendor_options.py | 91 -- .../models/configure_meter_params.py | 44 - .../models/configure_provider_command.py | 56 -- ...nfigure_provider_command_vendor_options.py | 91 -- .../models/configure_provider_params.py | 28 - .../enter_configuration_mode_command.py | 51 - ...nfiguration_mode_command_vendor_options.py | 91 -- .../models/enter_configuration_mode_params.py | 38 - .../models/firmware_update_session_status.py | 23 - .../models/firmware_update_status_event.py | 70 -- ...mware_update_status_event_failed_meters.py | 115 --- ...re_update_status_event_progress_percent.py | 7 - .../_generated/models/firmware_version.py | 32 - .../models/heartbeat_summary_event.py | 69 -- ...eat_summary_event_read_reply_latency_ms.py | 11 - ...mmary_event_set_config_reply_latency_ms.py | 11 - .../high_capacity_meter_configuration.py | 35 - .../models/http_validation_error.py | 28 - .../models/http_validation_error_2.py | 28 - .../metering/_generated/models/input_.py | 13 - .../metering/_generated/models/log_event.py | 41 - .../metering/_generated/models/log_level.py | 23 - .../models/meter_behavior_command.py | 29 - .../_generated/models/meter_config_event.py | 69 -- .../models/meter_config_event_balance.py | 7 - .../meter_config_event_firmware_version.py | 11 - .../_generated/models/meter_configuration.py | 43 - .../_generated/models/meter_error_entry.py | 43 - .../models/meter_error_entry_description.py | 7 - .../models/meter_error_entry_location.py | 8 - ...eter_error_entry_timestamp_unix_seconds.py | 7 - .../_generated/models/meter_errors_event.py | 47 - .../models/meter_firmware_changed_event.py | 42 - .../models/meter_instant_reading_event.py | 79 -- .../_generated/models/meter_memory_event.py | 48 - .../_generated/models/meter_neighbor.py | 43 - .../meter_neighbor_last_seen_unix_seconds.py | 7 - .../models/meter_neighbor_link_quality.py | 7 - .../models/meter_neighbor_rssi_dbm.py | 7 - .../models/meter_neighbors_event.py | 47 - .../models/meter_network_statistics.py | 66 -- ...etwork_statistics_read_reply_latency_ms.py | 11 - ..._statistics_set_config_reply_latency_ms.py | 11 - .../_generated/models/meter_reading_event.py | 114 --- .../models/meter_reading_phased_event.py | 91 -- .../meter_reading_phased_event_per_phase.py | 117 --- .../_generated/models/meter_register_event.py | 48 - .../metering/_generated/models/meter_state.py | 42 - .../_generated/models/meter_version_event.py | 52 - .../meter_version_event_bootloader_version.py | 11 - .../_generated/models/network_health_event.py | 49 - .../metering/_generated/models/phase.py | 19 - .../_generated/models/phase_reading.py | 67 -- .../_generated/models/ping_meter_command.py | 49 - .../ping_meter_command_vendor_options.py | 91 -- .../_generated/models/ping_meter_params.py | 24 - .../models/provider_status_event.py | 58 -- .../metering/_generated/models/py.typed | 0 .../models/query_capabilities_command.py | 48 - ...ery_capabilities_command_vendor_options.py | 91 -- .../models/query_capabilities_params.py | 13 - .../query_firmware_update_status_command.py | 48 - ...re_update_status_command_vendor_options.py | 91 -- .../query_firmware_update_status_params.py | 28 - ...irmware_update_status_params_session_id.py | 8 - .../models/query_meter_config_command.py | 48 - ...ery_meter_config_command_vendor_options.py | 91 -- .../models/query_meter_config_params.py | 24 - .../models/query_meter_errors_command.py | 48 - ...ery_meter_errors_command_vendor_options.py | 91 -- .../models/query_meter_errors_params.py | 24 - .../models/query_meter_neighbors_command.py | 48 - ..._meter_neighbors_command_vendor_options.py | 91 -- .../models/query_meter_neighbors_params.py | 24 - .../models/query_meter_version_command.py | 48 - ...ry_meter_version_command_vendor_options.py | 91 -- .../models/query_meter_version_params.py | 24 - .../models/query_network_health_command.py | 49 - ...y_network_health_command_vendor_options.py | 91 -- .../models/query_network_health_params.py | 27 - .../query_network_health_params_meter_id.py | 7 - .../models/query_provider_status_command.py | 48 - ..._provider_status_command_vendor_options.py | 91 -- .../models/query_provider_status_params.py | 13 - .../models/read_meter_memory_command.py | 49 - ...ead_meter_memory_command_vendor_options.py | 91 -- .../models/read_meter_memory_params.py | 32 - .../models/read_meter_now_command.py | 49 - .../read_meter_now_command_vendor_options.py | 91 -- .../models/read_meter_now_params.py | 24 - .../models/read_meter_register_command.py | 48 - ...d_meter_register_command_vendor_options.py | 91 -- .../models/read_meter_register_params.py | 28 - .../models/register_meter_command.py | 61 -- .../register_meter_command_vendor_options.py | 91 -- .../models/register_meter_params.py | 59 -- .../register_meter_params_firmware_version.py | 11 - .../register_meter_params_initial_balance.py | 7 - .../models/reset_meter_errors_command.py | 48 - ...set_meter_errors_command_vendor_options.py | 91 -- .../models/reset_meter_errors_params.py | 24 - .../_generated/models/rf_test_command.py | 47 - .../models/rf_test_command_vendor_options.py | 91 -- .../_generated/models/rf_test_params.py | 33 - .../_generated/models/rf_test_result_event.py | 60 -- .../models/rf_test_result_event_rssi_dbm.py | 7 - .../_generated/models/set_balance_command.py | 55 - .../set_balance_command_vendor_options.py | 91 -- .../_generated/models/set_balance_params.py | 38 - .../models/set_balance_params_balance.py | 7 - .../models/start_firmware_update_command.py | 57 -- ..._firmware_update_command_vendor_options.py | 91 -- .../models/start_firmware_update_params.py | 41 - ...rt_firmware_update_params_target_meters.py | 7 - .../_generated/models/statistics_histogram.py | 41 - ...ream_events_v_1_events_get_200_response.py | 145 --- ...events_get_200_response_event_type_enum.py | 62 -- ...m_events_v_1_events_get_param_client_id.py | 8 - ...tream_events_v_1_events_get_param_types.py | 8 - ..._command_v_1_commands_post_request_body.py | 155 --- ...nds_post_request_body_command_type_enum.py | 70 -- .../_generated/models/throttle_config.py | 34 - .../models/unregister_meter_command.py | 47 - ...unregister_meter_command_vendor_options.py | 91 -- .../models/unregister_meter_params.py | 24 - .../_generated/models/validation_error.py | 45 - .../_generated/models/validation_error_ctx.py | 13 - .../_generated/models/validation_error_loc.py | 11 - .../models/validation_error_loc_item.py | 7 - .../models/write_meter_register_command.py | 47 - ...e_meter_register_command_vendor_options.py | 91 -- .../models/write_meter_register_params.py | 32 - sparkmeter/metering/_generated/py.typed | 0 sparkmeter/metering/cli.py | 632 +++++------- sparkmeter/metering/dispatch.py | 154 ++- sparkmeter/metering/events.py | 360 ++++--- sparkmeter/metering/http_sse.py | 58 ++ sparkmeter/metering/lifespan.py | 536 +++++++++- sparkmeter/metering/provider_config.py | 21 + sparkmeter/metering/reconcile.py | 244 +++-- sparkmeter/metering/runtime_client.py | 567 +++++++++++ sparkmeter/metering/tests/test_cli.py | 580 +++++++---- sparkmeter/metering/tests/test_dispatch.py | 282 ++++-- sparkmeter/metering/tests/test_events.py | 508 +++++++--- sparkmeter/metering/tests/test_http_sse.py | 120 +++ sparkmeter/metering/tests/test_lifespan.py | 937 ++++++++++++++++- .../metering/tests/test_provider_config.py | 33 + sparkmeter/metering/tests/test_reconcile.py | 448 +++++++-- .../metering/tests/test_runtime_client.py | 700 +++++++++++++ sparkmeter/metering/tools/__init__.py | 0 sparkmeter/metering/tools/cli_client.py | 166 --- sparkmeter/tests/test_asgi.py | 58 ++ ...ews0-TestConfigParameterList.test_get.page | 7 + ...t_configviews-ConfigTest.test_billing.page | 5 + ...figviews-ConfigTest.test_meter_driver.page | 245 +++++ ...st_configviews-ConfigTest.test_meters.page | 5 + .../test_configviews-ConfigTest.test_sms.page | 5 + ...oardview-DashboardViewTest.test_index.page | 5 + ...tionTest.test_tables-config_parameter.json | 9 + ...ation-MigrationTest.test_tables-meter.json | 3 + ...ration-MigrationTest.test_tables-meter.sql | 1 + ...ion-not-implemented-delete-collection.page | 5 + ...lete_item-not-implemented-delete-item.page | 5 + ...ection-not-implemented-get-collection.page | 5 + ...est_get_item-not-implemented-get-item.page | 5 + ...nd-not-implemented-get-item-not-found.page | 5 + ...ction-not-implemented-post-collection.page | 5 + ...d-not-implemented-post-item-not-found.page | 5 + ...ection-not-implemented-put-collection.page | 5 + ...est_put_item-not-implemented-put-item.page | 5 + ...st_eventviews-EventTest.test_messages.page | 5 + ...ndviews-GroundViewTest.test_edit-post.page | 5 + ..._groundviews-GroundViewTest.test_edit.page | 5 + ...ViewTest.test_edit_max_capacity_blank.page | 5 + ...wTest.test_edit_max_capacity_negative.page | 5 + ...t.test_edit_max_capacity_over_max_int.page | 5 + ...ws-GroundViewTest.test_edit_not_found.page | 5 + ...-GroundViewTest.test_index_cloud_none.page | 5 + ...s-GroundViewTest.test_index_cloud_one.page | 5 + ...s-GroundViewTest.test_index_cloud_two.page | 5 + ...iews-GroundViewTest.test_index_ground.page | 5 + ...ewTest.test_manual_override_not_found.page | 5 + ...undviews-GroundViewTest.test_override.page | 5 + ...iewTest.test_override_cloud_not_found.page | 5 + ...roundViewTest.test_override_not_found.page | 5 + ...ViewTest.test_override_without_serial.page | 5 + ...-GroundViewTest.test_status_not_found.page | 5 + ...iewTest.test_cloud_portal_link_absent.page | 5 + ....test_cloud_portal_link_absent_ground.page | 5 + ...ewTest.test_cloud_portal_link_present.page | 5 + ...erViewTest.test_insert_customer_meter.json | 1 + ...rViewTest.test_insert_totalizer_meter.json | 1 + ...terViewTest.test_reset_customer_meter.json | 1 + ...meterdomain-MeterViewTest.test_select.json | 3 + ...erViewTest.test_update_customer_meter.json | 1 + ...rViewTest.test_update_totalizer_meter.json | 1 + ...eterviews-MeterViewTest.test_add-post.page | 5 + ...est_meterviews-MeterViewTest.test_add.page | 199 +++- ...rViewTest.test_add_32_bit_serial-post.page | 5 + ...-MeterViewTest.test_add_32_bit_serial.page | 199 +++- ...erViewTest.test_add_duplicated_serial.page | 199 +++- ...rViewTest.test_add_empty_country_code.page | 199 +++- ...rViewTest.test_add_empty_phone_number.page | 199 +++- ...eterViewTest.test_add_from_cloud-post.page | 5 + ...ews-MeterViewTest.test_add_from_cloud.page | 199 +++- ...iewTest.test_add_invalid_phone_number.page | 199 +++- ...est_add_invalid_serial-invalid-serial.page | 199 +++- ...MeterViewTest.test_add_invalid_serial.page | 199 +++- ...MeterViewTest.test_add_totalizer-post.page | 5 + ...iews-MeterViewTest.test_add_totalizer.page | 17 +- ...st.test_add_totalizer_from_cloud-post.page | 5 + ...iewTest.test_add_totalizer_from_cloud.page | 17 +- ...-MeterViewTest.test_add_unknown_model.page | 199 +++- ...ViewTest.test_chart_customer-customer.page | 5 + ...ws-MeterViewTest.test_chart_not_found.page | 5 + ...ewTest.test_chart_totalizer-totalizer.page | 5 + ...rViewTest.test_code_redirect-subpage2.page | 5 + ...omer_state_cloud-customer-state-cloud.page | 199 +++- ...er_state_ground-customer-state-ground.page | 199 +++- ...er_tariff_cloud-customer-tariff-cloud.page | 199 +++- ...ews-MeterViewTest.test_edit_not_found.page | 5 + ...ews-MeterViewTest.test_edit_totalizer.page | 17 +- ...wTest.test_edit_with_tags-prepopulate.page | 199 +++- ...ews-MeterViewTest.test_edit_with_tags.page | 199 +++- ...ewTest.test_messages_export_not_found.page | 5 + ...ViewTest.test_messages_json_not_found.page | 5 + ...erViewTest.test_reset_meter_not_found.page | 5 + ...iewTest.test_serial_redirect-subpage2.page | 5 + ...eterViewTest.test_set_state_not_found.page | 5 + ...wTest.test_transactions_csv_not_found.page | 5 + ...Test.test_transactions_json_not_found.page | 5 + ...terViewTest.test_unknown_server_error.page | 17 +- ...erviews-MeterViewTest.test_view_cloud.page | 5 + ...rviews-MeterViewTest.test_view_ground.page | 5 + ...ViewTest.test_view_ground_error_state.page | 5 + ...ews-MeterViewTest.test_view_not_found.page | 5 + ...er_verified-phone-number-not-verified.page | 5 + ...number_verified-phone-number-verified.page | 5 + ...ews-MeterViewTest.test_view_totalizer.page | 5 + ...st.test_latest_readings-csv-cloud-all.page | 2 +- ...t.test_latest_readings-csv-cloud-none.page | 2 +- ...test_latest_readings-csv-cloud-only-1.page | 2 +- ...test_latest_readings-csv-cloud-only-2.page | 2 +- ....test_latest_readings-csv-ground1-all.page | 2 +- ...test_latest_readings-csv-ground1-none.page | 2 +- ...st_latest_readings-csv-ground1-only-1.page | 2 +- ...st_latest_readings-csv-ground1-only-2.page | 2 +- ....test_latest_readings-csv-ground2-all.page | 2 +- ...test_latest_readings-csv-ground2-none.page | 2 +- ...st_latest_readings-csv-ground2-only-1.page | 2 +- ...st_latest_readings-csv-ground2-only-2.page | 2 +- ...t.test_latest_readings-json-cloud-all.page | 7 + ...est_latest_readings-json-cloud-only-1.page | 4 + ...est_latest_readings-json-cloud-only-2.page | 3 + ...test_latest_readings-json-ground1-all.page | 4 + ...t_latest_readings-json-ground1-only-1.page | 4 + ...test_latest_readings-json-ground2-all.page | 3 + ...t_latest_readings-json-ground2-only-2.page | 3 + ...wTest.test_latest_readings_page_cloud.page | 5 + ...Test.test_latest_readings_page_ground.page | 5 + ...st_latest_readings_totalizers_present.page | 2 + ...ountViewTest.test_add_duplicated_name.page | 5 + ...AccountViewTest.test_add_missing_name.page | 5 + ...sAccountViewTest.test_edit_global-get.page | 5 + ...sAccountViewTest.test_edit_restricted.page | 5 + ...tview-SalesAccountViewTest.test_index.page | 5 + ...SalesAccountViewTest.test_view_global.page | 5 + ...ountViewTest.test_view_no_permissions.page | 5 + ...sAccountViewTest.test_view_restricted.page | 5 + ...t_tariffviews-TariffViewTest.test_add.page | 5 + ...iffviews-TariffViewTest.test_add_form.page | 5 + ...iewTest.test_add_form_int_max_allowed.page | 5 + ...ffViewTest.test_add_form_int_outrange.page | 5 + ...iewTest.test_add_form_with_blockrates.page | 5 + ...ewTest.test_add_form_with_load_limits.page | 5 + ...est.test_add_form_with_tous-tous-post.page | 5 + ...Test.test_add_with_invalid_blockrates.page | 5 + ...ffViewTest.test_add_with_invalid_tous.page | 5 + ...st.test_edit-edit-negative-post-error.page | 5 + ...iffViewTest.test_edit-edit-post-error.page | 5 + ...s-TariffViewTest.test_edit-new-tariff.page | 5 + ..._tariffviews-TariffViewTest.test_edit.page | 5 + ...s-TariffViewTest.test_error_blockrate.page | 5 + ...iffViewTest.test_error_duplicate_name.page | 5 + ...-TariffViewTest.test_error_empty_name.page | 5 + ...fViewTest.test_error_enter_load_limit.page | 5 + ...t.test_error_enter_monthly_plan_price.page | 5 + ...t.test_error_existing_duplicate_names.page | 5 + ...s-TariffViewTest.test_error_flat_rate.page | 5 + ...ViewTest.test_error_low_balance_empty.page | 5 + ...wTest.test_error_low_balance_negative.page | 5 + ...iewTest.test_error_negative_flat_rate.page | 5 + ...ewTest.test_error_negative_load_limit.page | 5 + ...t.test_error_no_scheduled_load_limits.page | 5 + ...fviews-TariffViewTest.test_error_tous.page | 5 + ..._tariffviews-TariffViewTest.test_list.page | 5 + ..._tariffviews-TariffViewTest.test_view.page | 5 + ...ws-TariffViewTest.test_view_not_found.page | 5 + ...ionviews-TransactionViewTest.test_add.page | 5 + ...ews-TransactionViewTest.test_add_debt.page | 5 + ...ansactionViewTest.test_add_debt_error.page | 5 + ...t.test_add_from_disabled_account-post.page | 5 + ...ewTest.test_add_from_disabled_account.page | 5 + ...tionViewTest.test_add_meter_not_found.page | 5 + ...actionViewTest.test_reverse_not_found.page | 5 + ...TransactionViewTest.test_transactions.page | 5 + ...wTest.test_transfer_account_not_found.page | 5 + ...est.test_add_account_all_access-empty.page | 5 + ...UserViewTest.test_add_duplicate_email.page | 5 + ...rViewTest.test_add_duplicate_username.page | 5 + ...serviews-UserViewTest.test_add_errors.page | 5 + ...Test.test_add_ground_all_access-empty.page | 5 + ...ewTest.test_add_mismatching_passwords.page | 5 + ...ViewTest.test_add_missing_email-empty.page | 5 + ...erViewTest.test_add_missing_passwords.page | 5 + ...serViewTest.test_add_missing_username.page | 5 + ...-UserViewTest.test_add_operator-empty.page | 5 + ...ws-UserViewTest.test_add_vendor-empty.page | 5 + ...rviews-UserViewTest.test_bad_username.page | 5 + ...ews-UserViewTest.test_edit-redirected.page | 5 + ...test_userviews-UserViewTest.test_edit.page | 5 + ...s-UserViewTest.test_edit_unknown_user.page | 5 + ...test_userviews-UserViewTest.test_list.page | 5 + ...t.test_reset_credentials_unknown_user.page | 5 + ...ViewTest.test_update_locale-not-found.page | 5 + ...-UserViewTest.test_user_add_api-empty.page | 5 + ...test_user_sales_accounts_invalid_user.page | 5 + ...test_userviews-UserViewTest.test_view.page | 5 + ..._userviews-UserViewTest.test_view_api.page | 5 + ...s-UserViewTest.test_view_unknown_user.page | 5 + ...WebViewTest.test_change_password_view.page | 5 + ...Test.test_change_password_view_portal.page | 5 + ...iewTest.test_override_banner_on_cloud.page | 5 + ...ewTest.test_override_banner_on_ground.page | 5 + ...t_views-WebViewTest.test_readonly_app.page | 5 + ...iews-WebViewTest.test_reset_demo_data.page | 5 + ...iewTest.test_reset_demo_data_disabled.page | 5 + ...ewTest.test_reset_demo_data_post-post.page | 5 + uv.lock | 131 ++- 405 files changed, 9172 insertions(+), 11854 deletions(-) delete mode 100644 .gitattributes delete mode 100755 scripts/regen-metering-wire.sh delete mode 100644 sparkmeter/metering/_generated/__init__.py delete mode 100644 sparkmeter/metering/_generated/client.py delete mode 100644 sparkmeter/metering/_generated/core/README.md delete mode 100644 sparkmeter/metering/_generated/core/__init__.py delete mode 100644 sparkmeter/metering/_generated/core/auth/__init__.py delete mode 100644 sparkmeter/metering/_generated/core/auth/base.py delete mode 100644 sparkmeter/metering/_generated/core/auth/plugins.py delete mode 100644 sparkmeter/metering/_generated/core/cattrs_converter.py delete mode 100644 sparkmeter/metering/_generated/core/config.py delete mode 100644 sparkmeter/metering/_generated/core/exception_aliases.py delete mode 100644 sparkmeter/metering/_generated/core/exceptions.py delete mode 100644 sparkmeter/metering/_generated/core/http_transport.py delete mode 100644 sparkmeter/metering/_generated/core/pagination.py delete mode 100644 sparkmeter/metering/_generated/core/py.typed delete mode 100644 sparkmeter/metering/_generated/core/streaming_helpers.py delete mode 100644 sparkmeter/metering/_generated/core/utils.py delete mode 100644 sparkmeter/metering/_generated/endpoints/__init__.py delete mode 100644 sparkmeter/metering/_generated/endpoints/default.py delete mode 100644 sparkmeter/metering/_generated/endpoints/py.typed delete mode 100644 sparkmeter/metering/_generated/mocks/__init__.py delete mode 100644 sparkmeter/metering/_generated/mocks/endpoints/__init__.py delete mode 100644 sparkmeter/metering/_generated/mocks/endpoints/mock_default.py delete mode 100644 sparkmeter/metering/_generated/mocks/mock_client.py delete mode 100644 sparkmeter/metering/_generated/models/__init__.py delete mode 100644 sparkmeter/metering/_generated/models/associate_meter_command.py delete mode 100644 sparkmeter/metering/_generated/models/associate_meter_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/associate_meter_params.py delete mode 100644 sparkmeter/metering/_generated/models/associate_meter_params_set_aes_key.py delete mode 100644 sparkmeter/metering/_generated/models/associate_meter_params_set_channel.py delete mode 100644 sparkmeter/metering/_generated/models/associate_meter_params_set_mac.py delete mode 100644 sparkmeter/metering/_generated/models/cancel_firmware_update_command.py delete mode 100644 sparkmeter/metering/_generated/models/cancel_firmware_update_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/cancel_firmware_update_params.py delete mode 100644 sparkmeter/metering/_generated/models/capabilities_event.py delete mode 100644 sparkmeter/metering/_generated/models/capabilities_event_vendor_capabilities.py delete mode 100644 sparkmeter/metering/_generated/models/capability_flag.py delete mode 100644 sparkmeter/metering/_generated/models/command_accepted.py delete mode 100644 sparkmeter/metering/_generated/models/command_accepted_event.py delete mode 100644 sparkmeter/metering/_generated/models/command_accepted_event_result.py delete mode 100644 sparkmeter/metering/_generated/models/command_applied_event.py delete mode 100644 sparkmeter/metering/_generated/models/command_applied_event_result.py delete mode 100644 sparkmeter/metering/_generated/models/command_cached_event.py delete mode 100644 sparkmeter/metering/_generated/models/command_failed_event.py delete mode 100644 sparkmeter/metering/_generated/models/command_failed_event_detail.py delete mode 100644 sparkmeter/metering/_generated/models/command_rejected_event.py delete mode 100644 sparkmeter/metering/_generated/models/command_rejected_event_detail.py delete mode 100644 sparkmeter/metering/_generated/models/command_timed_out_event.py delete mode 100644 sparkmeter/metering/_generated/models/configure_high_capacity_meter_command.py delete mode 100644 sparkmeter/metering/_generated/models/configure_high_capacity_meter_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/configure_high_capacity_meter_params.py delete mode 100644 sparkmeter/metering/_generated/models/configure_meter_command.py delete mode 100644 sparkmeter/metering/_generated/models/configure_meter_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/configure_meter_params.py delete mode 100644 sparkmeter/metering/_generated/models/configure_provider_command.py delete mode 100644 sparkmeter/metering/_generated/models/configure_provider_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/configure_provider_params.py delete mode 100644 sparkmeter/metering/_generated/models/enter_configuration_mode_command.py delete mode 100644 sparkmeter/metering/_generated/models/enter_configuration_mode_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/enter_configuration_mode_params.py delete mode 100644 sparkmeter/metering/_generated/models/firmware_update_session_status.py delete mode 100644 sparkmeter/metering/_generated/models/firmware_update_status_event.py delete mode 100644 sparkmeter/metering/_generated/models/firmware_update_status_event_failed_meters.py delete mode 100644 sparkmeter/metering/_generated/models/firmware_update_status_event_progress_percent.py delete mode 100644 sparkmeter/metering/_generated/models/firmware_version.py delete mode 100644 sparkmeter/metering/_generated/models/heartbeat_summary_event.py delete mode 100644 sparkmeter/metering/_generated/models/heartbeat_summary_event_read_reply_latency_ms.py delete mode 100644 sparkmeter/metering/_generated/models/heartbeat_summary_event_set_config_reply_latency_ms.py delete mode 100644 sparkmeter/metering/_generated/models/high_capacity_meter_configuration.py delete mode 100644 sparkmeter/metering/_generated/models/http_validation_error.py delete mode 100644 sparkmeter/metering/_generated/models/http_validation_error_2.py delete mode 100644 sparkmeter/metering/_generated/models/input_.py delete mode 100644 sparkmeter/metering/_generated/models/log_event.py delete mode 100644 sparkmeter/metering/_generated/models/log_level.py delete mode 100644 sparkmeter/metering/_generated/models/meter_behavior_command.py delete mode 100644 sparkmeter/metering/_generated/models/meter_config_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_config_event_balance.py delete mode 100644 sparkmeter/metering/_generated/models/meter_config_event_firmware_version.py delete mode 100644 sparkmeter/metering/_generated/models/meter_configuration.py delete mode 100644 sparkmeter/metering/_generated/models/meter_error_entry.py delete mode 100644 sparkmeter/metering/_generated/models/meter_error_entry_description.py delete mode 100644 sparkmeter/metering/_generated/models/meter_error_entry_location.py delete mode 100644 sparkmeter/metering/_generated/models/meter_error_entry_timestamp_unix_seconds.py delete mode 100644 sparkmeter/metering/_generated/models/meter_errors_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_firmware_changed_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_instant_reading_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_memory_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_neighbor.py delete mode 100644 sparkmeter/metering/_generated/models/meter_neighbor_last_seen_unix_seconds.py delete mode 100644 sparkmeter/metering/_generated/models/meter_neighbor_link_quality.py delete mode 100644 sparkmeter/metering/_generated/models/meter_neighbor_rssi_dbm.py delete mode 100644 sparkmeter/metering/_generated/models/meter_neighbors_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_network_statistics.py delete mode 100644 sparkmeter/metering/_generated/models/meter_network_statistics_read_reply_latency_ms.py delete mode 100644 sparkmeter/metering/_generated/models/meter_network_statistics_set_config_reply_latency_ms.py delete mode 100644 sparkmeter/metering/_generated/models/meter_reading_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_reading_phased_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_reading_phased_event_per_phase.py delete mode 100644 sparkmeter/metering/_generated/models/meter_register_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_state.py delete mode 100644 sparkmeter/metering/_generated/models/meter_version_event.py delete mode 100644 sparkmeter/metering/_generated/models/meter_version_event_bootloader_version.py delete mode 100644 sparkmeter/metering/_generated/models/network_health_event.py delete mode 100644 sparkmeter/metering/_generated/models/phase.py delete mode 100644 sparkmeter/metering/_generated/models/phase_reading.py delete mode 100644 sparkmeter/metering/_generated/models/ping_meter_command.py delete mode 100644 sparkmeter/metering/_generated/models/ping_meter_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/ping_meter_params.py delete mode 100644 sparkmeter/metering/_generated/models/provider_status_event.py delete mode 100644 sparkmeter/metering/_generated/models/py.typed delete mode 100644 sparkmeter/metering/_generated/models/query_capabilities_command.py delete mode 100644 sparkmeter/metering/_generated/models/query_capabilities_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/query_capabilities_params.py delete mode 100644 sparkmeter/metering/_generated/models/query_firmware_update_status_command.py delete mode 100644 sparkmeter/metering/_generated/models/query_firmware_update_status_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/query_firmware_update_status_params.py delete mode 100644 sparkmeter/metering/_generated/models/query_firmware_update_status_params_session_id.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_config_command.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_config_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_config_params.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_errors_command.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_errors_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_errors_params.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_neighbors_command.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_neighbors_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_neighbors_params.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_version_command.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_version_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/query_meter_version_params.py delete mode 100644 sparkmeter/metering/_generated/models/query_network_health_command.py delete mode 100644 sparkmeter/metering/_generated/models/query_network_health_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/query_network_health_params.py delete mode 100644 sparkmeter/metering/_generated/models/query_network_health_params_meter_id.py delete mode 100644 sparkmeter/metering/_generated/models/query_provider_status_command.py delete mode 100644 sparkmeter/metering/_generated/models/query_provider_status_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/query_provider_status_params.py delete mode 100644 sparkmeter/metering/_generated/models/read_meter_memory_command.py delete mode 100644 sparkmeter/metering/_generated/models/read_meter_memory_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/read_meter_memory_params.py delete mode 100644 sparkmeter/metering/_generated/models/read_meter_now_command.py delete mode 100644 sparkmeter/metering/_generated/models/read_meter_now_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/read_meter_now_params.py delete mode 100644 sparkmeter/metering/_generated/models/read_meter_register_command.py delete mode 100644 sparkmeter/metering/_generated/models/read_meter_register_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/read_meter_register_params.py delete mode 100644 sparkmeter/metering/_generated/models/register_meter_command.py delete mode 100644 sparkmeter/metering/_generated/models/register_meter_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/register_meter_params.py delete mode 100644 sparkmeter/metering/_generated/models/register_meter_params_firmware_version.py delete mode 100644 sparkmeter/metering/_generated/models/register_meter_params_initial_balance.py delete mode 100644 sparkmeter/metering/_generated/models/reset_meter_errors_command.py delete mode 100644 sparkmeter/metering/_generated/models/reset_meter_errors_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/reset_meter_errors_params.py delete mode 100644 sparkmeter/metering/_generated/models/rf_test_command.py delete mode 100644 sparkmeter/metering/_generated/models/rf_test_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/rf_test_params.py delete mode 100644 sparkmeter/metering/_generated/models/rf_test_result_event.py delete mode 100644 sparkmeter/metering/_generated/models/rf_test_result_event_rssi_dbm.py delete mode 100644 sparkmeter/metering/_generated/models/set_balance_command.py delete mode 100644 sparkmeter/metering/_generated/models/set_balance_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/set_balance_params.py delete mode 100644 sparkmeter/metering/_generated/models/set_balance_params_balance.py delete mode 100644 sparkmeter/metering/_generated/models/start_firmware_update_command.py delete mode 100644 sparkmeter/metering/_generated/models/start_firmware_update_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/start_firmware_update_params.py delete mode 100644 sparkmeter/metering/_generated/models/start_firmware_update_params_target_meters.py delete mode 100644 sparkmeter/metering/_generated/models/statistics_histogram.py delete mode 100644 sparkmeter/metering/_generated/models/stream_events_v_1_events_get_200_response.py delete mode 100644 sparkmeter/metering/_generated/models/stream_events_v_1_events_get_200_response_event_type_enum.py delete mode 100644 sparkmeter/metering/_generated/models/stream_events_v_1_events_get_param_client_id.py delete mode 100644 sparkmeter/metering/_generated/models/stream_events_v_1_events_get_param_types.py delete mode 100644 sparkmeter/metering/_generated/models/submit_command_v_1_commands_post_request_body.py delete mode 100644 sparkmeter/metering/_generated/models/submit_command_v_1_commands_post_request_body_command_type_enum.py delete mode 100644 sparkmeter/metering/_generated/models/throttle_config.py delete mode 100644 sparkmeter/metering/_generated/models/unregister_meter_command.py delete mode 100644 sparkmeter/metering/_generated/models/unregister_meter_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/unregister_meter_params.py delete mode 100644 sparkmeter/metering/_generated/models/validation_error.py delete mode 100644 sparkmeter/metering/_generated/models/validation_error_ctx.py delete mode 100644 sparkmeter/metering/_generated/models/validation_error_loc.py delete mode 100644 sparkmeter/metering/_generated/models/validation_error_loc_item.py delete mode 100644 sparkmeter/metering/_generated/models/write_meter_register_command.py delete mode 100644 sparkmeter/metering/_generated/models/write_meter_register_command_vendor_options.py delete mode 100644 sparkmeter/metering/_generated/models/write_meter_register_params.py delete mode 100644 sparkmeter/metering/_generated/py.typed create mode 100644 sparkmeter/metering/http_sse.py create mode 100644 sparkmeter/metering/provider_config.py create mode 100644 sparkmeter/metering/runtime_client.py create mode 100644 sparkmeter/metering/tests/test_http_sse.py create mode 100644 sparkmeter/metering/tests/test_provider_config.py create mode 100644 sparkmeter/metering/tests/test_runtime_client.py delete mode 100644 sparkmeter/metering/tools/__init__.py delete mode 100644 sparkmeter/metering/tools/cli_client.py create mode 100644 sparkmeter/tests/test_asgi.py create mode 100644 test-data/config/test_configviews-ConfigTest.test_meter_driver.page diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index bf82af2..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -sparkmeter/metering/_generated/** linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22c76cc..5cd64e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,42 +32,6 @@ jobs: - run: uv run pydocstyle --ignore=D211,D403 sparkmeter continue-on-error: true - metering-wire-drift: - # Verify the committed sparkmeter/metering/_generated/ matches what - # the regen script would produce against the current spec. Catches - # cases where the spec changed but the webapp client wasn't - # regenerated, or where someone hand-edited generated files. - # - # TODO: parameterize the spec source. For now the job expects - # `OPENAPI_URL` to be set (a CI secret / repo variable pointing at - # the canonical openapi.json — release artifact, GitHub Pages, etc.) - # If unset, the drift check is skipped so CI doesn't block on this. - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Skip if spec source not configured - id: have_spec - run: | - if [ -z "${{ vars.OPENAPI_URL }}" ]; then - echo "OPENAPI_URL not set; skipping drift check" - echo "skip=true" >> "$GITHUB_OUTPUT" - fi - - if: steps.have_spec.outputs.skip != 'true' - run: curl -LsSf https://astral.sh/uv/install.sh | sh - - if: steps.have_spec.outputs.skip != 'true' - run: uv python install 3.13 - - if: steps.have_spec.outputs.skip != 'true' - name: Fetch openapi.json - run: curl -fsSL "${{ vars.OPENAPI_URL }}" -o /tmp/openapi.json - - if: steps.have_spec.outputs.skip != 'true' - name: Regenerate metering client - env: - OPENAPI_PATH: /tmp/openapi.json - run: ./scripts/regen-metering-wire.sh - - if: steps.have_spec.outputs.skip != 'true' - name: Verify no drift in committed _generated/ - run: git diff --exit-code sparkmeter/metering/_generated/ - test: runs-on: ubuntu-latest permissions: diff --git a/.gitignore b/.gitignore index acdf25d..3bb5c72 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .noseids .DS_Store +# local meter driver config fixture +meter_driver_configs/*.json + # per deployment settings instance/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 883cc37..c330f89 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,9 +5,9 @@ repos: rev: v6.0.0 hooks: - id: trailing-whitespace - exclude: ^(test-data/|sparkmeter/metering/_generated/) + exclude: ^test-data/ - id: end-of-file-fixer - exclude: ^(test-data/|sparkmeter/metering/_generated/) + exclude: ^test-data/ - id: check-added-large-files - repo: https://github.com/gitleaks/gitleaks @@ -20,9 +20,9 @@ repos: hooks: - id: ruff-check args: ["--fix"] - exclude: ^(sparkmeter/metering/_generated/|test-data/) + exclude: ^test-data/ - id: ruff-format - exclude: ^(sparkmeter/metering/_generated/|test-data/) + exclude: ^test-data/ - repo: local hooks: diff --git a/pyproject.toml b/pyproject.toml index 41c0b6a..23cee37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,8 @@ dependencies = [ "requests", "httpx", - # Used by the generated metering-provider client at - # sparkmeter/metering/_generated/ (pyopenapi-gen output). + # Structures raw metering-provider SSE event dicts into the local + # dataclasses in sparkmeter/metering/legacy_event_models.py. "cattrs", # AWS S3 — used by the historical-data feature in sparkmeter/history/. @@ -54,7 +54,12 @@ dependencies = [ "fastapi", "hypercorn", "a2wsgi", + # gRPC contract for the optional meter-driver profile. The generated + # stubs come from the meter-driver-spec package (pinned to a spec tag), + # not from local codegen; protobuf/grpcio are their runtime. + "meter-driver-spec @ https://github.com/EarthSpark/meter-driver-spec/releases/download/v1.4.0/meter_driver_spec-1.4.0-py3-none-any.whl", "protobuf", + "grpcio", "click", # dates and times @@ -73,6 +78,9 @@ dependencies = [ "speaklater", ] +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.version] source = "vcs" # uv's build isolation invokes hatch-vcs from a temp directory that @@ -104,7 +112,6 @@ dev = [ "ruff>=0.15.21", "pydocstyle", "pre-commit", - "grpcio-tools", "pyopenapi-gen", "pytest-diagnose @ git+https://github.com/tescalada/pytest-diagnose.git", "pytest-randomly>=4.1.0", @@ -112,7 +119,7 @@ dev = [ [tool.ruff] line-length = 110 -extend-exclude = ["sparkmeter/metering/_generated", "test-data"] +extend-exclude = ["test-data"] [tool.ruff.lint] select = ["F", "I"] diff --git a/scripts/regen-metering-wire.sh b/scripts/regen-metering-wire.sh deleted file mode 100755 index 9d56046..0000000 --- a/scripts/regen-metering-wire.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# -# Regenerate the metering-provider client at -# sparkmeter/metering/_generated/ from the metering provider's OpenAPI -# spec. -# -# Set OPENAPI_PATH (or OPENAPI_URL) to point at the spec source. -# -# Run from the webapp repo root: -# -# OPENAPI_PATH=/path/to/openapi.json ./scripts/regen-metering-wire.sh -# -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -if [ -z "${OPENAPI_PATH:-}" ]; then - echo "OPENAPI_PATH must be set to the path of the metering-provider openapi.json" >&2 - exit 1 -fi - -if [ ! -f "${OPENAPI_PATH}" ]; then - echo "openapi.json not found at ${OPENAPI_PATH}" >&2 - exit 1 -fi - -uv run --project "${REPO_ROOT}" pyopenapi-gen \ - --project-root "${REPO_ROOT}" \ - --output-package sparkmeter.metering._generated \ - --force \ - --no-postprocess \ - "${OPENAPI_PATH}" - -echo "regenerated sparkmeter/metering/_generated/" diff --git a/setup.cfg b/setup.cfg index 490c5ba..1d36304 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,7 +14,6 @@ norecursedirs = .eggs .git build venv venv-3.6 scripts/config/node_modules dist deploy/build node_docker venv_docker - sparkmeter/metering/_generated python_classes = *Test Test* [coverage:xml] @@ -25,10 +24,7 @@ relative_files = True omit = **/test_*.py sparkmeter/tests/base.py - sparkmeter/metering/_generated/** [flake8] select = E,W,H max-line-length = 110 -exclude = - sparkmeter/metering/_generated diff --git a/sparkmeter/asgi.py b/sparkmeter/asgi.py index cf9529f..8827449 100644 --- a/sparkmeter/asgi.py +++ b/sparkmeter/asgi.py @@ -34,6 +34,12 @@ from sparkmeter.metering.lifespan import metering_lifespan # noqa: E402 from sparkmeter.periodic import periodic_lifespan # noqa: E402 +# When this file is launched via `python -m sparkmeter.asgi`, Python executes it +# as `__main__`. Register the canonical module name as an alias to the running +# module so in-process helpers don't accidentally import a second copy. +if __name__ == "__main__": + sys.modules.setdefault("sparkmeter.asgi", sys.modules[__name__]) + @asynccontextmanager async def app_lifespan(app: FastAPI): @@ -42,6 +48,7 @@ async def app_lifespan(app: FastAPI): metering_lifespan is wrapped on the inside so its teardown (drain SSE / dispatch) runs before periodic jobs are cancelled. """ + app.state.main_loop = asyncio.get_running_loop() async with periodic_lifespan(app): async with metering_lifespan(app): yield @@ -81,6 +88,7 @@ async def health(): # which is bound to a per-request thread-local that lifespan threads # never enter. api.state.flask_app = flask_app + globals()["public_app"] = api # Mount Flask under "/" — FastAPI's own routes take precedence; everything # else falls through to the WSGI app. api.mount("/", WSGIMiddleware(flask_app)) @@ -101,6 +109,7 @@ async def share_metering_state(request, call_next): request.app.state.metering = getattr(public_app.state, "metering", None) return await call_next(request) + globals()["internal_app"] = api return api @@ -132,6 +141,8 @@ async def _serve_both() -> None: public = create_public_app() internal = create_internal_app(public) + globals()["public_app"] = public + globals()["internal_app"] = internal public_cfg = Config() public_cfg.bind = [os.environ.get("PUBLIC_BIND", "0.0.0.0:5000")] diff --git a/sparkmeter/config/provider_settings.py b/sparkmeter/config/provider_settings.py index 0e80203..5e40262 100644 --- a/sparkmeter/config/provider_settings.py +++ b/sparkmeter/config/provider_settings.py @@ -144,11 +144,24 @@ def _fetch_requirements_payload(service_url, timeout=10.0): def _iter_candidate_requirement_schemas(spec): """Yield object schemas that may describe driver requirement fields.""" - components = (spec.get("components") or {}).get("schemas") or {} - for schema in components.values(): + + def _walk(schema, depth=0): + if depth > 6: + return resolved = _resolve_schema(spec, schema) - if isinstance(resolved, dict) and (resolved.get("properties") or {}): + if not isinstance(resolved, dict): + return + properties = resolved.get("properties") or {} + if properties: yield resolved + # Requirement fields are frequently nested (e.g. under + # vendor_options), so descend into object-typed properties too. + for prop_schema in properties.values(): + yield from _walk(prop_schema, depth + 1) + + components = (spec.get("components") or {}).get("schemas") or {} + for schema in components.values(): + yield from _walk(schema) for path_item in (spec.get("paths") or {}).values(): if not isinstance(path_item, dict): @@ -161,9 +174,7 @@ def _iter_candidate_requirement_schemas(spec): .get("application/json", {}) .get("schema", {}) ) - resolved = _resolve_schema(spec, schema) - if isinstance(resolved, dict) and (resolved.get("properties") or {}): - yield resolved + yield from _walk(schema) def _best_matching_requirements_schema(spec, required_fields): @@ -180,6 +191,15 @@ def _best_matching_requirements_schema(spec, required_fields): return best_schema if best_score else {} +# Type hints for the spec's standard driver init fields, used when the +# driver's OpenAPI does not describe a required field's schema itself. +_STANDARD_INIT_FIELD_TYPES = { + "aes_key": "string", + "channel": "integer", + "heartbeat_period_duration": "integer", +} + + def _extract_fields_from_requirements(spec, required_fields): """Build normalized field specs from a requirements list plus OpenAPI schema hints.""" schema = _best_matching_requirements_schema(spec, required_fields) @@ -187,7 +207,9 @@ def _extract_fields_from_requirements(spec, required_fields): schema_required = set(schema.get("required") or []) fields = [] for name in required_fields: - resolved = _resolve_schema(spec, properties.get(name) or {}) + resolved = dict(_resolve_schema(spec, properties.get(name) or {})) + if not resolved.get("type") and name in _STANDARD_INIT_FIELD_TYPES: + resolved["type"] = _STANDARD_INIT_FIELD_TYPES[name] fields.append( _field_spec( name, @@ -200,6 +222,11 @@ def _extract_fields_from_requirements(spec, required_fields): def _extract_driver_requirement_fields(base_url, spec, timeout=10.0): """Discover required driver fields from /v1/requirements, else OpenAPI.""" + vendor_option_fields = _extract_vendor_option_fields(spec) + if not vendor_option_fields: + # The driver declares no configurable requirements, so there is + # nothing to enrich and no reason to probe /v1/requirements. + return [] try: payload = _fetch_requirements_payload(base_url, timeout=timeout) required_fields = payload.get("required_fields") or [] @@ -210,7 +237,7 @@ def _extract_driver_requirement_fields(base_url, spec, timeout=10.0): except (httpx.HTTPError, ValueError, ProviderRegistrationError): pass - return _extract_vendor_option_fields(spec) + return vendor_option_fields def _get_parameter(name): diff --git a/sparkmeter/event/eventspecs.py b/sparkmeter/event/eventspecs.py index 78d652b..aefe323 100644 --- a/sparkmeter/event/eventspecs.py +++ b/sparkmeter/event/eventspecs.py @@ -442,6 +442,13 @@ class MeterCreatedEvent(MeterEventSpec): def process(self, event): MeterEventSpec.process(self, event) meter = event.object + from sparkmeter.metering.api import register_meter + + register_meter( + node_id=int(meter.code), + node_type=str(meter.product_code), + mac=int(meter.code), + ) meter.send_set_config_unconditionally() diff --git a/sparkmeter/meter/metercommand.py b/sparkmeter/meter/metercommand.py index 664f8f9..2dad1d8 100644 --- a/sparkmeter/meter/metercommand.py +++ b/sparkmeter/meter/metercommand.py @@ -204,37 +204,3 @@ def get_heartbeat_reading(mac): click.echo(f"no readings recorded for meter {mac}", err=True) raise SystemExit(1) pprint.pprint({c.name: getattr(reading, c.name) for c in reading.__table__.columns}) - - -@meter.command("ping") -@click.option("-m", "--mac", default=None, help="MAC of meter to ping") -@with_appcontext -def ping(mac=None): - """Send a per-meter ping via the metering provider. - - If `--mac` is omitted, pings every customer meter. - """ - import asyncio - - from sparkmeter.meter.meterdomain import Meter - from sparkmeter.metering.tools.cli_client import run_per_meter_command, submit_ping - - if mac is not None: - meter_ids = [str(mac)] - else: - meter_ids = [str(m.code) for m in Meter.query.all()] - - asyncio.run(run_per_meter_command(submit_ping, meter_ids)) - - -@meter.command("get-neighborlists") -@with_appcontext -def get_neighborlists(): - """Query each meter for its view of its radio neighbors.""" - import asyncio - - from sparkmeter.meter.meterdomain import Meter - from sparkmeter.metering.tools.cli_client import run_per_meter_command, submit_query_neighbors - - meter_ids = [str(m.code) for m in Meter.query.all()] - asyncio.run(run_per_meter_command(submit_query_neighbors, meter_ids)) diff --git a/sparkmeter/meter/meterform.py b/sparkmeter/meter/meterform.py index af2d61d..0de31a7 100644 --- a/sparkmeter/meter/meterform.py +++ b/sparkmeter/meter/meterform.py @@ -118,6 +118,10 @@ class BaseMeterForm(BaseForm): _("Meter driver"), choices=[], default="", + # A missing or empty selection means "no driver"; eligibility is + # enforced by validate_provider_id, not WTForms' choice check (which + # would reject a form submitted without this field at all). + validate_choice=False, ) customer_name = StringField(_("Name"), default="new customer") customer_code = StringField(_("Code")) @@ -230,11 +234,14 @@ def validate_tariff(self, field): def validate_provider_id(self, field): """Ensure the selected meter driver is one of the currently eligible choices.""" - available_values = [value for value, _label in self.provider_id.choices if value] - if available_values and not field.data: + # A missing field (a form/client that doesn't submit it) or an empty + # selection both mean "no driver". + selected = field.data or "" + available = [value for value, _label in self.provider_id.choices if value] + if available and not selected: raise ValidationError(_("Please select a meter driver.")) - valid_values = {value for value, _label in self.provider_id.choices} - if field.data not in valid_values: + valid = {value for value, _label in self.provider_id.choices} + if selected not in valid: raise ValidationError(_("Please select a valid meter driver.")) def save(self, view): diff --git a/sparkmeter/meter/meterstate.py b/sparkmeter/meter/meterstate.py index af388d5..f882ca7 100644 --- a/sparkmeter/meter/meterstate.py +++ b/sparkmeter/meter/meterstate.py @@ -35,6 +35,9 @@ class MeterState(object): # state name to state id dict, created on first request _state_name_to_id = None + # state id to state name dict, created on first request + _state_id_to_name = None + # state id to translation dict, created on first request _state_id_to_translation = None @@ -51,6 +54,19 @@ def get_state_id_from_name(cls, name): cls._state_name_to_id = {s.name: s.id for s in cls._all_states()} return cls._state_name_to_id.get(name, cls.STATE_UNKNOWN.id) + @classmethod + def get_state_name_from_id(cls, state_id): + """Get the state name from the specified id. + + Inverse of get_state_id_from_name; if the id is not recognized the + name of the "unknown" state is returned. + :param state_id: State id (int) as specified in Reading.state + :returns: str name of the state + """ + if cls._state_id_to_name is None: + cls._state_id_to_name = {s.id: s.name for s in cls._all_states()} + return cls._state_id_to_name.get(state_id, cls.STATE_UNKNOWN.name) + @classmethod def get_state_translation_from_id(cls, state_id): """Get the translated name from the specified id. diff --git a/sparkmeter/meter/tests/test_metercommand.py b/sparkmeter/meter/tests/test_metercommand.py index 8b4caca..48c9457 100644 --- a/sparkmeter/meter/tests/test_metercommand.py +++ b/sparkmeter/meter/tests/test_metercommand.py @@ -307,82 +307,6 @@ def test_get_heartbeat_reading_prints_latest(self, cli): result = cli("meter", "get-heartbeat", "-m", str(m.code)) assert "'meter': '%d'" % m.code in result.output - def test_ping(self, cli, mocker, capfd): - """`meter ping -m ` submits a `ping_meter` to the metering provider.""" - submitted = [] - - async def fake_submit_ping(client, meter_id, correlation_id): - submitted.append((meter_id, correlation_id)) - - async def fake_run_per_meter_command(submitter, meter_ids): - for mid in meter_ids: - await submitter(mocker.MagicMock(), mid, "corr-" + mid) - - mocker.patch("sparkmeter.metering.tools.cli_client.submit_ping", fake_submit_ping) - mocker.patch( - "sparkmeter.metering.tools.cli_client.run_per_meter_command", - fake_run_per_meter_command, - ) - - m = MeterFactory() - self.session.commit() - result = cli("meter", "ping", "-m", str(m.code)) - assert result.exit_code == 0 - assert submitted == [(str(m.code), f"corr-{m.code}")] - - def test_ping_all(self, cli, mocker): - """`meter ping` (no mac) submits a `ping_meter` for every meter.""" - submitted = [] - - async def fake_submit_ping(client, meter_id, correlation_id): - submitted.append(meter_id) - - async def fake_run_per_meter_command(submitter, meter_ids): - for mid in meter_ids: - await submitter(mocker.MagicMock(), mid, "corr-" + mid) - - mocker.patch("sparkmeter.metering.tools.cli_client.submit_ping", fake_submit_ping) - mocker.patch( - "sparkmeter.metering.tools.cli_client.run_per_meter_command", - fake_run_per_meter_command, - ) - - MeterFactory() - MeterFactory() - self.session.commit() - result = cli("meter", "ping") - assert result.exit_code == 0 - assert len(submitted) == 2 - - -class NeighborListCommandTest(SparkMeterTestCaseBase): - def test_get_neighborlists(self, cli, mocker, capfd): - """`meter get-neighborlists` submits `query_meter_neighbors` for every meter.""" - submitted = [] - - async def fake_submit_neighbors(client, meter_id, correlation_id): - submitted.append(meter_id) - - async def fake_run_per_meter_command(submitter, meter_ids): - for mid in meter_ids: - await submitter(mocker.MagicMock(), mid, "corr-" + mid) - - mocker.patch( - "sparkmeter.metering.tools.cli_client.submit_query_neighbors", - fake_submit_neighbors, - ) - mocker.patch( - "sparkmeter.metering.tools.cli_client.run_per_meter_command", - fake_run_per_meter_command, - ) - - MeterFactory(code=1) - MeterFactory(code=2) - self.session.commit() - result = cli("meter", "get-neighborlists") - assert result.exit_code == 0 - assert sorted(submitted) == ["1", "2"] - class WithForeverTest: def test_runs_once_without_forever(self): diff --git a/sparkmeter/metering/__init__.py b/sparkmeter/metering/__init__.py index e993f61..a29df76 100644 --- a/sparkmeter/metering/__init__.py +++ b/sparkmeter/metering/__init__.py @@ -1,14 +1,14 @@ """ Metering-provider integration package. -The webapp talks to a vendor-agnostic metering provider over HTTP+SSE. -The generated client (in `_generated/`) is regenerated from the -provider's `openapi.json` via `scripts/regen-metering-wire.sh`. +The webapp talks to a vendor-agnostic metering provider over HTTP+SSE +(with an optional gRPC profile). The gRPC stubs and HTTP models come +from the `meter-driver-spec` package. Custom modules layered on top: - `lifespan` FastAPI lifespan; owns the client + SSE consumer + dispatch - `dispatch` sync→async bridge for Flask request handlers -- `events` SSE event handlers (DB writes, log forwarding, watchdog) +- `events` SSE event handlers (DB writes, watchdog) - `reconcile` startup reconcile registering every meter from DB - `api` sync Flask surface (kept stable across this migration) """ diff --git a/sparkmeter/metering/_generated/__init__.py b/sparkmeter/metering/_generated/__init__.py deleted file mode 100644 index 3ec0dd8..0000000 --- a/sparkmeter/metering/_generated/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Client package __init__.py -# Re-exports from core and local client. - -from sparkmeter.metering._generated.core.auth import BaseAuth, ApiKeyAuth, BearerAuth, OAuth2Auth -from sparkmeter.metering._generated.core.config import ClientConfig -from sparkmeter.metering._generated.core.exceptions import HTTPError, ClientError, ServerError -from sparkmeter.metering._generated.core.exception_aliases import * # noqa: F401, F403 -from sparkmeter.metering._generated.core.http_transport import HttpTransport, HttpxTransport -from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict, converter -from .client import APIClient - -__all__ = [ - "APIClient", - "BaseAuth", "ApiKeyAuth", "BearerAuth", "OAuth2Auth", - "ClientConfig", - "HTTPError", "ClientError", "ServerError", - "HttpTransport", "HttpxTransport", - "structure_from_dict", "unstructure_to_dict", "converter", -] diff --git a/sparkmeter/metering/_generated/client.py b/sparkmeter/metering/_generated/client.py deleted file mode 100644 index 7f14224..0000000 --- a/sparkmeter/metering/_generated/client.py +++ /dev/null @@ -1,83 +0,0 @@ -from typing import Any, Dict, Protocol, runtime_checkable - -from sparkmeter.metering._generated.core.auth.plugins import ApiKeyAuth -from sparkmeter.metering._generated.core.config import ClientConfig -from sparkmeter.metering._generated.core.http_transport import HttpTransport, HttpxTransport - -from .endpoints.default import DefaultClient, DefaultClientProtocol - -@runtime_checkable -class APIClientProtocol(Protocol): - """Protocol defining the interface of APIClient for dependency injection.""" - - @property - def default(self) -> 'DefaultClientProtocol': - ... - - async def request(self, method: str, url: str, **kwargs: Any) -> Any: - ... - - async def close(self) -> None: - ... - - async def __aenter__(self) -> 'APIClientProtocol': - ... - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None) -> None: - ... - - -class APIClient(APIClientProtocol): - """ -metering-provider (version 0.1.0) - -Vendor-agnostic metering provider HTTP+SSE API. - - -Async API client with pluggable transport, tag-specific clients, and client-level -headers. - -Args: - config (ClientConfig) : Client configuration object. - transport (HttpTransport | None) - : Custom HTTP transport (optional). - default (DefaultClient) : Client for 'default' endpoints. - - """ - def __init__(self, config: ClientConfig, transport: HttpTransport | None = None) -> None: - self.config = config - self.transport = transport if transport is not None else HttpxTransport(str(config.base_url), config.timeout) - self._base_url: str = str(self.config.base_url) - self._default: DefaultClient | None = None - - @property - def default(self) -> DefaultClient: - """Client for 'default' endpoints.""" - if self._default is None: - self._default = DefaultClient(self.transport, self._base_url) - return self._default - - async def request(self, method: str, url: str, **kwargs: Any) -> Any: - """Send an HTTP request via the transport.""" - return await self.transport.request(method, url, **kwargs) - - async def close(self) -> None: - """Close the underlying transport if supported.""" - if hasattr(self.transport, 'close'): - await self.transport.close() - else: - pass # Or log a warning if close is expected but not found - - async def __aenter__(self) -> 'APIClient': - """Enter the async context manager. Returns self.""" - if hasattr(self.transport, '__aenter__'): - await self.transport.__aenter__() - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None) -> None: - """Exit the async context manager, ensuring transport is closed.""" - if hasattr(self.transport, '__aexit__'): - await self.transport.__aexit__(exc_type, exc_val, exc_tb) - else: - await self.close() - \ No newline at end of file diff --git a/sparkmeter/metering/_generated/core/README.md b/sparkmeter/metering/_generated/core/README.md deleted file mode 100644 index b2a8e4c..0000000 --- a/sparkmeter/metering/_generated/core/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Core Runtime Components - -This directory contains the core runtime components required by the generated API client. - -## Client Independence - -**The generated API client is fully independent and does NOT require the `pyopenapi_gen` package to be installed at runtime.** - -All necessary base classes, protocols, transport implementations, and utility functions are included directly within this `core` package. You can safely use the generated client in any Python environment without installing the generator itself. - -## Shared Core (Optional) - -In scenarios where multiple API clients are generated (perhaps for different services within the same ecosystem), you might want to share a single instance of this `core` package to avoid duplication. - -To achieve this: -1. Generate the first client normally. This will create the `core` package. -2. Move or copy this `core` package to a location accessible by all your client packages (e.g., a shared `libs` directory). -3. Ensure this shared location is included in your Python environment's `PYTHONPATH`. -4. When generating subsequent clients, use the `--core-import-path ` option with `pyopenapi-gen`. This tells the generator *not* to create a new `core` directory but instead to generate imports relative to the specified shared path (e.g., `from shared_libs.core.http_transport import HttpTransport`). - -This allows multiple clients to reuse the same base implementation, reducing code size and ensuring consistency. \ No newline at end of file diff --git a/sparkmeter/metering/_generated/core/__init__.py b/sparkmeter/metering/_generated/core/__init__.py deleted file mode 100644 index 4f460e4..0000000 --- a/sparkmeter/metering/_generated/core/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -# Re-export core exceptions and generated aliases -from .exceptions import HTTPError, ClientError, ServerError -from .exception_aliases import * # noqa: F403 - -# Re-export other commonly used core components -from .http_transport import HttpTransport, HttpxTransport -from .config import ClientConfig -from .cattrs_converter import structure_from_dict, unstructure_to_dict, converter -from .utils import DataclassSerializer -from .auth.base import BaseAuth -from .auth.plugins import ApiKeyAuth, BearerAuth, OAuth2Auth - -__all__ = [ - # Base exceptions - "HTTPError", - "ClientError", - "ServerError", - # All ErrorXXX from exception_aliases are implicitly in __all__ due to star import - - # Transport layer - "HttpTransport", - "HttpxTransport", - - # Configuration - "ClientConfig", - - # Serialization (cattrs) - "structure_from_dict", - "unstructure_to_dict", - "converter", - - # Utilities - "DataclassSerializer", - - # Authentication - "BaseAuth", - "ApiKeyAuth", - "BearerAuth", - "OAuth2Auth", - # Generated exception aliases - "UnprocessableEntityError", -] \ No newline at end of file diff --git a/sparkmeter/metering/_generated/core/auth/__init__.py b/sparkmeter/metering/_generated/core/auth/__init__.py deleted file mode 100644 index c9b0171..0000000 --- a/sparkmeter/metering/_generated/core/auth/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# Core Auth __init__ -from .base import BaseAuth -from .plugins import ApiKeyAuth, BearerAuth, OAuth2Auth - -__all__ = [ - "BaseAuth", - "ApiKeyAuth", - "BearerAuth", - "OAuth2Auth", -] diff --git a/sparkmeter/metering/_generated/core/auth/base.py b/sparkmeter/metering/_generated/core/auth/base.py deleted file mode 100644 index fd8b059..0000000 --- a/sparkmeter/metering/_generated/core/auth/base.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Any, Protocol # noqa: F401 - - -class BaseAuth(Protocol): - """Protocol for authentication plugins.""" - - async def authenticate_request(self, request_args: dict[str, Any]) -> dict[str, Any]: - """Modify or augment the request arguments for authentication.""" - # Default stub returns the input unchanged - return request_args - - -class CompositeAuth(BaseAuth): - """Compose multiple BaseAuth plugins, applying each in sequence to the request.""" - - def __init__(self, *plugins: BaseAuth): - self.plugins = plugins - - async def authenticate_request(self, request_args: dict[str, Any]) -> dict[str, Any]: - for plugin in self.plugins: - request_args = await plugin.authenticate_request(request_args) - return request_args diff --git a/sparkmeter/metering/_generated/core/auth/plugins.py b/sparkmeter/metering/_generated/core/auth/plugins.py deleted file mode 100644 index f886e2b..0000000 --- a/sparkmeter/metering/_generated/core/auth/plugins.py +++ /dev/null @@ -1,89 +0,0 @@ -from typing import Any, Awaitable, Callable - -from .base import BaseAuth - - -class BearerAuth(BaseAuth): - """Authentication plugin for Bearer tokens.""" - - def __init__(self, token: str) -> None: - self.token = token - - async def authenticate_request(self, request_args: dict[str, Any]) -> dict[str, Any]: - # Ensure headers dict exists - headers = dict(request_args.get("headers", {})) - headers["Authorization"] = f"Bearer {self.token}" - request_args["headers"] = headers - return request_args - - -class HeadersAuth(BaseAuth): - """Authentication plugin for arbitrary headers.""" - - def __init__(self, headers: dict[str, str]) -> None: - self.headers = headers - - async def authenticate_request(self, request_args: dict[str, Any]) -> dict[str, Any]: - # Merge custom headers - hdrs = dict(request_args.get("headers", {})) - hdrs.update(self.headers) - request_args["headers"] = hdrs - return request_args - - -class ApiKeyAuth(BaseAuth): - """Authentication plugin for API keys (header, query, or cookie).""" - - def __init__(self, key: str, location: str = "header", name: str = "X-API-Key") -> None: - """ - Args: - key: The API key value. - location: Where to add the key ("header", "query", or "cookie"). - name: The name of the header/query/cookie parameter. - """ - self.key = key - self.location = location - self.name = name - - async def authenticate_request(self, request_args: dict[str, Any]) -> dict[str, Any]: - if self.location == "header": - headers = dict(request_args.get("headers", {})) - headers[self.name] = self.key - request_args["headers"] = headers - elif self.location == "query": - params = dict(request_args.get("params", {})) - params[self.name] = self.key - request_args["params"] = params - elif self.location == "cookie": - cookies = dict(request_args.get("cookies", {})) - cookies[self.name] = self.key - request_args["cookies"] = cookies - else: - raise ValueError(f"Invalid API key location: {self.location}") - return request_args - - -class OAuth2Auth(BaseAuth): - """Authentication plugin for OAuth2 Bearer tokens, with optional auto-refresh.""" - - def __init__(self, access_token: str, refresh_callback: Callable[[str], Awaitable[str]] | None = None) -> None: - """ - Args: - access_token: The OAuth2 access token. - refresh_callback: Optional async function to refresh the token. If provided, will be called - if token is expired. - """ - self.access_token = access_token - self.refresh_callback = refresh_callback - - async def authenticate_request(self, request_args: dict[str, Any]) -> dict[str, Any]: - # In a real implementation, check expiry and refresh if needed - if self.refresh_callback is not None: - # Optionally refresh token (user must implement expiry logic) - new_token = await self.refresh_callback(self.access_token) - if new_token and new_token != self.access_token: - self.access_token = new_token - headers = dict(request_args.get("headers", {})) - headers["Authorization"] = f"Bearer {self.access_token}" - request_args["headers"] = headers - return request_args diff --git a/sparkmeter/metering/_generated/core/cattrs_converter.py b/sparkmeter/metering/_generated/core/cattrs_converter.py deleted file mode 100644 index 5ad1ea1..0000000 --- a/sparkmeter/metering/_generated/core/cattrs_converter.py +++ /dev/null @@ -1,950 +0,0 @@ -""" -cattrs converter utilities for generated clients. - -This module provides cattrs converter functions for JSON serialization/deserialization -in generated API clients. It handles: -- Automatic camelCase ↔ snake_case transformation -- Python keyword conflicts (id → id_) -- base64 bytes encoding/decoding -- Nested object structures - -The converter is configured globally to handle name transformations automatically -for all dataclasses, with no per-class metadata required. -""" - -from __future__ import annotations - -import base64 -import dataclasses -import re -import types -from datetime import date, datetime -from typing import Annotated, Any, Callable, TypeVar, Union, get_args, get_origin, get_type_hints - -import cattrs -from cattrs.errors import BaseValidationError, ClassValidationError, IterableValidationError -from cattrs.gen import make_dict_unstructure_fn, override - -T = TypeVar("T") - -# Module-level caches: each class is processed at most once per process. -# get_type_hints() is expensive for modules using `from __future__ import annotations` -# (PEP 563) — string annotations are re-evaluated on every call. Without caching, -# every structure_from_dict call triggers a full type-graph traversal with a fresh -# get_type_hints() call per class, producing O(N × calls) evaluations. -_type_hints_cache: dict[type, dict[str, Any]] = {} -_structure_hooks_registered: set[type] = set() -_unstructure_hooks_registered: set[type] = set() -# Lazily populated on first unstructure call per class; see _register_unstructure_hooks_recursively. -_unstructure_fn_cache: dict[type, Any] = {} - - -def _get_type_hints_with_extras(cls: type) -> dict[str, Any]: - """Return get_type_hints(cls, include_extras=True), cached globally. - - include_extras=True preserves Annotated[...] discriminator metadata. - The result is cached so string annotations in PEP 563 modules are - evaluated only once rather than on every structure_from_dict call. - """ - if cls not in _type_hints_cache: - try: - _type_hints_cache[cls] = get_type_hints(cls, include_extras=True) - except Exception: - _type_hints_cache[cls] = {} - return _type_hints_cache[cls] - - -# Python keywords that get '_' suffix in generated code -PYTHON_KEYWORDS = { - "id", - "type", - "class", - "def", - "return", - "if", - "elif", - "else", - "for", - "while", - "import", - "from", - "as", - "pass", - "break", - "continue", -} - - -def camel_to_snake(name: str) -> str: - """ - Convert camelCase to snake_case. - - Scenario: - Convert JSON field names (camelCase) to Python field names (snake_case). - - Expected Outcome: - Proper snake_case transformation with special handling for Python keywords. - - Examples: - "pageSize" → "page_size" - "totalPages" → "total_pages" - "hasNext" → "has_next" - "id" → "id_" (Python keyword) - "_count" → "count" (leading underscore preserved as-is in JSON, but mapped) - """ - # Insert underscore before uppercase letters - snake = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name) - snake = snake.lower() - - # Add trailing underscore for Python keywords - if snake in PYTHON_KEYWORDS: - snake = f"{snake}_" - - return snake - - -def snake_to_camel(name: str) -> str: - """ - Convert snake_case to camelCase. - - Scenario: - Convert Python field names (snake_case) back to JSON field names (camelCase). - - Expected Outcome: - Proper camelCase transformation with special handling for Python keyword suffixes. - - Examples: - "page_size" → "pageSize" - "total_pages" → "totalPages" - "has_next" → "hasNext" - "id_" → "id" (remove trailing underscore from Python keyword) - "count" → "_count" (if original JSON had leading underscore) - """ - # Remove trailing underscore if it was added for Python keyword - if name.endswith("_") and name[:-1] in PYTHON_KEYWORDS: - name = name[:-1] - - # Convert snake_case to camelCase - components = name.split("_") - return components[0] + "".join(x.title() for x in components[1:]) - - -# Global converter instance with automatic name transformation -converter = cattrs.Converter() - - -def _make_dataclass_structure_fn(cls: type[T]) -> Any: - """ - Create a structure function for a dataclass with automatic name transformation. - - Scenario: - Generate a structure function that automatically converts JSON keys - (camelCase) to Python dataclass field names (snake_case), while - preserving Annotated[...] metadata on nested field types so that - discriminated union hooks remain active. - - Expected Outcome: - A function that structures JSON into the dataclass with correct field - name transformation, and that passes the fully-preserved Annotated type - (including discriminator metadata) to converter.structure for every field. - - Why not make_dict_structure_fn: - cattrs' make_dict_structure_fn internally calls adapted_fields() which - uses get_type_hints() without include_extras=True. Generated model files - use `from __future__ import annotations`, making all annotations lazy - strings. When adapted_fields resolves those strings it strips Annotated - extras, losing discriminator metadata before structuring begins. - This custom implementation owns the full pipeline and preserves extras. - """ - # Resolve field types preserving Annotated extras (discriminator metadata). - # include_extras=True is required: without it, Annotated[Union[...], Disc()] - # is reduced to Union[...], dropping the discriminator. - # Result is globally cached; see _get_type_hints_with_extras. - type_hints: dict[str, Any] = _get_type_hints_with_extras(cls) - - # Build python_name → json_key lookup from Meta.key_transform_with_load. - # Meta.key_transform_with_load format: {"json_key": "python_field_name"} - python_to_json: dict[str, str] = {} - if hasattr(cls, "Meta") and hasattr(cls.Meta, "key_transform_with_load"): # type: ignore[attr-defined] - for json_key, python_name in cls.Meta.key_transform_with_load.items(): # type: ignore[attr-defined] - python_to_json[python_name] = json_key - - # Pre-compute per-field data at registration time to eliminate dict lookups - # inside structure_fn on every call. Tuple layout: - # (python_name, json_key, field_type, default, default_factory) - field_specs: list[tuple[str, str, Any, Any, Any]] = [ - ( - f.name, - python_to_json.get(f.name, f.name), - type_hints.get(f.name, f.type), - f.default, - f.default_factory, - ) - for f in dataclasses.fields(cls) # type: ignore[arg-type] - ] - - cls_name = cls.__name__ # avoid attribute lookup on every error path - - def structure_fn(data: dict[str, Any] | None, _type: type) -> Any: - if data is None: - raise TypeError( - f"Cannot structure None into {cls_name}: " - f"Received null value for non-optional field. " - f"This is likely a schema mismatch - either the API is returning null " - f"for a required field, or the OpenAPI schema is missing 'nullable: true'. " - f"To fix: make the field optional in the OpenAPI spec by adding 'nullable: true' " - f"or removing it from the 'required' array." - ) - - if not isinstance(data, dict): - raise TypeError( - f"Cannot structure {type(data).__name__} into {cls_name}: " - f"Expected a dict, got {type(data).__name__}" - ) - - kwargs: dict[str, Any] = {} - field_errors: list[Exception] = [] - - for python_name, json_key, field_type, default, default_factory in field_specs: - if json_key in data: - try: - kwargs[python_name] = converter.structure(data[json_key], field_type) - except Exception as exc: - # Attach cattrs-compatible note so _extract_errors can - # reconstruct the field path (e.g. "items[].id"). - note = f"Structuring class {cls_name} @ attribute {python_name}" - if not hasattr(exc, "__notes__") or exc.__notes__ is None: - exc.__notes__ = [] - exc.__notes__.append(note) - field_errors.append(exc) - elif default is not dataclasses.MISSING: - kwargs[python_name] = default - elif default_factory is not dataclasses.MISSING: - kwargs[python_name] = default_factory() - # Required field absent from data: omit from kwargs so that the - # dataclass constructor raises a descriptive TypeError. - - if field_errors: - raise ClassValidationError(f"While structuring {cls_name}", field_errors, cls) - - return cls(**kwargs) - - return structure_fn - - -def _make_dataclass_unstructure_fn(cls: type[T]) -> Any: - """ - Create an unstructure function for a dataclass with field name transformation. - - Scenario: - Generate an unstructure function that converts Python dataclass field names - to JSON keys using the Meta class mappings if available. - - Expected Outcome: - A function that cattrs can use to unstructure the dataclass into JSON, - with field name transformation based on Meta.key_transform_with_dump. - For user-defined dataclasses without Meta, Python field names are used as-is. - """ - # Get field renaming map (Python field name → JSON key) - field_overrides: dict[str, Any] = {} - if dataclasses.is_dataclass(cls): - for field in dataclasses.fields(cls): - python_name = field.name - json_key = python_name # Default: no transformation - - # Check if class has Meta with explicit mappings - if hasattr(cls, "Meta") and hasattr(cls.Meta, "key_transform_with_dump"): # type: ignore[attr-defined] - mappings: dict[str, str] = cls.Meta.key_transform_with_dump # type: ignore[attr-defined] - # Use explicit mapping if available, otherwise keep Python name - json_key = mappings.get(python_name, python_name) - # No Meta mappings - use Python name as-is (no camelCase assumption) - - # Only add override if JSON key differs from Python field name - if json_key != python_name: - field_overrides[python_name] = override(rename=json_key) - - return make_dict_unstructure_fn(cls, converter, **field_overrides) - - -def structure_with_base64_bytes(data: str | bytes, _: type[bytes]) -> bytes: - """ - Structure hook for base64-encoded bytes. - - Handles OpenAPI format "byte" which is base64-encoded string. - - Args: - data: Either base64 string or raw bytes - _: Target type (bytes) - - Returns: - Decoded bytes - """ - if isinstance(data, str): - return base64.b64decode(data) - return data - - -def unstructure_bytes_to_base64(data: bytes) -> str: - """ - Unstructure hook for bytes to base64 string. - - Args: - data: Raw bytes - - Returns: - base64-encoded string - """ - return base64.b64encode(data).decode("utf-8") - - -# Register base64 bytes handling -converter.register_structure_hook(bytes, structure_with_base64_bytes) -converter.register_unstructure_hook(bytes, unstructure_bytes_to_base64) - - -def structure_datetime(data: str | datetime, _: type[datetime]) -> datetime: - """ - Structure hook for datetime fields. - - Handles OpenAPI format "date-time" which is ISO 8601 string. - - Args: - data: Either ISO 8601 string or datetime object - _: Target type (datetime) - - Returns: - datetime object - - Raises: - ValueError: If string is not valid ISO 8601 format - """ - if isinstance(data, datetime): - return data - if isinstance(data, str): - # Try ISO 8601 format with timezone - try: - return datetime.fromisoformat(data.replace("Z", "+00:00")) - except ValueError: - # Try without timezone - return datetime.fromisoformat(data) - raise TypeError(f"Cannot convert {type(data)} to datetime") - - -def unstructure_datetime(data: datetime) -> str: - """ - Unstructure hook for datetime to ISO 8601 string. - - Args: - data: datetime object - - Returns: - ISO 8601 formatted string - """ - return data.isoformat() - - -def structure_date(data: str | date, _: type[date]) -> date: - """ - Structure hook for date fields. - - Handles OpenAPI format "date" which is ISO 8601 date string. - - Args: - data: Either ISO 8601 date string or date object - _: Target type (date) - - Returns: - date object - - Raises: - ValueError: If string is not valid ISO 8601 date format - """ - if isinstance(data, date): - return data - if isinstance(data, str): - return date.fromisoformat(data) - raise TypeError(f"Cannot convert {type(data)} to date") - - -def unstructure_date(data: date) -> str: - """ - Unstructure hook for date to ISO 8601 string. - - Args: - data: date object - - Returns: - ISO 8601 formatted date string (YYYY-MM-DD) - """ - return data.isoformat() - - -# Register datetime and date handling -converter.register_structure_hook(datetime, structure_datetime) -converter.register_unstructure_hook(datetime, unstructure_datetime) -converter.register_structure_hook(date, structure_date) -converter.register_unstructure_hook(date, unstructure_date) - - -# ============================================================================= -# Union Type Structure Hook -# ============================================================================= - - -def _is_union_type(t: Any) -> bool: - """ - Check if a type is a Union type (including Annotated[Union, ...]). - - Scenario: - Detect Union types including both typing.Union and Python 3.10+ X | Y syntax, - as well as Annotated[Union, ...] for discriminated unions. - - Expected Outcome: - Returns True for Union types, False otherwise. - """ - origin = get_origin(t) - # Handle both typing.Union and types.UnionType (Python 3.10+ X | Y syntax) - if origin is Union or (hasattr(types, "UnionType") and isinstance(t, types.UnionType)): - return True - - # Also handle Annotated[Union, ...] for discriminated unions - if origin is Annotated: - args = get_args(t) - if args and _is_union_type(args[0]): - return True - - return False - - -def _truncate_data_repr(data: Any, max_length: int = 200) -> str: - """ - Create a truncated string representation of data for error messages. - - Args: - data: The data to represent - max_length: Maximum length of the output string - - Returns: - A string representation, truncated if necessary - """ - try: - repr_str = repr(data) - if len(repr_str) <= max_length: - return repr_str - return repr_str[: max_length - 3] + "..." - except Exception: - return f"<{type(data).__name__}: repr failed>" - - -def _structure_union(data: Any, union_type: type) -> Any: - """ - Structure a Union type by trying each variant. - - Scenario: - OpenAPI oneOf/anyOf schemas generate Union types. When deserialising - API responses, we need to determine which variant matches the data. - - Expected Outcome: - Returns the structured data as the first matching Union variant. - - Strategy: - 1. If data is None and NoneType is in the union, return None - 2. Check for discriminator metadata for O(1) variant lookup - 3. If data is a dict, try each dataclass variant - 4. Try structuring with other variants (generic types, registered hooks) - 5. Fall back to dict[str, Any] if present - 6. Raise error if no variant matches - - Args: - data: The raw data to structure - union_type: The Union type to structure into - - Returns: - Structured data as one of the Union variants - - Raises: - TypeError: If data is None but NoneType not in union - ValueError: If no Union variant matches the data - """ - # If this is Annotated[Union[...], metadata], extract the Union and metadata - origin = get_origin(union_type) - if origin is Annotated: - annotated_args = get_args(union_type) - if annotated_args: - actual_union = annotated_args[0] - args = get_args(actual_union) - else: - args = get_args(union_type) - else: - args = get_args(union_type) - - # Handle None explicitly - if data is None: - if type(None) in args: - return None - raise TypeError(f"None is not valid for {union_type}") - - # Check for discriminator metadata (from Annotated[Union[...], discriminator]) - # This enables O(1) lookup instead of O(n) sequential tries - if hasattr(union_type, "__metadata__"): - for metadata in union_type.__metadata__: - # Check if this is discriminator metadata (has property_name and get_mapping method) - if hasattr(metadata, "property_name") and hasattr(metadata, "get_mapping"): - if isinstance(data, dict) and metadata.property_name in data: - discriminator_value = data[metadata.property_name] - - # Get the mapping using the get_mapping method - mapping = metadata.get_mapping() - - if mapping and discriminator_value in mapping: - variant = mapping[discriminator_value] - - # Handle forward reference strings - if isinstance(variant, str): - # Try to find the actual type from union args - for arg in args: - if hasattr(arg, "__name__") and arg.__name__ == variant: - variant = arg - break - - # Attempt to structure with the discriminated variant - try: - _register_structure_hooks_recursively(variant) - return converter.structure(data, variant) - except Exception as e: - # Provide clear error message for discriminated variant failure - raise ValueError( - f"Failed to deserialize as {variant.__name__} " - f"(discriminator {metadata.property_name}={discriminator_value!r}): {e}" - ) from e - elif mapping: - # Unknown discriminator value - valid_values = list(mapping.keys()) - raise ValueError( - f"Unknown discriminator value {discriminator_value!r} " - f"for field {metadata.property_name!r}. " - f"Expected one of: {valid_values}" - ) - # If discriminator property not in data, fall through to sequential try - - # Separate variants by type category - dataclass_variants: list[type[Any]] = [] - dict_any_fallback = False - other_variants: list[Any] = [] # Can include generic types like List[T] - - for arg in args: - if arg is type(None): - continue - elif isinstance(arg, type) and dataclasses.is_dataclass(arg): - dataclass_variants.append(arg) - elif get_origin(arg) is dict: - # Check if it's dict[str, Any] - our fallback type - dict_args = get_args(arg) - if dict_args == (str, Any): - dict_any_fallback = True - else: - # Other dict types should be tried as variants - other_variants.append(arg) - else: - # Includes plain types (str, int, datetime) and generic types (List[T]) - other_variants.append(arg) - - # If data is a dict, try dataclass variants first - if isinstance(data, dict): - errors: list[tuple[str, str]] = [] - for variant in dataclass_variants: - try: - # Ensure hooks are registered for this variant - _register_structure_hooks_recursively(variant) - return converter.structure(data, variant) - except Exception as e: - errors.append((variant.__name__, str(e))) - continue - - # If no dataclass matched and dict fallback is available, return raw dict - if dict_any_fallback: - return data - - # All variants failed - provide helpful error with data preview - if errors: - data_preview = _truncate_data_repr(data) - error_details = "\n".join(f" - {name}: {err}" for name, err in errors) - raise ValueError( - f"Could not structure dict into any variant of {union_type}.\n" - f"Data: {data_preview}\n" - f"Tried variants:\n{error_details}" - ) - - # Try other variants using converter.structure (accumulate errors for debugging) - # This handles: - # - Types with registered hooks (datetime, date, bytes, etc.) - # - Generic types (List[T], Dict[K,V], etc.) - # - Plain types (str, int, etc.) - other_errors: list[tuple[str, str]] = [] - for variant in other_variants: - try: - return converter.structure(data, variant) - except Exception as e: # nosec B112 - intentional: trying variants until one succeeds - variant_name = getattr(variant, "__name__", str(variant)) - other_errors.append((variant_name, str(e))) - continue - - # Last resort: if dict fallback is available and we have dict data - if dict_any_fallback and isinstance(data, dict): - return data - - # Include data preview in error message for debugging - data_preview = _truncate_data_repr(data) - if other_errors: - error_details = "\n".join(f" - {name}: {err}" for name, err in other_errors) - raise TypeError( - f"Cannot structure {type(data).__name__} into {union_type}.\n" - f"Data: {data_preview}\n" - f"Tried variants:\n{error_details}" - ) - - raise TypeError( - f"Cannot structure {type(data).__name__} into {union_type}.\n" - f"Data: {data_preview}\n" - f"Expected one of: {[arg for arg in args if arg is not type(None)]}" - ) - - -def _union_structure_hook(data: Any, union_type: type) -> Any: - """ - Structure hook for Union types. - - This hook is called directly by cattrs when structuring a Union type. - It delegates to _structure_union to handle the actual structuring logic. - - Args: - data: The raw data to structure - union_type: The Union type to structure into - - Returns: - Structured data as one of the Union variants - """ - return _structure_union(data, union_type) - - -def _register_union_structure_hook() -> None: - """ - Register a structure hook for Union types. - - Scenario: - cattrs doesn't natively handle Union types containing dataclasses. - This registers a hook that enables structuring for all Union types. - - Expected Outcome: - All Union types (from OpenAPI oneOf/anyOf) can be structured correctly. - """ - - def predicate(t: type) -> bool: - return _is_union_type(t) - - converter.register_structure_hook_func(predicate, _union_structure_hook) - - -# Register the Union hook at module load time -_register_union_structure_hook() - - -def _register_structure_hooks_recursively(cls: type[Any], visited: set[type[Any]] | None = None) -> None: - """ - Recursively register structure hooks for a dataclass and all its nested dataclass types. - - Scenario: - Before structuring a dataclass, we need to register hooks for it and all - nested data classes so that field name transformation works at all levels. - - Expected Outcome: - All dataclass types in the object graph have structure hooks registered. - - Args: - cls: The dataclass type to register hooks for - visited: Set of already-visited types to avoid infinite recursion - """ - if visited is None: - visited = set() - - # Global persistent guard: each class is registered at most once per process. - # Without this, structure_from_dict traverses and calls get_type_hints for the - # entire type graph on every invocation — O(N × calls) instead of O(N) total. - if cls in _structure_hooks_registered: - return - - # Local within-call guard: prevents infinite recursion during the traversal. - if cls in visited: - return - - visited.add(cls) - - # Only process dataclasses - if not dataclasses.is_dataclass(cls): - return - - # Mark as processing before recursing to prevent cycles. - _structure_hooks_registered.add(cls) - - try: - structure_fn = _make_dataclass_structure_fn(cls) - - def predicate(t: type[Any], captured_cls: type[Any] = cls) -> bool: - return t is captured_cls - - # Predicate-based registration intentionally used instead of register_structure_hook. - # Predicate hooks have lower priority than exact hooks, so user-registered hooks - # (e.g. converter.register_structure_hook(MyClass, custom_fn)) are not overwritten. - converter.register_structure_hook_func(predicate, structure_fn) - except Exception: # nosec B110 - pass - - # Traverse nested dataclass fields using the cached type hints. - type_hints = _get_type_hints_with_extras(cls) - - for field in dataclasses.fields(cls): - # Use resolved type hint if available, otherwise raw field type - field_type = type_hints.get(field.name, field.type) - - # Handle direct dataclass types - if isinstance(field_type, type) and dataclasses.is_dataclass(field_type): - _register_structure_hooks_recursively(field_type, visited) - continue - - # Handle generic types (List[T], Optional[T], etc.) and Unions - _register_hooks_for_nested_types(field_type, visited, _register_structure_hooks_recursively) - - -def _register_hooks_for_nested_types( - type_hint: Any, visited: set[type], registrar: Callable[[type, set[type]], None] -) -> None: - """ - Recursively inspect a type hint to find and register hooks for nested dataclasses. - Handles Unions, Lists, Optionals, and other generic types. - """ - # If it's a direct dataclass, register it - if isinstance(type_hint, type) and dataclasses.is_dataclass(type_hint): - registrar(type_hint, visited) - return - - # If it's a generic type or Union, inspect its arguments - origin = get_origin(type_hint) - if origin is not None: - for arg in get_args(type_hint): - _register_hooks_for_nested_types(arg, visited, registrar) - - -def _extract_errors(e: BaseException | Exception, path: str = "") -> list[str]: - """ - Recursively extract human-readable error messages from cattrs validation errors. - - Args: - e: The exception to extract errors from - path: The current path in the object structure (e.g. "items[0].id") - - Returns: - List of formatted error messages - """ - messages = [] - - if isinstance(e, IterableValidationError): - # Handle list/iterable errors - for sub_exc in e.exceptions: - # We don't have reliable index information from cattrs for which item failed - # (it only returns the exceptions, not the indices), so we use [] to indicate - # an item in the list without specifying a misleading index. - new_path = f"{path}[]" if path else "[]" - messages.extend(_extract_errors(sub_exc, new_path)) - - elif isinstance(e, ClassValidationError): - # Handle dataclass/object errors - for sub_exc in e.exceptions: - # ClassValidationError usually wraps other exceptions. - # We try to find which field caused it by inspecting the notes attached by cattrs. - # Notes format: "Structuring class {ClassName} @ attribute {AttributeName}" - field_name = None - if hasattr(sub_exc, "__notes__"): - for note in sub_exc.__notes__: - match = re.search(r"Structuring class .* @ attribute (.*)", note) - if match: - field_name = match.group(1) - break - - if field_name: - new_path = f"{path}.{field_name}" if path else field_name - messages.extend(_extract_errors(sub_exc, new_path)) - else: - messages.extend(_extract_errors(sub_exc, path)) - - elif isinstance(e, ExceptionGroup): - # Handle Python 3.11+ ExceptionGroup if cattrs uses it - for sub_exc in e.exceptions: - messages.extend(_extract_errors(sub_exc, path)) - - else: - # Leaf exception (ValueError, TypeError, etc.) - msg = str(e) - if path: - messages.append(f"{path}: {msg}") - else: - messages.append(msg) - - return messages - - -def structure_from_dict(data: Any, cls: type[T]) -> T: - """ - Structure data into a typed instance with automatic field name transformation. - - Scenario: - Convert JSON response (with camelCase keys) into Python dataclass instance - (with snake_case fields). Works recursively for nested dataclasses. - Supports both single objects and generic types like list[T]. - - Expected Outcome: - Properly structured instance with all field names transformed - automatically, including nested objects and lists. - - Args: - data: Data from JSON (dict, list, or primitive) - cls: Target type (dataclass, list[dataclass], etc.) - - Returns: - Instance of cls - """ - # Register structure hooks for this dataclass and all nested dataclasses - if dataclasses.is_dataclass(cls): - _register_structure_hooks_recursively(cls) - else: - # Handle generic types like list[T], dict[str, T] — register hooks - # for any dataclass types found in the type arguments - _register_hooks_for_nested_types(cls, set(), _register_structure_hooks_recursively) - - try: - return converter.structure(data, cls) - except BaseValidationError as e: - # Extract readable error messages - error_msgs = _extract_errors(e) - error_text = "\n".join(f"- {msg}" for msg in error_msgs) - type_name = getattr(cls, "__name__", str(cls)) - raise ValueError(f"Failed to convert data to {type_name}:\n{error_text}") from e - except Exception as e: - # Fallback for other errors - type_name = getattr(cls, "__name__", str(cls)) - raise ValueError(f"Failed to convert data to {type_name}: {e}") from e - - -def _register_unstructure_hooks_recursively(cls: type[Any], visited: set[type[Any]] | None = None) -> None: - """ - Recursively register unstructure hooks for a dataclass and all its nested dataclass types. - - Scenario: - Before unstructuring a dataclass, we need to register hooks for it and all - nested data classes so that field name transformation works at all levels. - - Expected Outcome: - All dataclass types in the object graph have unstructure hooks registered. - - Args: - cls: The dataclass type to register hooks for - visited: Set of already-visited types to avoid infinite recursion - """ - if visited is None: - visited = set() - - # Global persistent guard: each class is registered at most once per process. - if cls in _unstructure_hooks_registered: - return - - # Local within-call guard: prevents infinite recursion during the traversal. - if cls in visited: - return - - visited.add(cls) - - # Only process dataclasses - if not dataclasses.is_dataclass(cls): - return - - # Mark as processing before recursing to prevent cycles. - _unstructure_hooks_registered.add(cls) - - # Build the unstructure function lazily on first call, then cache it. - # make_dict_unstructure_fn uses static dispatch (captures the converter's hook - # map at generation time). Deferring to first call ensures all nested class hooks - # are already registered, so field-level dispatch is correct. After the first - # call the result is cached in _unstructure_fn_cache — O(1) on every subsequent call. - try: - captured_cls = cls - - def hook(obj: Any, _captured: type[Any] = captured_cls) -> Any: - # Build lazily on first call so make_dict_unstructure_fn runs after all - # nested hooks are registered (it uses static dispatch at generation time). - if _captured not in _unstructure_fn_cache: - _unstructure_fn_cache[_captured] = _make_dataclass_unstructure_fn(_captured) - return _unstructure_fn_cache[_captured](obj) - - def predicate(t: type[Any], captured_cls: type[Any] = cls) -> bool: - return t is captured_cls - - # Predicate-based registration intentionally used instead of register_unstructure_hook. - # Predicate hooks have lower priority than exact hooks, so user-registered hooks - # are not overwritten. - converter.register_unstructure_hook_func(predicate, hook) - except Exception: # nosec B110 - pass - - # Traverse nested dataclass fields using the cached type hints. - type_hints = _get_type_hints_with_extras(cls) - - for field in dataclasses.fields(cls): - field_type = type_hints.get(field.name, field.type) - - if isinstance(field_type, type) and dataclasses.is_dataclass(field_type): - _register_unstructure_hooks_recursively(field_type, visited) - continue - - _register_hooks_for_nested_types(field_type, visited, _register_unstructure_hooks_recursively) - - -def unstructure_to_dict(instance: Any) -> dict[str, Any]: - """ - Unstructure dataclass instance to dict with automatic field name transformation. - - Scenario: - Convert Python dataclass instance (with snake_case fields) into JSON-ready - dictionary (with camelCase keys). Works recursively for nested dataclasses. - - Expected Outcome: - Dictionary with all field names transformed automatically to match JSON - format, including nested objects and lists. - - Args: - instance: Dataclass instance - - Returns: - Dictionary representation - """ - cls = type(instance) - - # Register unstructure hooks for this dataclass and all nested dataclasses - if dataclasses.is_dataclass(cls): - _register_unstructure_hooks_recursively(cls) - - result: dict[str, Any] = converter.unstructure(instance) - return result - - -__all__ = [ - "converter", - "structure_from_dict", - "unstructure_to_dict", - "structure_with_base64_bytes", - "unstructure_bytes_to_base64", - "structure_datetime", - "unstructure_datetime", - "structure_date", - "unstructure_date", - "camel_to_snake", - "snake_to_camel", -] diff --git a/sparkmeter/metering/_generated/core/config.py b/sparkmeter/metering/_generated/core/config.py deleted file mode 100644 index da831dd..0000000 --- a/sparkmeter/metering/_generated/core/config.py +++ /dev/null @@ -1,6 +0,0 @@ - -from dataclasses import dataclass -@dataclass -class ClientConfig: - base_url: str - timeout: float | None = 30.0 diff --git a/sparkmeter/metering/_generated/core/exception_aliases.py b/sparkmeter/metering/_generated/core/exception_aliases.py deleted file mode 100644 index 82d3285..0000000 --- a/sparkmeter/metering/_generated/core/exception_aliases.py +++ /dev/null @@ -1,17 +0,0 @@ -from httpx import Response -from sparkmeter.metering._generated.core.exceptions import ClientError, ServerError - -class UnprocessableEntityError(ClientError): - """HTTP 422 Unprocessable Entity. - -Raised when the server responds with a 422 status code.""" - def __init__(self, response: Response) -> None: - """Initialise UnprocessableEntityError with the HTTP response. - - Args: - response: The httpx Response object that triggered this exception - """ - super().__init__(status_code=response.status_code, message=response.text, response=response) - - -__all__ = ["UnprocessableEntityError"] diff --git a/sparkmeter/metering/_generated/core/exceptions.py b/sparkmeter/metering/_generated/core/exceptions.py deleted file mode 100644 index 15161e8..0000000 --- a/sparkmeter/metering/_generated/core/exceptions.py +++ /dev/null @@ -1,20 +0,0 @@ -class HTTPError(Exception): - """Base HTTP error with status code and message.""" - - def __init__(self, status_code: int, message: str, response: object | None = None) -> None: - super().__init__(f"{status_code}: {message}") - self.status_code = status_code - self.message = message - self.response = response - - -class ClientError(HTTPError): - """4XX client error responses.""" - - pass - - -class ServerError(HTTPError): - """5XX server error responses.""" - - pass diff --git a/sparkmeter/metering/_generated/core/http_transport.py b/sparkmeter/metering/_generated/core/http_transport.py deleted file mode 100644 index 674eb6a..0000000 --- a/sparkmeter/metering/_generated/core/http_transport.py +++ /dev/null @@ -1,222 +0,0 @@ -from typing import Any, Protocol - -import httpx - -from .auth.base import BaseAuth -from .exceptions import HTTPError - - -class HttpTransport(Protocol): - """ - Defines the interface for an asynchronous HTTP transport layer. - - This protocol allows different HTTP client implementations (like httpx, aiohttp) - to be used interchangeably by the generated API client. It requires - implementing classes to provide an async `request` method. - - All implementations must: - - Provide a fully type-annotated async `request` method. - - Return an `httpx.Response` object for all requests. - - Be safe for use in async contexts. - - STRICT REQUIREMENT: Raise `HTTPError` for all HTTP responses with status codes < 200 or >= 300. - This ensures that only successful (2xx) responses are returned to the caller, and all error - or informational responses are handled via exceptions. Implementors must not return non-2xx - responses directly; instead, they must raise `HTTPError` with the status code and response body. - This contract guarantees consistent error handling for all generated clients. - """ - - async def request( - self, - method: str, - url: str, - **kwargs: Any, - ) -> httpx.Response: - """ - Sends an asynchronous HTTP request. - - Args: - method: The HTTP method (e.g., 'GET', 'POST'). - url: The target URL for the request. - **kwargs: Additional keyword arguments for the HTTP client (e.g., headers, params, json, data). - - Returns: - httpx.Response: The HTTP response object. - - Raises: - HTTPError: For all HTTP responses with status codes < 200 or >= 300. Implementors MUST raise - this exception for any non-2xx response, passing the status code and response body. - Exception: Implementations may raise exceptions for network errors or invalid responses. - - Protocol Contract: - - Only successful (2xx) responses are returned to the caller. - - All other responses (including redirects, client errors, and server errors) must result in - an HTTPError being raised. This ensures that error handling is consistent and explicit - across all generated clients and transport implementations. - """ - raise NotImplementedError() - - async def close(self) -> None: - """ - Closes any resources held by the transport (e.g., HTTP connections). - - All implementations must provide this method. If the transport does not hold resources, - this should be a no-op. - """ - raise NotImplementedError() - - -class HttpxTransport: - """ - A concrete implementation of the HttpTransport protocol using the `httpx` library. - - This class provides the default asynchronous HTTP transport mechanism for the - generated API client. It wraps an `httpx.AsyncClient` instance to handle - request sending, connection pooling, and resource management. - - Optionally supports authentication via any BaseAuth-compatible plugin, including CompositeAuth. - - CONTRACT: - - This implementation strictly raises `HTTPError` for all HTTP responses with status codes < 200 or >= 300. - Only successful (2xx) responses are returned to the caller. This ensures that all error, informational, - and redirect responses are handled via exceptions, providing a consistent error handling model for the - generated client code. - - Attributes: - _client (httpx.AsyncClient): Configured HTTPX async client for all requests. - _auth (BaseAuth | None): Optional authentication plugin for request signing (can be CompositeAuth). - _bearer_token (str | None): Optional bearer token for Authorization header. - _default_headers (dict[str, str] | None): Default headers to apply to all requests. - """ - - def __init__( - self, - base_url: str, - timeout: float | None = None, - auth: BaseAuth | None = None, - bearer_token: str | None = None, - default_headers: dict[str, str] | None = None, - verify_ssl: bool = True, - ) -> None: - """ - Initializes the HttpxTransport. - - Args: - base_url (str): The base URL for all API requests made through this transport. - timeout (float | None): The default timeout in seconds for requests. If None, httpx's default is used. - auth (BaseAuth | None): Optional authentication plugin for request signing (can be CompositeAuth). - bearer_token (str | None): Optional raw bearer token string for Authorization header. - default_headers (dict[str, str] | None): Default headers to apply to all requests. - verify_ssl (bool): Whether to verify SSL certificates. Defaults to True. - Set to False for local development with self-signed certificates. - - Note: - If both auth and bearer_token are provided, auth takes precedence. - """ - self._client: httpx.AsyncClient = httpx.AsyncClient(base_url=base_url, timeout=timeout, verify=verify_ssl) - self._auth: BaseAuth | None = auth - self._bearer_token: str | None = bearer_token - self._default_headers: dict[str, str] | None = default_headers - - async def _prepare_headers( - self, - current_request_kwargs: dict[str, Any], - ) -> dict[str, str]: - """ - Prepares headers for an HTTP request, incorporating default headers, - request-specific headers, and authentication. - """ - # Initialize headers for the current request - prepared_headers: dict[str, str] = {} - - # 1. Apply transport-level default headers - if self._default_headers: - prepared_headers.update(self._default_headers) - - # 2. Merge headers passed specifically for this request (overriding transport defaults) - if "headers" in current_request_kwargs and isinstance(current_request_kwargs["headers"], dict): - prepared_headers.update(current_request_kwargs["headers"]) - - # 3. Apply authentication plugin or bearer token (which can further modify headers) - # We pass a temporary request_args dict containing only the headers to the auth plugin, - # as the auth plugin might expect other keys which are not relevant for header preparation. - # The auth plugin is expected to modify the 'headers' key in the passed dict. - temp_request_args_for_auth = {"headers": prepared_headers.copy()} - - if self._auth is not None: - authenticated_args = await self._auth.authenticate_request(temp_request_args_for_auth) - # Ensure 'headers' key exists and is a dict after authentication - if "headers" in authenticated_args and isinstance(authenticated_args["headers"], dict): - prepared_headers = authenticated_args["headers"] - else: - # Handle cases where auth plugin might not return headers as expected - # This could be an error or a specific design of an auth plugin. - # For now, we assume it should always return a 'headers' dict. - # If not, we retain the headers we had before calling the auth plugin. - pass # Or raise an error, or log a warning. - elif self._bearer_token is not None: - # If no auth plugin, but bearer token is present, add/overwrite Authorization header. - prepared_headers["Authorization"] = f"Bearer {self._bearer_token}" - - return prepared_headers - - async def request( - self, - method: str, - url: str, - **kwargs: Any, - ) -> httpx.Response: - """ - Sends an asynchronous HTTP request using the underlying httpx.AsyncClient. - - Args: - method (str): The HTTP method (e.g., 'GET', 'POST'). - url (str): The target URL path, relative to the `base_url` provided during initialization, or an absolute - URL. - **kwargs: Additional keyword arguments passed directly to `httpx.AsyncClient.request` (e.g., headers, - params, json, data). - - Returns: - httpx.Response: The HTTP response object from the server. - - Raises: - httpx.HTTPError: For network errors or invalid responses. - HTTPError: For non-2xx HTTP responses. - """ - # Prepare request arguments, excluding headers initially - request_args: dict[str, Any] = {k: v for k, v in kwargs.items() if k != "headers"} - - # This method handles default headers, request-specific headers, and authentication - prepared_headers = await self._prepare_headers(kwargs) - request_args["headers"] = prepared_headers - - response = await self._client.request(method, url, **request_args) - if response.status_code < 200 or response.status_code >= 300: - raise HTTPError(status_code=response.status_code, message=response.text, response=response) - return response - - async def close(self) -> None: - """ - Closes the underlying httpx.AsyncClient and releases resources. - - This should be called when the transport is no longer needed, typically - when the main API client is being shut down, to ensure proper cleanup - of network connections. - """ - await self._client.aclose() - - async def __aenter__(self) -> "HttpxTransport": - """ - Enter the async context manager. Returns self. - """ - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: object | None, - ) -> None: - """ - Exit the async context manager. Calls close(). - """ - await self.close() diff --git a/sparkmeter/metering/_generated/core/pagination.py b/sparkmeter/metering/_generated/core/pagination.py deleted file mode 100644 index 5e6cb1d..0000000 --- a/sparkmeter/metering/_generated/core/pagination.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Pagination utilities for handling paginated API endpoints. - -This module provides functions for working with paginated API responses, -turning them into convenient async iterators that automatically handle -fetching subsequent pages. -""" - -from typing import Any, AsyncIterator, Awaitable, Callable - - -def paginate_by_next( - fetch_page: Callable[..., Awaitable[dict[str, Any]]], - items_key: str = "items", - next_key: str = "next", - **params: Any, -) -> AsyncIterator[Any]: - """ - Create an async iterator that yields items from paginated API responses. - - This function creates a paginator that automatically handles fetching - subsequent pages of results by using a "next page token" pattern. It calls - the provided `fetch_page` function repeatedly with the given parameters, - updating the next token parameter between calls. - - Args: - fetch_page: Async function to fetch a page of results - items_key: The key in the response dict where items are located (default: "items") - next_key: The key in the response dict for the next page token (default: "next") - **params: Initial parameters to pass to fetch_page - - Returns: - An AsyncIterator that yields individual items from all pages - - Example: - ```python - async def fetch_users_page(page_token=None, limit=100): - url = f"/users?limit={limit}" - if page_token: - url += f"&page_token={page_token}" - return await http_client.get(url) - - async for user in paginate_by_next(fetch_users_page, - items_key="users", - next_key="page_token", - limit=50): - print(user["name"]) - ``` - """ - - async def _paginate() -> AsyncIterator[Any]: - while True: - result = await fetch_page(**params) - # result is expected to be a dict - # (assumed since fetch_page is typed to return dict[str, Any]) - items = result.get(items_key, []) - for item in items: - yield item - token = result.get(next_key) - if not token: - break - params[next_key] = token - - return _paginate() diff --git a/sparkmeter/metering/_generated/core/py.typed b/sparkmeter/metering/_generated/core/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/sparkmeter/metering/_generated/core/streaming_helpers.py b/sparkmeter/metering/_generated/core/streaming_helpers.py deleted file mode 100644 index 80fa4db..0000000 --- a/sparkmeter/metering/_generated/core/streaming_helpers.py +++ /dev/null @@ -1,84 +0,0 @@ -import json -from typing import Any, AsyncIterator, List - -import httpx - - -class SSEEvent: - def __init__(self, data: str, event: str | None = None, id: str | None = None, retry: int | None = None) -> None: - self.data: str = data - self.event: str | None = event - self.id: str | None = id - self.retry: int | None = retry - - def __repr__(self) -> str: - return f"SSEEvent(data={self.data!r}, event={self.event!r}, id={self.id!r}, retry={self.retry!r})" - - -async def iter_bytes(response: httpx.Response) -> AsyncIterator[bytes]: - async for chunk in response.aiter_bytes(): - yield chunk - - -async def iter_ndjson(response: httpx.Response) -> AsyncIterator[Any]: - async for line in response.aiter_lines(): - line = line.strip() - if line: - yield json.loads(line) - - -async def iter_sse(response: httpx.Response) -> AsyncIterator[SSEEvent]: - """Parse Server-Sent Events (SSE) from a streaming response.""" - event_lines: list[str] = [] - async for line in response.aiter_lines(): - if line == "": - # End of event - if event_lines: - event = _parse_sse_event(event_lines) - if event: - yield event - event_lines = [] - else: - event_lines.append(line) - # Last event (if any) - if event_lines: - event = _parse_sse_event(event_lines) - if event: - yield event - - -def _parse_sse_event(lines: List[str]) -> SSEEvent: - data = [] - event = None - id = None - retry = None - for line in lines: - if line.startswith(":"): - continue # comment - if ":" in line: - field, value = line.split(":", 1) - value = value.lstrip() - if field == "data": - data.append(value) - elif field == "event": - event = value - elif field == "id": - id = value - elif field == "retry": - try: - retry = int(value) - except ValueError: - pass - return SSEEvent(data="\n".join(data), event=event, id=id, retry=retry) - - -async def iter_sse_events_text(response: httpx.Response) -> AsyncIterator[str]: - """ - Parses a Server-Sent Events (SSE) stream and yields the `data` field content - as a string for each event. - This is specifically for cases where the event data is expected to be a - single text payload (e.g., a JSON string) per event. - """ - async for sse_event in iter_sse(response): - if sse_event.data: # Ensure data is not empty - yield sse_event.data diff --git a/sparkmeter/metering/_generated/core/utils.py b/sparkmeter/metering/_generated/core/utils.py deleted file mode 100644 index 7540de8..0000000 --- a/sparkmeter/metering/_generated/core/utils.py +++ /dev/null @@ -1,520 +0,0 @@ -"""Utilities for pyopenapi_gen. - -This module contains utility classes and functions used across the code generation process. -""" - -import base64 -import dataclasses -import keyword -import logging -import re -from typing import Any, Set, Type, TypeVar, cast - -logger = logging.getLogger(__name__) - -T = TypeVar("T") - - -class NameSanitizer: - """Helper to sanitize spec names and tags into valid Python identifiers and filenames.""" - - # Python built-ins and common problematic names that should be avoided in module names - RESERVED_NAMES = { - # Built-in types - "type", - "int", - "str", - "float", - "bool", - "list", - "dict", - "set", - "tuple", - "bytes", - "object", - "complex", - "frozenset", - "bytearray", - "memoryview", - "range", - # Built-in functions - "abs", - "all", - "any", - "bin", - "callable", - "chr", - "classmethod", - "compile", - "delattr", - "dir", - "divmod", - "enumerate", - "eval", - "exec", - "filter", - "format", - "getattr", - "globals", - "hasattr", - "hash", - "help", - "hex", - "id", - "input", - "isinstance", - "issubclass", - "iter", - "len", - "locals", - "map", - "max", - "min", - "next", - "oct", - "open", - "ord", - "pow", - "print", - "property", - "repr", - "reversed", - "round", - "setattr", - "slice", - "sorted", - "staticmethod", - "sum", - "super", - "vars", - "zip", - # Common standard library modules - "os", - "sys", - "json", - "time", - "datetime", - "math", - "random", - "string", - "collections", - "itertools", - "functools", - "typing", - "pathlib", - "logging", - "urllib", - "http", - "email", - "uuid", - "hashlib", - "base64", - "copy", - "re", - # Other problematic names - "data", - "model", - "models", - "client", - "api", - "config", - "utils", - "helpers", - } - - @staticmethod - def sanitize_module_name(name: str) -> str: - """Convert a raw name into a valid Python module name in snake_case, - splitting camel case and PascalCase.""" - # # <<< Add Check for problematic input >>> - # if '[' in name or ']' in name or ',' in name: - # logger.error(f"sanitize_module_name received potentially invalid input: '{name}'") - # # Optionally, return a default/error value or raise exception - # # For now, just log and continue - # # <<< End Check >>> - - # Split on non-alphanumeric and camel case boundaries - words = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", name) - if not words: - # fallback: split on non-alphanumerics - words = re.split(r"\W+", name) - module = "_".join(word.lower() for word in words if word) - # If it starts with a digit, prefix with underscore - if module and module[0].isdigit(): - module = "_" + module - # Avoid Python keywords and reserved names - if keyword.iskeyword(module) or module in NameSanitizer.RESERVED_NAMES: - module += "_" - return module - - @staticmethod - def sanitize_class_name(name: str) -> str: - """Convert a raw name into a valid Python class name in PascalCase.""" - # Split on non-alphanumeric and camel case boundaries - words = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", name) - if not words: # Fallback if findall is empty (e.g. if name was all symbols) - # Basic split on non-alphanumeric as a last resort if findall yields nothing - words = [part for part in re.split(r"[^a-zA-Z0-9]+", name) if part] - - # Capitalize each word and join - cls_name = "".join(word.capitalize() for word in words if word) - - if not cls_name: # If name was e.g. "-" or "_" - cls_name = "UnnamedClass" # Or some other default - - # If it starts with a digit, prefix with underscore - if cls_name[0].isdigit(): # Check after ensuring cls_name is not empty - cls_name = "_" + cls_name - # Avoid Python keywords and reserved names (case-insensitive) - if keyword.iskeyword(cls_name.lower()) or cls_name.lower() in NameSanitizer.RESERVED_NAMES: - cls_name += "_" - return cls_name - - @staticmethod - def sanitize_tag_class_name(tag: str) -> str: - """Sanitize a tag for use as a PascalCase client class name (e.g., DataSourcesClient).""" - words = re.split(r"[\W_]+", tag) - return "".join(word.capitalize() for word in words if word) + "Client" - - @staticmethod - def sanitize_tag_attr_name(tag: str) -> str: - """Sanitize a tag for use as a snake_case attribute name (e.g., data_sources).""" - attr = re.sub(r"[\W]+", "_", tag).lower() - return attr.strip("_") - - @staticmethod - def normalize_tag_key(tag: str) -> str: - """Normalize a tag for case-insensitive uniqueness (e.g., datasources).""" - return re.sub(r"[\W_]+", "", tag).lower() - - @staticmethod - def sanitize_filename(name: str, suffix: str = ".py") -> str: - """Generate a valid Python filename from raw name in snake_case.""" - module = NameSanitizer.sanitize_module_name(name) - return module + suffix - - @staticmethod - def sanitize_method_name(name: str) -> str: - """Convert a raw name into a valid Python method name in snake_case, - splitting camelCase and PascalCase.""" - # Remove curly braces - name = re.sub(r"[{}]", "", name) - # Split camelCase and PascalCase to snake_case - name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) - name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) - # Replace non-alphanumerics with underscores - name = re.sub(r"[^0-9a-zA-Z_]", "_", name) - # Lowercase and collapse multiple underscores - name = re.sub(r"_+", "_", name).strip("_").lower() - # If it starts with a digit, prefix with underscore - if name and name[0].isdigit(): - name = "_" + name - # Avoid Python keywords and reserved names - if keyword.iskeyword(name) or name in NameSanitizer.RESERVED_NAMES: - name += "_" - return name - - @staticmethod - def clean_auto_generated_operation_id(operation_id: str, http_method: str, path: str) -> str: - """Strip FastAPI auto-generated suffixes from an operationId. - - FastAPI generates operationIds like ``create_details_details_post`` - from handler ``create_details`` at ``POST /details``. This method - detects that pattern and returns just ``create_details``. - - If the pattern is not detected the original *operation_id* is - returned unchanged. - """ - method_lower = http_method.lower() - method_suffix = f"_{method_lower}" - - # 1. operationId must end with _{method} - if not operation_id.lower().endswith(method_suffix): - return operation_id - - # 2. Strip the method suffix (preserve original casing of the prefix) - without_method = operation_id[: -len(method_suffix)] - - # 3. Normalise path to the FastAPI segment format. - # FastAPI replaces all non-word characters (\W) with underscores, - # so we must do the same to match paths with hyphens, dots, etc. - normalized_path = path.strip("/") - normalized_path = re.sub(r"[{}]", "", normalized_path) - normalized_path = re.sub(r"[^0-9a-zA-Z_]", "_", normalized_path) - normalized_path = re.sub(r"_+", "_", normalized_path).strip("_").lower() - - # 4. Check for _{normalized_path} suffix - if not normalized_path: - return operation_id - - path_suffix = f"_{normalized_path}" - if without_method.lower().endswith(path_suffix): - prefix = without_method[: -len(path_suffix)] - # 5. Guard against empty result - if prefix: - return prefix - - return operation_id - - @staticmethod - def is_valid_python_identifier(name: str) -> bool: - """Check if a string is a valid Python identifier.""" - if not isinstance(name, str) or not name: - return False - # Check if it's a keyword - if keyword.iskeyword(name): - return False - # Check pattern: starts with letter/underscore, then letter/digit/underscore - return re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name) is not None - - -class ParamSubstitutor: - """Helper for rendering path templates with path parameters.""" - - @staticmethod - def render_path(template: str, values: dict[str, Any]) -> str: - """Replace placeholders in a URL path template using provided values.""" - rendered = template - for key, val in values.items(): - rendered = rendered.replace(f"{{{key}}}", str(val)) - return rendered - - -class KwargsBuilder: - """Builder for assembling HTTP request keyword arguments.""" - - def __init__(self) -> None: - self._kwargs: dict[str, Any] = {} - - def with_params(self, **params: Any) -> "KwargsBuilder": - """Add query parameters, skipping None values.""" - filtered = {k: v for k, v in params.items() if v is not None} - if filtered: - self._kwargs["params"] = filtered - return self - - def with_json(self, body: Any) -> "KwargsBuilder": - """Add a JSON body to the request.""" - self._kwargs["json"] = body - return self - - def build(self) -> dict[str, Any]: - """Return the assembled kwargs dictionary.""" - return self._kwargs - - -class Formatter: - """Helper to format code using Black, falling back to unformatted content if - Black is unavailable or errors.""" - - def __init__(self) -> None: - from typing import Any, Callable - - self._file_mode: Any | None = None - self._format_str: Callable[..., str] | None = None - try: - from black import FileMode, format_str - - # Suppress blib2to3 debug logging that floods output during formatting - blib2to3_logger = logging.getLogger("blib2to3") - blib2to3_logger.setLevel(logging.WARNING) - - # Also suppress the driver logger specifically - driver_logger = logging.getLogger("blib2to3.pgen2.driver") - driver_logger.setLevel(logging.WARNING) - - # Initialize Black formatter - self._file_mode = FileMode() - self._format_str = format_str - except ImportError: - self._file_mode = None - self._format_str = None - - def format(self, code: str) -> str: - """Format the given code string with Black if possible.""" - if self._format_str is not None and self._file_mode is not None: - try: - formatted: str = self._format_str(code, mode=self._file_mode) - return formatted - except Exception: - # On any Black formatting error, return original code - return code - return code - - -# --- Casting Helper --- - - -def safe_cast(expected_type: Type[T], data: Any) -> T: - """ - Performs a cast for the type checker using object cast. - (Validation temporarily removed). - """ - # No validation for now - # Cast to object first, then to expected_type - return cast(expected_type, cast(object, data)) # type: ignore[valid-type] - - -class DataclassSerializer: - """Utility for converting dataclass instances to dictionaries for API serialisation. - - This is a convenience wrapper around cattrs unstructure_to_dict() with - circular reference protection. All serialisation is delegated to cattrs which provides: - - Custom unstructure hooks for correct handling of generated types - - Field name transformation (snake_case → camelCase) - - Type-specific handling (datetime, bytes, enums) - - Recursive nested dataclass handling - - Circular reference protection is provided by tracking visited objects and - post-processing cattrs output to ensure complete dict conversion. - - Note: This class is maintained for backward compatibility. - New code should use unstructure_to_dict() directly from cattrs_converter. - """ - - @staticmethod - def serialize(obj: Any) -> Any: - """Convert dataclass instances to dictionaries using cattrs with circular ref protection. - - Uses cattrs converter which respects custom unstructure hooks - for correct handling of all generated types, with additional circular - reference tracking to ensure JSON-safe output. - - Args: - obj: The object to serialise. Can be a dataclass, list, dict, or primitive. - - Returns: - The serialised object with dataclasses converted to dictionaries. - Guaranteed to be JSON-serializable (no dataclass instances in output). - - Handles: - - Dataclass instances with Meta.key_transform_with_dump: Applies field - name mapping (snake_case → camelCase) - - Regular dataclass instances: Converted to dictionaries using field names - - Lists: Recursively serialise each item - - Dictionaries: Recursively serialise values - - datetime: Convert to ISO format string - - Enums: Convert to their value - - bytes/bytearray: Convert to base64-encoded ASCII string - - Primitives: Return unchanged - - None values: Excluded from output - - Circular references: Handled gracefully (returns None for cycles) - - Custom cattrs hooks: Applied automatically for types with registered hooks - """ - return DataclassSerializer._serialize_with_tracking(obj, set()) - - @staticmethod - def _serialize_with_tracking(obj: Any, visited: Set[int]) -> Any: - """Internal serialisation with circular reference tracking. - - This wraps cattrs.unstructure_to_dict() with: - - Circular reference detection (prevents infinite recursion) - - Post-processing to ensure complete dict conversion - - cattrs handles: - - Custom unstructure hooks (critical for types with _data fields) - - Field name transformations (Meta.key_transform_with_dump) - - Type-specific conversions (datetime, enum, bytes) - - Args: - obj: The object to serialise - visited: Set of object IDs currently being processed - - Returns: - Serialised object with all dataclasses converted to dicts - """ - from .cattrs_converter import unstructure_to_dict - - # Handle primitives early (no tracking needed) - if obj is None or isinstance(obj, (str, int, float, bool)): - return obj - - # Handle circular references - obj_id = id(obj) - if obj_id in visited: - # Return None for circular refs (JSON-safe, avoids infinite recursion) - return None - - # Special handling for bytearray (cattrs handles bytes but not bytearray) - if isinstance(obj, bytearray): - return base64.b64encode(bytes(obj)).decode("ascii") - - # Handle lists - recurse on each item - if isinstance(obj, list): - visited.add(obj_id) - try: - return [DataclassSerializer._serialize_with_tracking(item, visited) for item in obj] - finally: - visited.remove(obj_id) - - # For dataclasses, track and use cattrs - if dataclasses.is_dataclass(obj) and not isinstance(obj, type): - visited.add(obj_id) - try: - # Let cattrs do the heavy lifting (respects custom hooks, field mappings, etc.) - result = unstructure_to_dict(obj) - - # Post-process to ensure nested dataclasses are fully converted - result = DataclassSerializer._ensure_all_dicts(result, visited) - - # Filter None values (maintains API compatibility) - return DataclassSerializer._remove_none_values(result) - finally: - visited.remove(obj_id) - - # For everything else (dicts, primitives, etc.), let cattrs handle it - result = unstructure_to_dict(obj) - return DataclassSerializer._remove_none_values(result) - - @staticmethod - def _ensure_all_dicts(obj: Any, visited: Set[int]) -> Any: - """Post-process cattrs output to ensure nested dataclasses are dicts. - - cattrs sometimes leaves nested dataclass instances unconverted in circular - reference scenarios. This recursively ensures all dataclasses become dicts. - - Args: - obj: The object from cattrs (dict, list, or primitive) - visited: Set of object IDs already being processed - - Returns: - Object with all nested dataclasses converted to dicts - """ - # If we encounter a dataclass instance, it wasn't properly unstructured by cattrs - if dataclasses.is_dataclass(obj) and not isinstance(obj, type): - # Recurse through serialize to handle it properly - return DataclassSerializer._serialize_with_tracking(obj, visited) - - # Recursively process dict values - if isinstance(obj, dict): - result = {} - for key, value in obj.items(): - if value is not None: # Skip None values - processed = DataclassSerializer._ensure_all_dicts(value, visited) - if processed is not None: # Only include if not None after processing - result[key] = processed - return result - - # Recursively process list items - if isinstance(obj, list): - return [DataclassSerializer._ensure_all_dicts(item, visited) for item in obj] - - # Primitives pass through - return obj - - @staticmethod - def _remove_none_values(obj: Any) -> Any: - """Recursively remove None values from dictionaries and lists. - - Args: - obj: The object to filter (dict, list, or other) - - Returns: - The object with None values removed from dicts - """ - if isinstance(obj, dict): - return {k: DataclassSerializer._remove_none_values(v) for k, v in obj.items() if v is not None} - elif isinstance(obj, list): - return [DataclassSerializer._remove_none_values(item) for item in obj] - else: - return obj diff --git a/sparkmeter/metering/_generated/endpoints/__init__.py b/sparkmeter/metering/_generated/endpoints/__init__.py deleted file mode 100644 index 55c4cc7..0000000 --- a/sparkmeter/metering/_generated/endpoints/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__all__ = ["DefaultClient", "DefaultClientProtocol"] -from .default import DefaultClient, DefaultClientProtocol diff --git a/sparkmeter/metering/_generated/endpoints/default.py b/sparkmeter/metering/_generated/endpoints/default.py deleted file mode 100644 index 0e07ae8..0000000 --- a/sparkmeter/metering/_generated/endpoints/default.py +++ /dev/null @@ -1,132 +0,0 @@ -from typing import Any, AsyncIterator, Callable, Dict, NoReturn, Optional, Protocol, cast, runtime_checkable - -from ..models.command_accepted import CommandAccepted -from ..models.stream_events_v_1_events_get_param_client_id import StreamEventsV1EventsGetParamClientId -from ..models.stream_events_v_1_events_get_param_types import StreamEventsV1EventsGetParamTypes -from ..models.submit_command_v_1_commands_post_request_body import SubmitCommandV1CommandsPostRequestBody -from sparkmeter.metering._generated.core import UnprocessableEntityError -from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict -from sparkmeter.metering._generated.core.exceptions import HTTPError -from sparkmeter.metering._generated.core.http_transport import HttpTransport -from sparkmeter.metering._generated.core.streaming_helpers import iter_bytes, iter_sse_events_text -from sparkmeter.metering._generated.core.utils import DataclassSerializer - -import collections.abc -import json - -from ..models.command_accepted import CommandAccepted -from ..models.stream_events_v_1_events_get_param_client_id import StreamEventsV1EventsGetParamClientId -from ..models.stream_events_v_1_events_get_param_types import StreamEventsV1EventsGetParamTypes -from ..models.submit_command_v_1_commands_post_request_body import SubmitCommandV1CommandsPostRequestBody - -@runtime_checkable -class DefaultClientProtocol(Protocol): - """Protocol defining the interface of DefaultClient for dependency injection.""" - - async def submit_command_v1_commands_post( - self, - body: SubmitCommandV1CommandsPostRequestBody, - ) -> CommandAccepted: ... - - def stream_events_v1_events_get( - self, - types: StreamEventsV1EventsGetParamTypes | None = None, - client_id: StreamEventsV1EventsGetParamClientId | None = None, - ) -> AsyncIterator[dict[str, Any]]: ... - - - -class DefaultClient(DefaultClientProtocol): - """Client for default endpoints. Uses HttpTransport for all HTTP and header management.""" - - def __init__(self, transport: HttpTransport, base_url: str) -> None: - self._transport = transport - self.base_url: str = base_url - - async def submit_command_v1_commands_post( - self, - body: SubmitCommandV1CommandsPostRequestBody, - ) -> CommandAccepted: - """ - Submit Command - - Args: - body (SubmitCommandV1CommandsPostRequestBody) - : Request body. (json) - - Returns: - CommandAccepted: Successful Response - - Raises: - HttpError: - HTTPError: 422: Validation Error - """ - url = f"{self.base_url}/v1/commands" - - json_body: SubmitCommandV1CommandsPostRequestBody = DataclassSerializer.serialize(body) - - response = await self._transport.request("POST", url, params=None, json=json_body, headers=None) - - # Check response status code and handle accordingly - match response.status_code: - case 202: - return structure_from_dict(response.json(), CommandAccepted) - case 422: - raise UnprocessableEntityError(response=response) - case _: - raise HTTPError(response=response, message="Unhandled status code", status_code=response.status_code) - # All paths above should return or raise - this should never execute - raise RuntimeError('Unexpected code path') # pragma: no cover - - - async def stream_events_v1_events_get( - self, - types: StreamEventsV1EventsGetParamTypes | None = None, - client_id: StreamEventsV1EventsGetParamClientId | None = None, - ) -> AsyncIterator[dict[str, Any]]: - """ - Stream Events - - Args: - types (StreamEventsV1EventsGetParamTypes | None) - : Comma-separated event_type filter; omit for all events. - client_id (StreamEventsV1EventsGetParamClientId | None) - : Stable client identifier for command-reply routing. If - omitted, the server assigns a fresh UUID. - - Returns: - AsyncIterator[dict[str, Any]]: Long-lived Server-Sent Events stream. Each event's - `data:` payload is a JSON-encoded `Event` (one of the - variants in `components.schemas`). - - Raises: - HttpError: - HTTPError: 422: Validation Error - """ - url = f"{self.base_url}/v1/events" - - params: dict[str, Any] = { - **({"types": DataclassSerializer.serialize(types)} if types is not None else {}), - **({"client_id": DataclassSerializer.serialize(client_id)} if client_id is not None else {}), - } - - response = await self._transport.request( - "GET", url, - params=params, - json=None, - data=None, - headers=None - ) - - # Check response status code and handle accordingly - match response.status_code: - case 200: - async for chunk in iter_sse_events_text(response): - yield json.loads(chunk) - return # Explicit return for async generator - case 422: - raise UnprocessableEntityError(response=response) - case _: - raise HTTPError(response=response, message="Unhandled status code", status_code=response.status_code) - # All paths above should return or raise - this should never execute - raise RuntimeError('Unexpected code path') # pragma: no cover \ No newline at end of file diff --git a/sparkmeter/metering/_generated/endpoints/py.typed b/sparkmeter/metering/_generated/endpoints/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/sparkmeter/metering/_generated/mocks/__init__.py b/sparkmeter/metering/_generated/mocks/__init__.py deleted file mode 100644 index b25b815..0000000 --- a/sparkmeter/metering/_generated/mocks/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Mock implementations for testing. - -These mocks implement the Protocol contracts without requiring -network transport or authentication. Use them as base classes -in your tests. - -Example: - from myapi.mocks import MockAPIClient, MockPetsClient - - class TestPetsClient(MockPetsClient): - async def list_pets(self, limit: int | None = None) -> list[Pet]: - return [Pet(id=1, name='Test Pet')] - - client = MockAPIClient(pets=TestPetsClient()) -""" - -from .mock_client import MockAPIClient -from .endpoints.mock_default import MockDefaultClient - -__all__ = [ - "MockAPIClient", - "MockDefaultClient", -] \ No newline at end of file diff --git a/sparkmeter/metering/_generated/mocks/endpoints/__init__.py b/sparkmeter/metering/_generated/mocks/endpoints/__init__.py deleted file mode 100644 index 7132fbf..0000000 --- a/sparkmeter/metering/_generated/mocks/endpoints/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Mock endpoint clients for testing. - -Import mock classes to use as base classes for your test doubles. -""" - -from .mock_default import MockDefaultClient - -__all__ = [ - "MockDefaultClient", -] \ No newline at end of file diff --git a/sparkmeter/metering/_generated/mocks/endpoints/mock_default.py b/sparkmeter/metering/_generated/mocks/endpoints/mock_default.py deleted file mode 100644 index 89de286..0000000 --- a/sparkmeter/metering/_generated/mocks/endpoints/mock_default.py +++ /dev/null @@ -1,63 +0,0 @@ -from typing import Any, AsyncIterator, Dict, NoReturn, TYPE_CHECKING - -from ...models.command_accepted import CommandAccepted -from ...models.stream_events_v_1_events_get_param_client_id import StreamEventsV1EventsGetParamClientId -from ...models.stream_events_v_1_events_get_param_types import StreamEventsV1EventsGetParamTypes -from ...models.submit_command_v_1_commands_post_request_body import SubmitCommandV1CommandsPostRequestBody -from sparkmeter.metering._generated.core import UnprocessableEntityError -from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict -from sparkmeter.metering._generated.core.exceptions import HTTPError -from sparkmeter.metering._generated.core.http_transport import HttpTransport -from sparkmeter.metering._generated.core.streaming_helpers import iter_sse_events_text -from sparkmeter.metering._generated.core.utils import DataclassSerializer - -import collections.abc -import json - -from ...models.command_accepted import CommandAccepted -from ...models.stream_events_v_1_events_get_param_client_id import StreamEventsV1EventsGetParamClientId -from ...models.stream_events_v_1_events_get_param_types import StreamEventsV1EventsGetParamTypes -from ...models.submit_command_v_1_commands_post_request_body import SubmitCommandV1CommandsPostRequestBody - -if TYPE_CHECKING: - from ...endpoints.default import DefaultClientProtocol - -class MockDefaultClient: - """ - Mock implementation of DefaultClient for testing. - - Provides default implementations that raise NotImplementedError. - Override methods as needed in your tests. - - Example: - class TestDefaultClient(MockDefaultClient): - async def method_name(self, ...) -> ReturnType: - return test_data - """ - - async def submit_command_v1_commands_post( - self, - body: SubmitCommandV1CommandsPostRequestBody, - ) -> CommandAccepted: - """ - Mock implementation that raises NotImplementedError. - - Override this method in your test subclass to provide - the behavior needed for your test scenario. - """ - raise NotImplementedError("MockClient_Client.submit_command_v1_commands_post() not implemented. Override this method in your test subclass.") - - - async def stream_events_v1_events_get( - self, - types: StreamEventsV1EventsGetParamTypes | None = None, - client_id: StreamEventsV1EventsGetParamClientId | None = None, - ) -> AsyncIterator[dict[str, Any]]: - """ - Mock implementation that raises NotImplementedError. - - Override this method in your test subclass to provide - the behavior needed for your test scenario. - """ - raise NotImplementedError("MockClient_Client.stream_events_v1_events_get() not implemented. Override this method in your test subclass.") - yield # pragma: no cover \ No newline at end of file diff --git a/sparkmeter/metering/_generated/mocks/mock_client.py b/sparkmeter/metering/_generated/mocks/mock_client.py deleted file mode 100644 index 6d9dec7..0000000 --- a/sparkmeter/metering/_generated/mocks/mock_client.py +++ /dev/null @@ -1,56 +0,0 @@ -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from ..client import APIClientProtocol - from ..endpoints.default import DefaultClientProtocol - -from .endpoints.mock_default import MockDefaultClient - -class MockAPIClient: - """ - Mock implementation of APIClient for testing. - - Auto-creates default mock implementations for all tag-based endpoint clients. - You can override specific tag clients by passing them to the constructor. - - Example: - # Use all defaults - client = MockAPIClient() - - # Override specific tag client - class MyDefaultClientMock(MockDefaultClient): - async def method_name(self, ...) -> ReturnType: - return test_data - - client = MockAPIClient(default=MyDefaultClientMock()) - """ - - def __init__( - self, - default: "DefaultClientProtocol | None" = None, - ) -> None: - self._default = default if default is not None else MockDefaultClient() - - @property - def default(self) -> "DefaultClientProtocol": - return self._default - - async def request(self, method: str, url: str, **kwargs: Any) -> Any: - """ - Mock request method - raises NotImplementedError. - - This is a low-level method - consider using tag-specific methods instead. - """ - raise NotImplementedError("MockAPIClient.request() not implemented. Use tag-specific methods instead.") - - async def close(self) -> None: - """Mock close method - no-op for testing.""" - pass # No cleanup needed for mocks - - async def __aenter__(self) -> "APIClientProtocol": - """Enter async context manager.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None) -> None: - """Exit async context manager - no-op for mocks.""" - pass # No cleanup needed for mocks \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/__init__.py b/sparkmeter/metering/_generated/models/__init__.py deleted file mode 100644 index 66d584f..0000000 --- a/sparkmeter/metering/_generated/models/__init__.py +++ /dev/null @@ -1,321 +0,0 @@ -from typing import List - -from .associate_meter_command import AssociateMeterCommand -from .associate_meter_command_vendor_options import AssociateMeterCommandVendorOptions -from .associate_meter_params import AssociateMeterParams -from .associate_meter_params_set_aes_key import AssociateMeterParamsSetAesKey -from .associate_meter_params_set_channel import AssociateMeterParamsSetChannel -from .associate_meter_params_set_mac import AssociateMeterParamsSetMac -from .cancel_firmware_update_command import CancelFirmwareUpdateCommand -from .cancel_firmware_update_command_vendor_options import CancelFirmwareUpdateCommandVendorOptions -from .cancel_firmware_update_params import CancelFirmwareUpdateParams -from .capabilities_event import CapabilitiesEvent -from .capabilities_event_vendor_capabilities import CapabilitiesEventVendorCapabilities -from .capability_flag import CapabilityFlag -from .command_accepted import CommandAccepted -from .command_accepted_event import CommandAcceptedEvent -from .command_accepted_event_result import CommandAcceptedEventResult -from .command_applied_event import CommandAppliedEvent -from .command_applied_event_result import CommandAppliedEventResult -from .command_cached_event import CommandCachedEvent -from .command_failed_event import CommandFailedEvent -from .command_failed_event_detail import CommandFailedEventDetail -from .command_rejected_event import CommandRejectedEvent -from .command_rejected_event_detail import CommandRejectedEventDetail -from .command_timed_out_event import CommandTimedOutEvent -from .configure_high_capacity_meter_command import ConfigureHighCapacityMeterCommand -from .configure_high_capacity_meter_command_vendor_options import ConfigureHighCapacityMeterCommandVendorOptions -from .configure_high_capacity_meter_params import ConfigureHighCapacityMeterParams -from .configure_meter_command import ConfigureMeterCommand -from .configure_meter_command_vendor_options import ConfigureMeterCommandVendorOptions -from .configure_meter_params import ConfigureMeterParams -from .configure_provider_command import ConfigureProviderCommand -from .configure_provider_command_vendor_options import ConfigureProviderCommandVendorOptions -from .configure_provider_params import ConfigureProviderParams -from .enter_configuration_mode_command import EnterConfigurationModeCommand -from .enter_configuration_mode_command_vendor_options import EnterConfigurationModeCommandVendorOptions -from .enter_configuration_mode_params import EnterConfigurationModeParams -from .firmware_update_session_status import FirmwareUpdateSessionStatus -from .firmware_update_status_event import FirmwareUpdateStatusEvent -from .firmware_update_status_event_failed_meters import FirmwareUpdateStatusEventFailedMeters -from .firmware_update_status_event_progress_percent import FirmwareUpdateStatusEventProgressPercent -from .firmware_version import FirmwareVersion -from .heartbeat_summary_event import HeartbeatSummaryEvent -from .heartbeat_summary_event_read_reply_latency_ms import HeartbeatSummaryEventReadReplyLatencyMs -from .heartbeat_summary_event_set_config_reply_latency_ms import HeartbeatSummaryEventSetConfigReplyLatencyMs -from .high_capacity_meter_configuration import HighCapacityMeterConfiguration -from .http_validation_error import HttpValidationError -from .http_validation_error_2 import HttpValidationError2 -from .log_event import LogEvent -from .log_level import LogLevel -from .meter_behavior_command import MeterBehaviorCommand -from .meter_config_event import MeterConfigEvent -from .meter_config_event_balance import MeterConfigEventBalance -from .meter_config_event_firmware_version import MeterConfigEventFirmwareVersion -from .meter_configuration import MeterConfiguration -from .meter_error_entry import MeterErrorEntry -from .meter_error_entry_description import MeterErrorEntryDescription -from .meter_error_entry_location import MeterErrorEntryLocation -from .meter_error_entry_timestamp_unix_seconds import MeterErrorEntryTimestampUnixSeconds -from .meter_errors_event import MeterErrorsEvent -from .meter_firmware_changed_event import MeterFirmwareChangedEvent -from .meter_instant_reading_event import MeterInstantReadingEvent -from .meter_memory_event import MeterMemoryEvent -from .meter_neighbor import MeterNeighbor -from .meter_neighbor_last_seen_unix_seconds import MeterNeighborLastSeenUnixSeconds -from .meter_neighbor_link_quality import MeterNeighborLinkQuality -from .meter_neighbor_rssi_dbm import MeterNeighborRssiDbm -from .meter_neighbors_event import MeterNeighborsEvent -from .meter_network_statistics import MeterNetworkStatistics -from .meter_network_statistics_read_reply_latency_ms import MeterNetworkStatisticsReadReplyLatencyMs -from .meter_network_statistics_set_config_reply_latency_ms import MeterNetworkStatisticsSetConfigReplyLatencyMs -from .meter_reading_event import MeterReadingEvent -from .meter_reading_phased_event import MeterReadingPhasedEvent -from .meter_reading_phased_event_per_phase import MeterReadingPhasedEventPerPhase -from .meter_register_event import MeterRegisterEvent -from .meter_state import MeterState -from .meter_version_event import MeterVersionEvent -from .meter_version_event_bootloader_version import MeterVersionEventBootloaderVersion -from .network_health_event import NetworkHealthEvent -from .phase import Phase -from .phase_reading import PhaseReading -from .ping_meter_command import PingMeterCommand -from .ping_meter_command_vendor_options import PingMeterCommandVendorOptions -from .ping_meter_params import PingMeterParams -from .provider_status_event import ProviderStatusEvent -from .query_capabilities_command import QueryCapabilitiesCommand -from .query_capabilities_command_vendor_options import QueryCapabilitiesCommandVendorOptions -from .query_capabilities_params import QueryCapabilitiesParams -from .query_firmware_update_status_command import QueryFirmwareUpdateStatusCommand -from .query_firmware_update_status_command_vendor_options import QueryFirmwareUpdateStatusCommandVendorOptions -from .query_firmware_update_status_params import QueryFirmwareUpdateStatusParams -from .query_firmware_update_status_params_session_id import QueryFirmwareUpdateStatusParamsSessionId -from .query_meter_config_command import QueryMeterConfigCommand -from .query_meter_config_command_vendor_options import QueryMeterConfigCommandVendorOptions -from .query_meter_config_params import QueryMeterConfigParams -from .query_meter_errors_command import QueryMeterErrorsCommand -from .query_meter_errors_command_vendor_options import QueryMeterErrorsCommandVendorOptions -from .query_meter_errors_params import QueryMeterErrorsParams -from .query_meter_neighbors_command import QueryMeterNeighborsCommand -from .query_meter_neighbors_command_vendor_options import QueryMeterNeighborsCommandVendorOptions -from .query_meter_neighbors_params import QueryMeterNeighborsParams -from .query_meter_version_command import QueryMeterVersionCommand -from .query_meter_version_command_vendor_options import QueryMeterVersionCommandVendorOptions -from .query_meter_version_params import QueryMeterVersionParams -from .query_network_health_command import QueryNetworkHealthCommand -from .query_network_health_command_vendor_options import QueryNetworkHealthCommandVendorOptions -from .query_network_health_params import QueryNetworkHealthParams -from .query_network_health_params_meter_id import QueryNetworkHealthParamsMeterId -from .query_provider_status_command import QueryProviderStatusCommand -from .query_provider_status_command_vendor_options import QueryProviderStatusCommandVendorOptions -from .query_provider_status_params import QueryProviderStatusParams -from .read_meter_memory_command import ReadMeterMemoryCommand -from .read_meter_memory_command_vendor_options import ReadMeterMemoryCommandVendorOptions -from .read_meter_memory_params import ReadMeterMemoryParams -from .read_meter_now_command import ReadMeterNowCommand -from .read_meter_now_command_vendor_options import ReadMeterNowCommandVendorOptions -from .read_meter_now_params import ReadMeterNowParams -from .read_meter_register_command import ReadMeterRegisterCommand -from .read_meter_register_command_vendor_options import ReadMeterRegisterCommandVendorOptions -from .read_meter_register_params import ReadMeterRegisterParams -from .register_meter_command import RegisterMeterCommand -from .register_meter_command_vendor_options import RegisterMeterCommandVendorOptions -from .register_meter_params import RegisterMeterParams -from .register_meter_params_firmware_version import RegisterMeterParamsFirmwareVersion -from .register_meter_params_initial_balance import RegisterMeterParamsInitialBalance -from .reset_meter_errors_command import ResetMeterErrorsCommand -from .reset_meter_errors_command_vendor_options import ResetMeterErrorsCommandVendorOptions -from .reset_meter_errors_params import ResetMeterErrorsParams -from .rf_test_command import RfTestCommand -from .rf_test_command_vendor_options import RfTestCommandVendorOptions -from .rf_test_params import RfTestParams -from .rf_test_result_event import RfTestResultEvent -from .rf_test_result_event_rssi_dbm import RfTestResultEventRssiDbm -from .set_balance_command import SetBalanceCommand -from .set_balance_command_vendor_options import SetBalanceCommandVendorOptions -from .set_balance_params import SetBalanceParams -from .set_balance_params_balance import SetBalanceParamsBalance -from .start_firmware_update_command import StartFirmwareUpdateCommand -from .start_firmware_update_command_vendor_options import StartFirmwareUpdateCommandVendorOptions -from .start_firmware_update_params import StartFirmwareUpdateParams -from .start_firmware_update_params_target_meters import StartFirmwareUpdateParamsTargetMeters -from .statistics_histogram import StatisticsHistogram -from .stream_events_v_1_events_get_200_response import StreamEventsV1EventsGet200Response -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum -from .stream_events_v_1_events_get_param_client_id import StreamEventsV1EventsGetParamClientId -from .stream_events_v_1_events_get_param_types import StreamEventsV1EventsGetParamTypes -from .submit_command_v_1_commands_post_request_body import SubmitCommandV1CommandsPostRequestBody -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum -from .throttle_config import ThrottleConfig -from .unregister_meter_command import UnregisterMeterCommand -from .unregister_meter_command_vendor_options import UnregisterMeterCommandVendorOptions -from .unregister_meter_params import UnregisterMeterParams -from .validation_error import ValidationError -from .validation_error_ctx import ValidationErrorCtx -from .validation_error_loc import ValidationErrorLoc -from .validation_error_loc_item import ValidationErrorLocItem -from .write_meter_register_command import WriteMeterRegisterCommand -from .write_meter_register_command_vendor_options import WriteMeterRegisterCommandVendorOptions -from .write_meter_register_params import WriteMeterRegisterParams -from .input_ import Input_ - -__all__: List[str] = [ - 'AssociateMeterCommand', - 'AssociateMeterCommandVendorOptions', - 'AssociateMeterParams', - 'AssociateMeterParamsSetAesKey', - 'AssociateMeterParamsSetChannel', - 'AssociateMeterParamsSetMac', - 'CancelFirmwareUpdateCommand', - 'CancelFirmwareUpdateCommandVendorOptions', - 'CancelFirmwareUpdateParams', - 'CapabilitiesEvent', - 'CapabilitiesEventVendorCapabilities', - 'CapabilityFlag', - 'CommandAccepted', - 'CommandAcceptedEvent', - 'CommandAcceptedEventResult', - 'CommandAppliedEvent', - 'CommandAppliedEventResult', - 'CommandCachedEvent', - 'CommandFailedEvent', - 'CommandFailedEventDetail', - 'CommandRejectedEvent', - 'CommandRejectedEventDetail', - 'CommandTimedOutEvent', - 'ConfigureHighCapacityMeterCommand', - 'ConfigureHighCapacityMeterCommandVendorOptions', - 'ConfigureHighCapacityMeterParams', - 'ConfigureMeterCommand', - 'ConfigureMeterCommandVendorOptions', - 'ConfigureMeterParams', - 'ConfigureProviderCommand', - 'ConfigureProviderCommandVendorOptions', - 'ConfigureProviderParams', - 'EnterConfigurationModeCommand', - 'EnterConfigurationModeCommandVendorOptions', - 'EnterConfigurationModeParams', - 'FirmwareUpdateSessionStatus', - 'FirmwareUpdateStatusEvent', - 'FirmwareUpdateStatusEventFailedMeters', - 'FirmwareUpdateStatusEventProgressPercent', - 'FirmwareVersion', - 'HeartbeatSummaryEvent', - 'HeartbeatSummaryEventReadReplyLatencyMs', - 'HeartbeatSummaryEventSetConfigReplyLatencyMs', - 'HighCapacityMeterConfiguration', - 'HttpValidationError', - 'HttpValidationError2', - 'Input_', - 'LogEvent', - 'LogLevel', - 'MeterBehaviorCommand', - 'MeterConfigEvent', - 'MeterConfigEventBalance', - 'MeterConfigEventFirmwareVersion', - 'MeterConfiguration', - 'MeterErrorEntry', - 'MeterErrorEntryDescription', - 'MeterErrorEntryLocation', - 'MeterErrorEntryTimestampUnixSeconds', - 'MeterErrorsEvent', - 'MeterFirmwareChangedEvent', - 'MeterInstantReadingEvent', - 'MeterMemoryEvent', - 'MeterNeighbor', - 'MeterNeighborLastSeenUnixSeconds', - 'MeterNeighborLinkQuality', - 'MeterNeighborRssiDbm', - 'MeterNeighborsEvent', - 'MeterNetworkStatistics', - 'MeterNetworkStatisticsReadReplyLatencyMs', - 'MeterNetworkStatisticsSetConfigReplyLatencyMs', - 'MeterReadingEvent', - 'MeterReadingPhasedEvent', - 'MeterReadingPhasedEventPerPhase', - 'MeterRegisterEvent', - 'MeterState', - 'MeterVersionEvent', - 'MeterVersionEventBootloaderVersion', - 'NetworkHealthEvent', - 'Phase', - 'PhaseReading', - 'PingMeterCommand', - 'PingMeterCommandVendorOptions', - 'PingMeterParams', - 'ProviderStatusEvent', - 'QueryCapabilitiesCommand', - 'QueryCapabilitiesCommandVendorOptions', - 'QueryCapabilitiesParams', - 'QueryFirmwareUpdateStatusCommand', - 'QueryFirmwareUpdateStatusCommandVendorOptions', - 'QueryFirmwareUpdateStatusParams', - 'QueryFirmwareUpdateStatusParamsSessionId', - 'QueryMeterConfigCommand', - 'QueryMeterConfigCommandVendorOptions', - 'QueryMeterConfigParams', - 'QueryMeterErrorsCommand', - 'QueryMeterErrorsCommandVendorOptions', - 'QueryMeterErrorsParams', - 'QueryMeterNeighborsCommand', - 'QueryMeterNeighborsCommandVendorOptions', - 'QueryMeterNeighborsParams', - 'QueryMeterVersionCommand', - 'QueryMeterVersionCommandVendorOptions', - 'QueryMeterVersionParams', - 'QueryNetworkHealthCommand', - 'QueryNetworkHealthCommandVendorOptions', - 'QueryNetworkHealthParams', - 'QueryNetworkHealthParamsMeterId', - 'QueryProviderStatusCommand', - 'QueryProviderStatusCommandVendorOptions', - 'QueryProviderStatusParams', - 'ReadMeterMemoryCommand', - 'ReadMeterMemoryCommandVendorOptions', - 'ReadMeterMemoryParams', - 'ReadMeterNowCommand', - 'ReadMeterNowCommandVendorOptions', - 'ReadMeterNowParams', - 'ReadMeterRegisterCommand', - 'ReadMeterRegisterCommandVendorOptions', - 'ReadMeterRegisterParams', - 'RegisterMeterCommand', - 'RegisterMeterCommandVendorOptions', - 'RegisterMeterParams', - 'RegisterMeterParamsFirmwareVersion', - 'RegisterMeterParamsInitialBalance', - 'ResetMeterErrorsCommand', - 'ResetMeterErrorsCommandVendorOptions', - 'ResetMeterErrorsParams', - 'RfTestCommand', - 'RfTestCommandVendorOptions', - 'RfTestParams', - 'RfTestResultEvent', - 'RfTestResultEventRssiDbm', - 'SetBalanceCommand', - 'SetBalanceCommandVendorOptions', - 'SetBalanceParams', - 'SetBalanceParamsBalance', - 'StartFirmwareUpdateCommand', - 'StartFirmwareUpdateCommandVendorOptions', - 'StartFirmwareUpdateParams', - 'StartFirmwareUpdateParamsTargetMeters', - 'StatisticsHistogram', - 'StreamEventsV1EventsGet200Response', - 'StreamEventsV1EventsGet200ResponseEventTypeEnum', - 'StreamEventsV1EventsGetParamClientId', - 'StreamEventsV1EventsGetParamTypes', - 'SubmitCommandV1CommandsPostRequestBody', - 'SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum', - 'ThrottleConfig', - 'UnregisterMeterCommand', - 'UnregisterMeterCommandVendorOptions', - 'UnregisterMeterParams', - 'ValidationError', - 'ValidationErrorCtx', - 'ValidationErrorLoc', - 'ValidationErrorLocItem', - 'WriteMeterRegisterCommand', - 'WriteMeterRegisterCommandVendorOptions', - 'WriteMeterRegisterParams', -] \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/associate_meter_command.py b/sparkmeter/metering/_generated/models/associate_meter_command.py deleted file mode 100644 index b8a12ec..0000000 --- a/sparkmeter/metering/_generated/models/associate_meter_command.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .associate_meter_command_vendor_options import AssociateMeterCommandVendorOptions -from .associate_meter_params import AssociateMeterParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["AssociateMeterCommand"] - -@dataclass -class AssociateMeterCommand: - """ - Onboard a physical meter onto the network. Reply contract: **two-stage**. - Acknowledgement comes back via `command_applied` once the meter has accepted the new - identity. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (AssociateMeterParams) - : Onboard a physical meter onto the network. Distinct from - `register_meter`, which adds a meter to the provider's - roster. `associate_meter` is the over-the-air operation - that gives a previously-unprovisioned meter its network - identity (MAC, channel, AES key). All three optional - fields target the meter's persistent network - configuration. Provide whichever the operator wants to - set. - vendor_options (AssociateMeterCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: AssociateMeterParams # Onboard a physical meter onto the network. Distinct from `register_meter`, which adds a meter to the provider's roster. `associate_meter` is the over-the-air operation that gives a previously-unprovisioned meter its network identity (MAC, channel, AES key). All three optional fields target the meter's persistent network configuration. Provide whichever the operator wants to set. - vendor_options: AssociateMeterCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/associate_meter_command_vendor_options.py b/sparkmeter/metering/_generated/models/associate_meter_command_vendor_options.py deleted file mode 100644 index 3fe3fcf..0000000 --- a/sparkmeter/metering/_generated/models/associate_meter_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["AssociateMeterCommandVendorOptions"] - -@dataclass -class AssociateMeterCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, AssociateMeterCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for AssociateMeterCommandVendorOptions -def _structure_associatemetercommandvendoroptions(data: dict[str, Any], _: type[AssociateMeterCommandVendorOptions]) -> AssociateMeterCommandVendorOptions: - """Structure hook for cattrs to handle AssociateMeterCommandVendorOptions deserialization.""" - if data is None: - return AssociateMeterCommandVendorOptions() - if isinstance(data, AssociateMeterCommandVendorOptions): - return data - return AssociateMeterCommandVendorOptions(_data=data) - - -def _unstructure_associatemetercommandvendoroptions(instance: AssociateMeterCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle AssociateMeterCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(AssociateMeterCommandVendorOptions, _structure_associatemetercommandvendoroptions) -converter.register_unstructure_hook(AssociateMeterCommandVendorOptions, _unstructure_associatemetercommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/associate_meter_params.py b/sparkmeter/metering/_generated/models/associate_meter_params.py deleted file mode 100644 index f06f5a2..0000000 --- a/sparkmeter/metering/_generated/models/associate_meter_params.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .associate_meter_params_set_aes_key import AssociateMeterParamsSetAesKey -from .associate_meter_params_set_channel import AssociateMeterParamsSetChannel -from .associate_meter_params_set_mac import AssociateMeterParamsSetMac - -__all__ = ["AssociateMeterParams"] - -@dataclass -class AssociateMeterParams: - """ - Onboard a physical meter onto the network. Distinct from `register_meter`, which adds a - meter to the provider's roster. `associate_meter` is the over-the-air operation that - gives a previously-unprovisioned meter its network identity (MAC, channel, AES key). - All three optional fields target the meter's persistent network configuration. Provide - whichever the operator wants to set. - - Args: - meter_id (str) : - set_aes_key (AssociateMeterParamsSetAesKey | None) - : Raw 16-byte network key. - set_channel (AssociateMeterParamsSetChannel | None) - : - set_mac (AssociateMeterParamsSetMac | None) - : - """ - meter_id: str - set_aes_key: AssociateMeterParamsSetAesKey | None = None # Raw 16-byte network key. - set_channel: AssociateMeterParamsSetChannel | None = None - set_mac: AssociateMeterParamsSetMac | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - "set_aes_key": "set_aes_key", - "set_channel": "set_channel", - "set_mac": "set_mac", - } - key_transform_with_dump = { - "meter_id": "meter_id", - "set_aes_key": "set_aes_key", - "set_channel": "set_channel", - "set_mac": "set_mac", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/associate_meter_params_set_aes_key.py b/sparkmeter/metering/_generated/models/associate_meter_params_set_aes_key.py deleted file mode 100644 index 7c24333..0000000 --- a/sparkmeter/metering/_generated/models/associate_meter_params_set_aes_key.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['AssociateMeterParamsSetAesKey'] - -AssociateMeterParamsSetAesKey: TypeAlias = str | None -"""Alias for Raw 16-byte network key.""" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/associate_meter_params_set_channel.py b/sparkmeter/metering/_generated/models/associate_meter_params_set_channel.py deleted file mode 100644 index 07cae0e..0000000 --- a/sparkmeter/metering/_generated/models/associate_meter_params_set_channel.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['AssociateMeterParamsSetChannel'] - -AssociateMeterParamsSetChannel: TypeAlias = int | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/associate_meter_params_set_mac.py b/sparkmeter/metering/_generated/models/associate_meter_params_set_mac.py deleted file mode 100644 index 917ffa1..0000000 --- a/sparkmeter/metering/_generated/models/associate_meter_params_set_mac.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['AssociateMeterParamsSetMac'] - -AssociateMeterParamsSetMac: TypeAlias = int | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/cancel_firmware_update_command.py b/sparkmeter/metering/_generated/models/cancel_firmware_update_command.py deleted file mode 100644 index c5bd47b..0000000 --- a/sparkmeter/metering/_generated/models/cancel_firmware_update_command.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .cancel_firmware_update_command_vendor_options import CancelFirmwareUpdateCommandVendorOptions -from .cancel_firmware_update_params import CancelFirmwareUpdateParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["CancelFirmwareUpdateCommand"] - -@dataclass -class CancelFirmwareUpdateCommand: - """ - Cancel an in-progress firmware-update session. Reply contract: **two-stage**. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (CancelFirmwareUpdateParams) - : - vendor_options (CancelFirmwareUpdateCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: CancelFirmwareUpdateParams - vendor_options: CancelFirmwareUpdateCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/cancel_firmware_update_command_vendor_options.py b/sparkmeter/metering/_generated/models/cancel_firmware_update_command_vendor_options.py deleted file mode 100644 index 092c600..0000000 --- a/sparkmeter/metering/_generated/models/cancel_firmware_update_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["CancelFirmwareUpdateCommandVendorOptions"] - -@dataclass -class CancelFirmwareUpdateCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, CancelFirmwareUpdateCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for CancelFirmwareUpdateCommandVendorOptions -def _structure_cancelfirmwareupdatecommandvendoroptions(data: dict[str, Any], _: type[CancelFirmwareUpdateCommandVendorOptions]) -> CancelFirmwareUpdateCommandVendorOptions: - """Structure hook for cattrs to handle CancelFirmwareUpdateCommandVendorOptions deserialization.""" - if data is None: - return CancelFirmwareUpdateCommandVendorOptions() - if isinstance(data, CancelFirmwareUpdateCommandVendorOptions): - return data - return CancelFirmwareUpdateCommandVendorOptions(_data=data) - - -def _unstructure_cancelfirmwareupdatecommandvendoroptions(instance: CancelFirmwareUpdateCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle CancelFirmwareUpdateCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(CancelFirmwareUpdateCommandVendorOptions, _structure_cancelfirmwareupdatecommandvendoroptions) -converter.register_unstructure_hook(CancelFirmwareUpdateCommandVendorOptions, _unstructure_cancelfirmwareupdatecommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/cancel_firmware_update_params.py b/sparkmeter/metering/_generated/models/cancel_firmware_update_params.py deleted file mode 100644 index cc83670..0000000 --- a/sparkmeter/metering/_generated/models/cancel_firmware_update_params.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["CancelFirmwareUpdateParams"] - -@dataclass -class CancelFirmwareUpdateParams: - """ - CancelFirmwareUpdateParams dataclass - - Args: - session_id (str) : - """ - session_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "session_id": "session_id", - } - key_transform_with_dump = { - "session_id": "session_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/capabilities_event.py b/sparkmeter/metering/_generated/models/capabilities_event.py deleted file mode 100644 index 9928f6b..0000000 --- a/sparkmeter/metering/_generated/models/capabilities_event.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import List - -from .capabilities_event_vendor_capabilities import CapabilitiesEventVendorCapabilities -from .capability_flag import CapabilityFlag -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["CapabilitiesEvent"] - -@dataclass -class CapabilitiesEvent: - """ - Reply to `query_capabilities`. `commands` lists which `command_type` values the - provider supports; `features` lists optional feature flags. Vendor-specific capabilities - are returned as opaque strings under `vendor_capabilities`. - - Args: - commands (List[str]) : - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - features (List[CapabilityFlag]) - : - meter_types (List[str] | None) - : Vendor-recognised `meter_type` values for - `register_meter`. - vendor_capabilities (CapabilitiesEventVendorCapabilities | None) - : - """ - commands: List[str] - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - features: List[CapabilityFlag] - meter_types: List[str] | None = field(default_factory=list) # Vendor-recognised `meter_type` values for `register_meter`. - vendor_capabilities: CapabilitiesEventVendorCapabilities | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "commands": "commands", - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "features": "features", - "meter_types": "meter_types", - "vendor_capabilities": "vendor_capabilities", - } - key_transform_with_dump = { - "commands": "commands", - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "features": "features", - "meter_types": "meter_types", - "vendor_capabilities": "vendor_capabilities", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/capabilities_event_vendor_capabilities.py b/sparkmeter/metering/_generated/models/capabilities_event_vendor_capabilities.py deleted file mode 100644 index 8d381dc..0000000 --- a/sparkmeter/metering/_generated/models/capabilities_event_vendor_capabilities.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["CapabilitiesEventVendorCapabilities"] - -@dataclass -class CapabilitiesEventVendorCapabilities: - """ - Generic JSON value object that preserves arbitrary data. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, CapabilitiesEventVendorCapabilities) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for CapabilitiesEventVendorCapabilities -def _structure_capabilitieseventvendorcapabilities(data: dict[str, Any], _: type[CapabilitiesEventVendorCapabilities]) -> CapabilitiesEventVendorCapabilities: - """Structure hook for cattrs to handle CapabilitiesEventVendorCapabilities deserialization.""" - if data is None: - return CapabilitiesEventVendorCapabilities() - if isinstance(data, CapabilitiesEventVendorCapabilities): - return data - return CapabilitiesEventVendorCapabilities(_data=data) - - -def _unstructure_capabilitieseventvendorcapabilities(instance: CapabilitiesEventVendorCapabilities) -> dict[str, Any]: - """Unstructure hook for cattrs to handle CapabilitiesEventVendorCapabilities serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(CapabilitiesEventVendorCapabilities, _structure_capabilitieseventvendorcapabilities) -converter.register_unstructure_hook(CapabilitiesEventVendorCapabilities, _unstructure_capabilitieseventvendorcapabilities) diff --git a/sparkmeter/metering/_generated/models/capability_flag.py b/sparkmeter/metering/_generated/models/capability_flag.py deleted file mode 100644 index 4e393a2..0000000 --- a/sparkmeter/metering/_generated/models/capability_flag.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -from enum import Enum, unique - -__all__ = ["CapabilityFlag"] - -@unique -class CapabilityFlag(str, Enum): - """ - Optional feature flags providers may declare. - - Args: - phased_readings (str) : Value for PHASED_READINGS - balance_push (str) : Value for BALANCE_PUSH - network_health (str) : Value for NETWORK_HEALTH - behavior_reboot (str) : Value for BEHAVIOR_REBOOT - behavior_calibrate (str) : Value for BEHAVIOR_CALIBRATE - behavior_unprovisioned (str) - : Value for BEHAVIOR_UNPROVISIONED - ping_meter (str) : Value for PING_METER - meter_neighbors (str) : Value for METER_NEIGHBORS - meter_config_query (str) : Value for METER_CONFIG_QUERY - meter_version_query (str): Value for METER_VERSION_QUERY - instant_reading (str) : Value for INSTANT_READING - meter_errors (str) : Value for METER_ERRORS - associate (str) : Value for ASSOCIATE - high_capacity_meter_config (str) - : Value for HIGH_CAPACITY_METER_CONFIG - firmware_update (str) : Value for FIRMWARE_UPDATE - configuration_mode (str) : Value for CONFIGURATION_MODE - rf_test (str) : Value for RF_TEST - meter_memory (str) : Value for METER_MEMORY - meter_registers (str) : Value for METER_REGISTERS - """ - PHASED_READINGS = "phased_readings" - BALANCE_PUSH = "balance_push" - NETWORK_HEALTH = "network_health" - BEHAVIOR_REBOOT = "behavior_reboot" - BEHAVIOR_CALIBRATE = "behavior_calibrate" - BEHAVIOR_UNPROVISIONED = "behavior_unprovisioned" - PING_METER = "ping_meter" - METER_NEIGHBORS = "meter_neighbors" - METER_CONFIG_QUERY = "meter_config_query" - METER_VERSION_QUERY = "meter_version_query" - INSTANT_READING = "instant_reading" - METER_ERRORS = "meter_errors" - ASSOCIATE = "associate" - HIGH_CAPACITY_METER_CONFIG = "high_capacity_meter_config" - FIRMWARE_UPDATE = "firmware_update" - CONFIGURATION_MODE = "configuration_mode" - RF_TEST = "rf_test" - METER_MEMORY = "meter_memory" - METER_REGISTERS = "meter_registers" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_accepted.py b/sparkmeter/metering/_generated/models/command_accepted.py deleted file mode 100644 index 612c2a5..0000000 --- a/sparkmeter/metering/_generated/models/command_accepted.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["CommandAccepted"] - -@dataclass -class CommandAccepted: - """ - HTTP-level acknowledgement that the request was received and validated. This is NOT the - command's terminal reply — that comes via SSE, keyed by `correlation_id`. The HTTP `202` - simply confirms the command was syntactically valid and queued for processing. - - Args: - correlation_id (str) : - queued (bool | None) : - """ - correlation_id: str - queued: bool | None = True - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "queued": "queued", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "queued": "queued", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_accepted_event.py b/sparkmeter/metering/_generated/models/command_accepted_event.py deleted file mode 100644 index c3966fb..0000000 --- a/sparkmeter/metering/_generated/models/command_accepted_event.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .command_accepted_event_result import CommandAcceptedEventResult -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["CommandAcceptedEvent"] - -@dataclass -class CommandAcceptedEvent: - """ - Provider has accepted the command for processing. For single-reply commands this is the - terminal event. For two-stage commands, expect a later - `command_applied`/`command_failed`/ `command_timed_out` with the same `correlation_id`. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - result (CommandAcceptedEventResult | None) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - result: CommandAcceptedEventResult | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "result": "result", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "result": "result", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_accepted_event_result.py b/sparkmeter/metering/_generated/models/command_accepted_event_result.py deleted file mode 100644 index b94e852..0000000 --- a/sparkmeter/metering/_generated/models/command_accepted_event_result.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, TypeAlias - -__all__ = ['CommandAcceptedEventResult'] - -CommandAcceptedEventResult: TypeAlias = dict[str, Any] | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_applied_event.py b/sparkmeter/metering/_generated/models/command_applied_event.py deleted file mode 100644 index 7ae98bc..0000000 --- a/sparkmeter/metering/_generated/models/command_applied_event.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .command_applied_event_result import CommandAppliedEventResult -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["CommandAppliedEvent"] - -@dataclass -class CommandAppliedEvent: - """ - Two-stage commands only: the meter has acknowledged the change. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - result (CommandAppliedEventResult | None) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - result: CommandAppliedEventResult | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "result": "result", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "result": "result", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_applied_event_result.py b/sparkmeter/metering/_generated/models/command_applied_event_result.py deleted file mode 100644 index 04fa0b7..0000000 --- a/sparkmeter/metering/_generated/models/command_applied_event_result.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, TypeAlias - -__all__ = ['CommandAppliedEventResult'] - -CommandAppliedEventResult: TypeAlias = dict[str, Any] | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_cached_event.py b/sparkmeter/metering/_generated/models/command_cached_event.py deleted file mode 100644 index 43807ee..0000000 --- a/sparkmeter/metering/_generated/models/command_cached_event.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["CommandCachedEvent"] - -@dataclass -class CommandCachedEvent: - """ - Cache-reply commands only: provider stored the value. Delivery to the underlying meter - is deferred and unobservable through this event. To verify, query the meter directly. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_failed_event.py b/sparkmeter/metering/_generated/models/command_failed_event.py deleted file mode 100644 index 5e440c6..0000000 --- a/sparkmeter/metering/_generated/models/command_failed_event.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .command_failed_event_detail import CommandFailedEventDetail -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["CommandFailedEvent"] - -@dataclass -class CommandFailedEvent: - """ - Provider attempted the command and could not complete it. Distinct from - `command_rejected` (which is pre-attempt validation). - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - reason (str) : - detail (CommandFailedEventDetail | None) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - reason: str - detail: CommandFailedEventDetail | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "detail": "detail", - "event_id": "event_id", - "event_type": "event_type", - "reason": "reason", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "detail": "detail", - "event_id": "event_id", - "event_type": "event_type", - "reason": "reason", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_failed_event_detail.py b/sparkmeter/metering/_generated/models/command_failed_event_detail.py deleted file mode 100644 index f5111f8..0000000 --- a/sparkmeter/metering/_generated/models/command_failed_event_detail.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, TypeAlias - -__all__ = ['CommandFailedEventDetail'] - -CommandFailedEventDetail: TypeAlias = dict[str, Any] | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_rejected_event.py b/sparkmeter/metering/_generated/models/command_rejected_event.py deleted file mode 100644 index 06d9fbe..0000000 --- a/sparkmeter/metering/_generated/models/command_rejected_event.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .command_rejected_event_detail import CommandRejectedEventDetail -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["CommandRejectedEvent"] - -@dataclass -class CommandRejectedEvent: - """ - Provider validated the command and refused it (no work attempted). - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - reason (str) : - detail (CommandRejectedEventDetail | None) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - reason: str - detail: CommandRejectedEventDetail | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "detail": "detail", - "event_id": "event_id", - "event_type": "event_type", - "reason": "reason", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "detail": "detail", - "event_id": "event_id", - "event_type": "event_type", - "reason": "reason", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_rejected_event_detail.py b/sparkmeter/metering/_generated/models/command_rejected_event_detail.py deleted file mode 100644 index 5a8ac24..0000000 --- a/sparkmeter/metering/_generated/models/command_rejected_event_detail.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, TypeAlias - -__all__ = ['CommandRejectedEventDetail'] - -CommandRejectedEventDetail: TypeAlias = dict[str, Any] | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/command_timed_out_event.py b/sparkmeter/metering/_generated/models/command_timed_out_event.py deleted file mode 100644 index b5afc77..0000000 --- a/sparkmeter/metering/_generated/models/command_timed_out_event.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["CommandTimedOutEvent"] - -@dataclass -class CommandTimedOutEvent: - """ - Two-stage command: no `applied` arrived within the deadline. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/configure_high_capacity_meter_command.py b/sparkmeter/metering/_generated/models/configure_high_capacity_meter_command.py deleted file mode 100644 index 2e957bd..0000000 --- a/sparkmeter/metering/_generated/models/configure_high_capacity_meter_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .configure_high_capacity_meter_command_vendor_options import ConfigureHighCapacityMeterCommandVendorOptions -from .configure_high_capacity_meter_params import ConfigureHighCapacityMeterParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["ConfigureHighCapacityMeterCommand"] - -@dataclass -class ConfigureHighCapacityMeterCommand: - """ - Configure CT ratio and related parameters on a high-capacity meter. Reply contract: - **two-stage** (sync `command_accepted`/`command_rejected`, eventual `command_applied`). - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (ConfigureHighCapacityMeterParams) - : - vendor_options (ConfigureHighCapacityMeterCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: ConfigureHighCapacityMeterParams - vendor_options: ConfigureHighCapacityMeterCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/configure_high_capacity_meter_command_vendor_options.py b/sparkmeter/metering/_generated/models/configure_high_capacity_meter_command_vendor_options.py deleted file mode 100644 index 94f675f..0000000 --- a/sparkmeter/metering/_generated/models/configure_high_capacity_meter_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["ConfigureHighCapacityMeterCommandVendorOptions"] - -@dataclass -class ConfigureHighCapacityMeterCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, ConfigureHighCapacityMeterCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for ConfigureHighCapacityMeterCommandVendorOptions -def _structure_configurehighcapacitymetercommandvendoroptions(data: dict[str, Any], _: type[ConfigureHighCapacityMeterCommandVendorOptions]) -> ConfigureHighCapacityMeterCommandVendorOptions: - """Structure hook for cattrs to handle ConfigureHighCapacityMeterCommandVendorOptions deserialization.""" - if data is None: - return ConfigureHighCapacityMeterCommandVendorOptions() - if isinstance(data, ConfigureHighCapacityMeterCommandVendorOptions): - return data - return ConfigureHighCapacityMeterCommandVendorOptions(_data=data) - - -def _unstructure_configurehighcapacitymetercommandvendoroptions(instance: ConfigureHighCapacityMeterCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle ConfigureHighCapacityMeterCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(ConfigureHighCapacityMeterCommandVendorOptions, _structure_configurehighcapacitymetercommandvendoroptions) -converter.register_unstructure_hook(ConfigureHighCapacityMeterCommandVendorOptions, _unstructure_configurehighcapacitymetercommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/configure_high_capacity_meter_params.py b/sparkmeter/metering/_generated/models/configure_high_capacity_meter_params.py deleted file mode 100644 index 9a480ad..0000000 --- a/sparkmeter/metering/_generated/models/configure_high_capacity_meter_params.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .high_capacity_meter_configuration import HighCapacityMeterConfiguration - -__all__ = ["ConfigureHighCapacityMeterParams"] - -@dataclass -class ConfigureHighCapacityMeterParams: - """ - ConfigureHighCapacityMeterParams dataclass - - Args: - configuration (HighCapacityMeterConfiguration) - : Settings specific to instrument-transformer-fed (high- - capacity) meters. - meter_id (str) : - """ - configuration: HighCapacityMeterConfiguration # Settings specific to instrument-transformer-fed (high-capacity) meters. - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "configuration": "configuration", - "meter_id": "meter_id", - } - key_transform_with_dump = { - "configuration": "configuration", - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/configure_meter_command.py b/sparkmeter/metering/_generated/models/configure_meter_command.py deleted file mode 100644 index 6d6ba32..0000000 --- a/sparkmeter/metering/_generated/models/configure_meter_command.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .configure_meter_command_vendor_options import ConfigureMeterCommandVendorOptions -from .configure_meter_params import ConfigureMeterParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["ConfigureMeterCommand"] - -@dataclass -class ConfigureMeterCommand: - """ - Apply a configuration and/or behaviour verb to a meter. Reply contract: **two-stage**. - Synchronous: `command_accepted` (queued for delivery) or `command_rejected` (validation - failed at the provider). Eventual: `command_applied` (meter acknowledged), - `command_failed` (provider gave up), or `command_timed_out`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (ConfigureMeterParams) - : Configure a meter's behaviour and optionally send a one- - shot verb. `behavior` is one of `MeterBehaviorCommand`; - pass `none` to apply only the `configuration` without - sending a verb. The `configuration` fields are required - even when sending only a verb because most vendors round- - trip the full config on every set-config message. - vendor_options (ConfigureMeterCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: ConfigureMeterParams # Configure a meter's behaviour and optionally send a one-shot verb. `behavior` is one of `MeterBehaviorCommand`; pass `none` to apply only the `configuration` without sending a verb. The `configuration` fields are required even when sending only a verb because most vendors round-trip the full config on every set-config message. - vendor_options: ConfigureMeterCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/configure_meter_command_vendor_options.py b/sparkmeter/metering/_generated/models/configure_meter_command_vendor_options.py deleted file mode 100644 index aeeb3d1..0000000 --- a/sparkmeter/metering/_generated/models/configure_meter_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["ConfigureMeterCommandVendorOptions"] - -@dataclass -class ConfigureMeterCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, ConfigureMeterCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for ConfigureMeterCommandVendorOptions -def _structure_configuremetercommandvendoroptions(data: dict[str, Any], _: type[ConfigureMeterCommandVendorOptions]) -> ConfigureMeterCommandVendorOptions: - """Structure hook for cattrs to handle ConfigureMeterCommandVendorOptions deserialization.""" - if data is None: - return ConfigureMeterCommandVendorOptions() - if isinstance(data, ConfigureMeterCommandVendorOptions): - return data - return ConfigureMeterCommandVendorOptions(_data=data) - - -def _unstructure_configuremetercommandvendoroptions(instance: ConfigureMeterCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle ConfigureMeterCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(ConfigureMeterCommandVendorOptions, _structure_configuremetercommandvendoroptions) -converter.register_unstructure_hook(ConfigureMeterCommandVendorOptions, _unstructure_configuremetercommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/configure_meter_params.py b/sparkmeter/metering/_generated/models/configure_meter_params.py deleted file mode 100644 index 3e5cbb7..0000000 --- a/sparkmeter/metering/_generated/models/configure_meter_params.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .meter_behavior_command import MeterBehaviorCommand -from .meter_configuration import MeterConfiguration - -__all__ = ["ConfigureMeterParams"] - -@dataclass -class ConfigureMeterParams: - """ - Configure a meter's behaviour and optionally send a one-shot verb. `behavior` is one of - `MeterBehaviorCommand`; pass `none` to apply only the `configuration` without sending a - verb. The `configuration` fields are required even when sending only a verb because most - vendors round-trip the full config on every set-config message. - - Args: - configuration (MeterConfiguration) - : Limits and behaviour applied to a single meter. - meter_id (str) : - behavior (MeterBehaviorCommand | None) - : One-shot verb to send to a meter as part of - `configure_meter`. Not every vendor supports every verb; - check capabilities first. Calibrate verbs in particular - are uncommon outside metering ICs that expose live - calibration. - """ - configuration: MeterConfiguration # Limits and behaviour applied to a single meter. - meter_id: str - behavior: MeterBehaviorCommand | None = None # One-shot verb to send to a meter as part of `configure_meter`. Not every vendor supports every verb; check capabilities first. Calibrate verbs in particular are uncommon outside metering ICs that expose live calibration. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "behavior": "behavior", - "configuration": "configuration", - "meter_id": "meter_id", - } - key_transform_with_dump = { - "behavior": "behavior", - "configuration": "configuration", - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/configure_provider_command.py b/sparkmeter/metering/_generated/models/configure_provider_command.py deleted file mode 100644 index bf6b7cf..0000000 --- a/sparkmeter/metering/_generated/models/configure_provider_command.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .configure_provider_command_vendor_options import ConfigureProviderCommandVendorOptions -from .configure_provider_params import ConfigureProviderParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["ConfigureProviderCommand"] - -@dataclass -class ConfigureProviderCommand: - """ - Reconfigure the provider's heartbeat and network parameters. Reply contract: **fire- - and-forget**. The provider applies the change as soon as it can; no event is emitted to - confirm. To verify, follow up with `query_provider_status` and inspect the reported - configuration. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (ConfigureProviderParams) - : Provider-level configuration. `heartbeat_seconds` is the - interval at which the provider polls its meters and emits - aggregated `heartbeat_summary` events. The valid range - and any divisibility constraints are vendor-specific (see - the vendor's capability documentation). Network - credentials (e.g., AES key, radio channel) are vendor- - specific and pass through `vendor_options`. - vendor_options (ConfigureProviderCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: ConfigureProviderParams # Provider-level configuration. `heartbeat_seconds` is the interval at which the provider polls its meters and emits aggregated `heartbeat_summary` events. The valid range and any divisibility constraints are vendor-specific (see the vendor's capability documentation). Network credentials (e.g., AES key, radio channel) are vendor-specific and pass through `vendor_options`. - vendor_options: ConfigureProviderCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/configure_provider_command_vendor_options.py b/sparkmeter/metering/_generated/models/configure_provider_command_vendor_options.py deleted file mode 100644 index 1fd63df..0000000 --- a/sparkmeter/metering/_generated/models/configure_provider_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["ConfigureProviderCommandVendorOptions"] - -@dataclass -class ConfigureProviderCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, ConfigureProviderCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for ConfigureProviderCommandVendorOptions -def _structure_configureprovidercommandvendoroptions(data: dict[str, Any], _: type[ConfigureProviderCommandVendorOptions]) -> ConfigureProviderCommandVendorOptions: - """Structure hook for cattrs to handle ConfigureProviderCommandVendorOptions deserialization.""" - if data is None: - return ConfigureProviderCommandVendorOptions() - if isinstance(data, ConfigureProviderCommandVendorOptions): - return data - return ConfigureProviderCommandVendorOptions(_data=data) - - -def _unstructure_configureprovidercommandvendoroptions(instance: ConfigureProviderCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle ConfigureProviderCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(ConfigureProviderCommandVendorOptions, _structure_configureprovidercommandvendoroptions) -converter.register_unstructure_hook(ConfigureProviderCommandVendorOptions, _unstructure_configureprovidercommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/configure_provider_params.py b/sparkmeter/metering/_generated/models/configure_provider_params.py deleted file mode 100644 index a29f808..0000000 --- a/sparkmeter/metering/_generated/models/configure_provider_params.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["ConfigureProviderParams"] - -@dataclass -class ConfigureProviderParams: - """ - Provider-level configuration. `heartbeat_seconds` is the interval at which the provider - polls its meters and emits aggregated `heartbeat_summary` events. The valid range and - any divisibility constraints are vendor-specific (see the vendor's capability - documentation). Network credentials (e.g., AES key, radio channel) are vendor-specific - and pass through `vendor_options`. - - Args: - heartbeat_seconds (int) : - """ - heartbeat_seconds: int - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "heartbeat_seconds": "heartbeat_seconds", - } - key_transform_with_dump = { - "heartbeat_seconds": "heartbeat_seconds", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/enter_configuration_mode_command.py b/sparkmeter/metering/_generated/models/enter_configuration_mode_command.py deleted file mode 100644 index f5eca97..0000000 --- a/sparkmeter/metering/_generated/models/enter_configuration_mode_command.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .enter_configuration_mode_command_vendor_options import EnterConfigurationModeCommandVendorOptions -from .enter_configuration_mode_params import EnterConfigurationModeParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["EnterConfigurationModeCommand"] - -@dataclass -class EnterConfigurationModeCommand: - """ - Schedule a fleet-wide configuration-mode window. Reply contract: **two-stage**. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (EnterConfigurationModeParams) - : Schedule a future window during which meters will accept - the new `new_aes_key` on `new_channel`. Outside the - window, meters keep using their previous key/channel. - Used to rotate AES keys or channels across a fleet - without losing contact with any meter. - vendor_options (EnterConfigurationModeCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: EnterConfigurationModeParams # Schedule a future window during which meters will accept the new `new_aes_key` on `new_channel`. Outside the window, meters keep using their previous key/channel. Used to rotate AES keys or channels across a fleet without losing contact with any meter. - vendor_options: EnterConfigurationModeCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/enter_configuration_mode_command_vendor_options.py b/sparkmeter/metering/_generated/models/enter_configuration_mode_command_vendor_options.py deleted file mode 100644 index 1c1cfa9..0000000 --- a/sparkmeter/metering/_generated/models/enter_configuration_mode_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["EnterConfigurationModeCommandVendorOptions"] - -@dataclass -class EnterConfigurationModeCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, EnterConfigurationModeCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for EnterConfigurationModeCommandVendorOptions -def _structure_enterconfigurationmodecommandvendoroptions(data: dict[str, Any], _: type[EnterConfigurationModeCommandVendorOptions]) -> EnterConfigurationModeCommandVendorOptions: - """Structure hook for cattrs to handle EnterConfigurationModeCommandVendorOptions deserialization.""" - if data is None: - return EnterConfigurationModeCommandVendorOptions() - if isinstance(data, EnterConfigurationModeCommandVendorOptions): - return data - return EnterConfigurationModeCommandVendorOptions(_data=data) - - -def _unstructure_enterconfigurationmodecommandvendoroptions(instance: EnterConfigurationModeCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle EnterConfigurationModeCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(EnterConfigurationModeCommandVendorOptions, _structure_enterconfigurationmodecommandvendoroptions) -converter.register_unstructure_hook(EnterConfigurationModeCommandVendorOptions, _unstructure_enterconfigurationmodecommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/enter_configuration_mode_params.py b/sparkmeter/metering/_generated/models/enter_configuration_mode_params.py deleted file mode 100644 index ce9d825..0000000 --- a/sparkmeter/metering/_generated/models/enter_configuration_mode_params.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["EnterConfigurationModeParams"] - -@dataclass -class EnterConfigurationModeParams: - """ - Schedule a future window during which meters will accept the new `new_aes_key` on - `new_channel`. Outside the window, meters keep using their previous key/channel. Used - to rotate AES keys or channels across a fleet without losing contact with any meter. - - Args: - end_unix_seconds (int) : - new_aes_key (str) : Raw 16-byte network key. - new_channel (int) : - start_unix_seconds (int) : - """ - end_unix_seconds: int - new_aes_key: str # Raw 16-byte network key. - new_channel: int - start_unix_seconds: int - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "end_unix_seconds": "end_unix_seconds", - "new_aes_key": "new_aes_key", - "new_channel": "new_channel", - "start_unix_seconds": "start_unix_seconds", - } - key_transform_with_dump = { - "end_unix_seconds": "end_unix_seconds", - "new_aes_key": "new_aes_key", - "new_channel": "new_channel", - "start_unix_seconds": "start_unix_seconds", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/firmware_update_session_status.py b/sparkmeter/metering/_generated/models/firmware_update_session_status.py deleted file mode 100644 index 50f80bf..0000000 --- a/sparkmeter/metering/_generated/models/firmware_update_session_status.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from enum import Enum, unique - -__all__ = ["FirmwareUpdateSessionStatus"] - -@unique -class FirmwareUpdateSessionStatus(str, Enum): - """ - FirmwareUpdateSessionStatus Enum - - Args: - starting (str) : Value for STARTING - in_progress (str) : Value for IN_PROGRESS - completed (str) : Value for COMPLETED - cancelled (str) : Value for CANCELLED - failed (str) : Value for FAILED - """ - STARTING = "starting" - IN_PROGRESS = "in_progress" - COMPLETED = "completed" - CANCELLED = "cancelled" - FAILED = "failed" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/firmware_update_status_event.py b/sparkmeter/metering/_generated/models/firmware_update_status_event.py deleted file mode 100644 index c8bef52..0000000 --- a/sparkmeter/metering/_generated/models/firmware_update_status_event.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import List - -from .firmware_update_session_status import FirmwareUpdateSessionStatus -from .firmware_update_status_event_failed_meters import FirmwareUpdateStatusEventFailedMeters -from .firmware_update_status_event_progress_percent import FirmwareUpdateStatusEventProgressPercent -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["FirmwareUpdateStatusEvent"] - -@dataclass -class FirmwareUpdateStatusEvent: - """ - Reply to `query_firmware_update_status`. One per active session. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - session_id (str) : - status (FirmwareUpdateSessionStatus) - : - completed_meters (List[str] | None) - : - failed_meters (FirmwareUpdateStatusEventFailedMeters | None) - : Mapping of meter_id → reason for meters that failed mid- - session. - pending_meters (List[str] | None) - : - progress_percent (FirmwareUpdateStatusEventProgressPercent | None) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - session_id: str - status: FirmwareUpdateSessionStatus - completed_meters: List[str] | None = field(default_factory=list) - failed_meters: FirmwareUpdateStatusEventFailedMeters | None = None # Mapping of meter_id → reason for meters that failed mid-session. - pending_meters: List[str] | None = field(default_factory=list) - progress_percent: FirmwareUpdateStatusEventProgressPercent | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "completed_meters": "completed_meters", - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "failed_meters": "failed_meters", - "pending_meters": "pending_meters", - "progress_percent": "progress_percent", - "session_id": "session_id", - "status": "status", - } - key_transform_with_dump = { - "completed_meters": "completed_meters", - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "failed_meters": "failed_meters", - "pending_meters": "pending_meters", - "progress_percent": "progress_percent", - "session_id": "session_id", - "status": "status", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/firmware_update_status_event_failed_meters.py b/sparkmeter/metering/_generated/models/firmware_update_status_event_failed_meters.py deleted file mode 100644 index 66e32ff..0000000 --- a/sparkmeter/metering/_generated/models/firmware_update_status_event_failed_meters.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -from collections.abc import ItemsView, KeysView, ValuesView -from dataclasses import dataclass, field -from typing import Any, ClassVar, Iterator - -__all__ = ["FirmwareUpdateStatusEventFailedMeters"] - -@dataclass -class FirmwareUpdateStatusEventFailedMeters: - """ - Mapping of meter_id → reason for meters that failed mid-session. - - This class wraps a dictionary with typed values, providing dict-like access - while ensuring values are properly deserialized into str instances. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - values become str instances - obj = structure_from_dict({"key": {"field": "value"}}, FirmwareUpdateStatusEventFailedMeters) - - # Access returns typed str instance - item = obj["key"] - print(item.field) # "value" - direct attribute access - - # Serialize for API request - data = unstructure_to_dict(obj) - """ - - _data: dict[str, str] = field(default_factory=dict, repr=False) - - # Runtime type information for cattrs deserialization - _value_type: ClassVar[str] = "str" - - def get(self, key: str, default: str | None = None) -> str | None: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> str: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: str) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> KeysView[str]: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> ValuesView[str]: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> ItemsView[str, str]: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Iterator[str]: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for FirmwareUpdateStatusEventFailedMeters -def _structure_firmwareupdatestatuseventfailedmeters(data: dict[str, Any], _: type[FirmwareUpdateStatusEventFailedMeters]) -> FirmwareUpdateStatusEventFailedMeters: - """Structure hook for cattrs to handle FirmwareUpdateStatusEventFailedMeters deserialization with typed values.""" - if data is None: - return FirmwareUpdateStatusEventFailedMeters() - if isinstance(data, FirmwareUpdateStatusEventFailedMeters): - return data - - # Import converter lazily to avoid circular imports - from sparkmeter.metering._generated.core.cattrs_converter import converter, _register_structure_hooks_recursively - - # Register hooks for dataclass value types (once, outside loop) - if hasattr(str, '__dataclass_fields__'): - _register_structure_hooks_recursively(str) - - # Deserialize each value into str - # Using converter.structure() for all values - cattrs handles primitives, datetime, bytes, etc. - structured_data: dict[str, str] = {} - for key, value in data.items(): - structured_data[key] = converter.structure(value, str) - - return FirmwareUpdateStatusEventFailedMeters(_data=structured_data) - - -def _unstructure_firmwareupdatestatuseventfailedmeters(instance: FirmwareUpdateStatusEventFailedMeters) -> dict[str, Any]: - """Unstructure hook for cattrs to handle FirmwareUpdateStatusEventFailedMeters serialization.""" - from sparkmeter.metering._generated.core.cattrs_converter import converter - - # Unstructure each value - return { - key: converter.unstructure(value) - for key, value in instance._data.items() - } - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(FirmwareUpdateStatusEventFailedMeters, _structure_firmwareupdatestatuseventfailedmeters) -converter.register_unstructure_hook(FirmwareUpdateStatusEventFailedMeters, _unstructure_firmwareupdatestatuseventfailedmeters) diff --git a/sparkmeter/metering/_generated/models/firmware_update_status_event_progress_percent.py b/sparkmeter/metering/_generated/models/firmware_update_status_event_progress_percent.py deleted file mode 100644 index 906814c..0000000 --- a/sparkmeter/metering/_generated/models/firmware_update_status_event_progress_percent.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['FirmwareUpdateStatusEventProgressPercent'] - -FirmwareUpdateStatusEventProgressPercent: TypeAlias = float | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/firmware_version.py b/sparkmeter/metering/_generated/models/firmware_version.py deleted file mode 100644 index bfef7f8..0000000 --- a/sparkmeter/metering/_generated/models/firmware_version.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["FirmwareVersion"] - -@dataclass -class FirmwareVersion: - """ - Semantic version of meter firmware. - - Args: - major (int) : - minor (int) : - patch (int) : - """ - major: int - minor: int - patch: int - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "major": "major", - "minor": "minor", - "patch": "patch", - } - key_transform_with_dump = { - "major": "major", - "minor": "minor", - "patch": "patch", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/heartbeat_summary_event.py b/sparkmeter/metering/_generated/models/heartbeat_summary_event.py deleted file mode 100644 index f41156c..0000000 --- a/sparkmeter/metering/_generated/models/heartbeat_summary_event.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .heartbeat_summary_event_read_reply_latency_ms import HeartbeatSummaryEventReadReplyLatencyMs -from .heartbeat_summary_event_set_config_reply_latency_ms import HeartbeatSummaryEventSetConfigReplyLatencyMs -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["HeartbeatSummaryEvent"] - -@dataclass -class HeartbeatSummaryEvent: - """ - End-of-heartbeat aggregate across the whole network. - - Args: - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - meters_attempted (int) : - meters_responded (int) : - packets_received (int) : - packets_sent (int) : - timestamp (int) : - total_registered_meters (int) - : - read_reply_latency_ms (HeartbeatSummaryEventReadReplyLatencyMs | None) - : - set_config_reply_latency_ms (HeartbeatSummaryEventSetConfigReplyLatencyMs | None) - : - """ - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - meters_attempted: int - meters_responded: int - packets_received: int - packets_sent: int - timestamp: int - total_registered_meters: int - read_reply_latency_ms: HeartbeatSummaryEventReadReplyLatencyMs | None = None - set_config_reply_latency_ms: HeartbeatSummaryEventSetConfigReplyLatencyMs | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "event_id": "event_id", - "event_type": "event_type", - "meters_attempted": "meters_attempted", - "meters_responded": "meters_responded", - "packets_received": "packets_received", - "packets_sent": "packets_sent", - "read_reply_latency_ms": "read_reply_latency_ms", - "set_config_reply_latency_ms": "set_config_reply_latency_ms", - "timestamp": "timestamp", - "total_registered_meters": "total_registered_meters", - } - key_transform_with_dump = { - "event_id": "event_id", - "event_type": "event_type", - "meters_attempted": "meters_attempted", - "meters_responded": "meters_responded", - "packets_received": "packets_received", - "packets_sent": "packets_sent", - "read_reply_latency_ms": "read_reply_latency_ms", - "set_config_reply_latency_ms": "set_config_reply_latency_ms", - "timestamp": "timestamp", - "total_registered_meters": "total_registered_meters", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/heartbeat_summary_event_read_reply_latency_ms.py b/sparkmeter/metering/_generated/models/heartbeat_summary_event_read_reply_latency_ms.py deleted file mode 100644 index 84838ee..0000000 --- a/sparkmeter/metering/_generated/models/heartbeat_summary_event_read_reply_latency_ms.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -from .statistics_histogram import StatisticsHistogram - -from .statistics_histogram import StatisticsHistogram - -__all__ = ['HeartbeatSummaryEventReadReplyLatencyMs'] - -HeartbeatSummaryEventReadReplyLatencyMs: TypeAlias = StatisticsHistogram | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/heartbeat_summary_event_set_config_reply_latency_ms.py b/sparkmeter/metering/_generated/models/heartbeat_summary_event_set_config_reply_latency_ms.py deleted file mode 100644 index dd41297..0000000 --- a/sparkmeter/metering/_generated/models/heartbeat_summary_event_set_config_reply_latency_ms.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -from .statistics_histogram import StatisticsHistogram - -from .statistics_histogram import StatisticsHistogram - -__all__ = ['HeartbeatSummaryEventSetConfigReplyLatencyMs'] - -HeartbeatSummaryEventSetConfigReplyLatencyMs: TypeAlias = StatisticsHistogram | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/high_capacity_meter_configuration.py b/sparkmeter/metering/_generated/models/high_capacity_meter_configuration.py deleted file mode 100644 index c88014f..0000000 --- a/sparkmeter/metering/_generated/models/high_capacity_meter_configuration.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["HighCapacityMeterConfiguration"] - -@dataclass -class HighCapacityMeterConfiguration: - """ - Settings specific to instrument-transformer-fed (high-capacity) meters. - - Args: - ct_ratio (int) : - echo (int | None) : Opaque token round-tripped to the meter and echoed back - on `command_applied` so the host can correlate config - rotations. - reverse_current (bool | None) - : - """ - ct_ratio: int - echo: int | None = 0 # Opaque token round-tripped to the meter and echoed back on `command_applied` so the host can correlate config rotations. - reverse_current: bool | None = False - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "ct_ratio": "ct_ratio", - "echo": "echo", - "reverse_current": "reverse_current", - } - key_transform_with_dump = { - "ct_ratio": "ct_ratio", - "echo": "echo", - "reverse_current": "reverse_current", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/http_validation_error.py b/sparkmeter/metering/_generated/models/http_validation_error.py deleted file mode 100644 index a0a08e9..0000000 --- a/sparkmeter/metering/_generated/models/http_validation_error.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import List - -from .validation_error import ValidationError - -__all__ = ["HttpValidationError"] - -@dataclass -class HttpValidationError: - """ - HttpValidationError dataclass - - Args: - detail (List[ValidationError] | None) - : - """ - detail: List[ValidationError] | None = field(default_factory=list) - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "detail": "detail", - } - key_transform_with_dump = { - "detail": "detail", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/http_validation_error_2.py b/sparkmeter/metering/_generated/models/http_validation_error_2.py deleted file mode 100644 index be220c4..0000000 --- a/sparkmeter/metering/_generated/models/http_validation_error_2.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import List - -from .validation_error import ValidationError - -__all__ = ["HttpValidationError2"] - -@dataclass -class HttpValidationError2: - """ - HttpValidationError2 dataclass - - Args: - detail (List[ValidationError] | None) - : - """ - detail: List[ValidationError] | None = field(default_factory=list) - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "detail": "detail", - } - key_transform_with_dump = { - "detail": "detail", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/input_.py b/sparkmeter/metering/_generated/models/input_.py deleted file mode 100644 index 83fb436..0000000 --- a/sparkmeter/metering/_generated/models/input_.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["Input_"] - -@dataclass -class Input_: - """ - Input_ dataclass - """ - # No properties defined in schema - pass \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/log_event.py b/sparkmeter/metering/_generated/models/log_event.py deleted file mode 100644 index fe11cb6..0000000 --- a/sparkmeter/metering/_generated/models/log_event.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .log_level import LogLevel -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["LogEvent"] - -@dataclass -class LogEvent: - """ - Provider log line. - - Args: - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - level (LogLevel) : - message (str) : - """ - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - level: LogLevel - message: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "event_id": "event_id", - "event_type": "event_type", - "level": "level", - "message": "message", - } - key_transform_with_dump = { - "event_id": "event_id", - "event_type": "event_type", - "level": "level", - "message": "message", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/log_level.py b/sparkmeter/metering/_generated/models/log_level.py deleted file mode 100644 index 0c6b729..0000000 --- a/sparkmeter/metering/_generated/models/log_level.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from enum import Enum, unique - -__all__ = ["LogLevel"] - -@unique -class LogLevel(str, Enum): - """ - LogLevel Enum - - Args: - trace (str) : Value for TRACE - debug (str) : Value for DEBUG - info (str) : Value for INFO - warn (str) : Value for WARN - error (str) : Value for ERROR - """ - TRACE = "trace" - DEBUG = "debug" - INFO = "info" - WARN = "warn" - ERROR = "error" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_behavior_command.py b/sparkmeter/metering/_generated/models/meter_behavior_command.py deleted file mode 100644 index 07fe8c0..0000000 --- a/sparkmeter/metering/_generated/models/meter_behavior_command.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from enum import Enum, unique - -__all__ = ["MeterBehaviorCommand"] - -@unique -class MeterBehaviorCommand(str, Enum): - """ - One-shot verb to send to a meter as part of `configure_meter`. Not every vendor - supports every verb; check capabilities first. Calibrate verbs in particular are - uncommon outside metering ICs that expose live calibration. - - Args: - none (str) : Value for NONE - enable (str) : Value for ENABLE - disable (str) : Value for DISABLE - reboot (str) : Value for REBOOT - calibrate_start (str) : Value for CALIBRATE_START - calibrate_finish (str) : Value for CALIBRATE_FINISH - enter_unprovisioned (str): Value for ENTER_UNPROVISIONED - """ - NONE = "none" - ENABLE = "enable" - DISABLE = "disable" - REBOOT = "reboot" - CALIBRATE_START = "calibrate_start" - CALIBRATE_FINISH = "calibrate_finish" - ENTER_UNPROVISIONED = "enter_unprovisioned" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_config_event.py b/sparkmeter/metering/_generated/models/meter_config_event.py deleted file mode 100644 index 097d315..0000000 --- a/sparkmeter/metering/_generated/models/meter_config_event.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .meter_config_event_balance import MeterConfigEventBalance -from .meter_config_event_firmware_version import MeterConfigEventFirmwareVersion -from .meter_configuration import MeterConfiguration -from .meter_state import MeterState -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterConfigEvent"] - -@dataclass -class MeterConfigEvent: - """ - Reply to `query_meter_config`. - - Args: - configuration (MeterConfiguration) - : Limits and behaviour applied to a single meter. - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - meter_id (str) : - state (MeterState) : Operational state of an electrical meter. Vendors may - not implement every state; subscribers should treat - unrecognised values as `unknown`. - balance (MeterConfigEventBalance | None) - : - firmware_version (MeterConfigEventFirmwareVersion | None) - : - low_balance (bool | None): - """ - configuration: MeterConfiguration # Limits and behaviour applied to a single meter. - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - meter_id: str - state: MeterState # Operational state of an electrical meter. Vendors may not implement every state; subscribers should treat unrecognised values as `unknown`. - balance: MeterConfigEventBalance | None = None - firmware_version: MeterConfigEventFirmwareVersion | None = None - low_balance: bool | None = False - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "balance": "balance", - "configuration": "configuration", - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "firmware_version": "firmware_version", - "low_balance": "low_balance", - "meter_id": "meter_id", - "state": "state", - } - key_transform_with_dump = { - "balance": "balance", - "configuration": "configuration", - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "firmware_version": "firmware_version", - "low_balance": "low_balance", - "meter_id": "meter_id", - "state": "state", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_config_event_balance.py b/sparkmeter/metering/_generated/models/meter_config_event_balance.py deleted file mode 100644 index cb02051..0000000 --- a/sparkmeter/metering/_generated/models/meter_config_event_balance.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['MeterConfigEventBalance'] - -MeterConfigEventBalance: TypeAlias = str | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_config_event_firmware_version.py b/sparkmeter/metering/_generated/models/meter_config_event_firmware_version.py deleted file mode 100644 index 030aa04..0000000 --- a/sparkmeter/metering/_generated/models/meter_config_event_firmware_version.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -from .firmware_version import FirmwareVersion - -from .firmware_version import FirmwareVersion - -__all__ = ['MeterConfigEventFirmwareVersion'] - -MeterConfigEventFirmwareVersion: TypeAlias = FirmwareVersion | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_configuration.py b/sparkmeter/metering/_generated/models/meter_configuration.py deleted file mode 100644 index 3566804..0000000 --- a/sparkmeter/metering/_generated/models/meter_configuration.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .throttle_config import ThrottleConfig - -__all__ = ["MeterConfiguration"] - -@dataclass -class MeterConfiguration: - """ - Limits and behaviour applied to a single meter. - - Args: - current_limit_amps (float): - power_limit_watts (float): - throttle (ThrottleConfig): Throttle behaviour applied when current/power exceeds - limits. `on_seconds` and `off_seconds` describe the duty - cycle while throttling; `count_limit` is the number of - full cycles after which the meter transitions to a - protective shutoff. - startup_delay_seconds (int | None) - : - """ - current_limit_amps: float - power_limit_watts: float - throttle: ThrottleConfig # Throttle behaviour applied when current/power exceeds limits. `on_seconds` and `off_seconds` describe the duty cycle while throttling; `count_limit` is the number of full cycles after which the meter transitions to a protective shutoff. - startup_delay_seconds: int | None = 0 - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "current_limit_amps": "current_limit_amps", - "power_limit_watts": "power_limit_watts", - "startup_delay_seconds": "startup_delay_seconds", - "throttle": "throttle", - } - key_transform_with_dump = { - "current_limit_amps": "current_limit_amps", - "power_limit_watts": "power_limit_watts", - "startup_delay_seconds": "startup_delay_seconds", - "throttle": "throttle", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_error_entry.py b/sparkmeter/metering/_generated/models/meter_error_entry.py deleted file mode 100644 index d005754..0000000 --- a/sparkmeter/metering/_generated/models/meter_error_entry.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .meter_error_entry_description import MeterErrorEntryDescription -from .meter_error_entry_location import MeterErrorEntryLocation -from .meter_error_entry_timestamp_unix_seconds import MeterErrorEntryTimestampUnixSeconds - -__all__ = ["MeterErrorEntry"] - -@dataclass -class MeterErrorEntry: - """ - MeterErrorEntry dataclass - - Args: - code (str) : Vendor-specific error code, stringified. - description (MeterErrorEntryDescription | None) - : - location (MeterErrorEntryLocation | None) - : Vendor-specific source location (e.g. file:line). - timestamp_unix_seconds (MeterErrorEntryTimestampUnixSeconds | None) - : - """ - code: str # Vendor-specific error code, stringified. - description: MeterErrorEntryDescription | None = None - location: MeterErrorEntryLocation | None = None # Vendor-specific source location (e.g. file:line). - timestamp_unix_seconds: MeterErrorEntryTimestampUnixSeconds | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "code": "code", - "description": "description", - "location": "location", - "timestamp_unix_seconds": "timestamp_unix_seconds", - } - key_transform_with_dump = { - "code": "code", - "description": "description", - "location": "location", - "timestamp_unix_seconds": "timestamp_unix_seconds", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_error_entry_description.py b/sparkmeter/metering/_generated/models/meter_error_entry_description.py deleted file mode 100644 index 0c3efe7..0000000 --- a/sparkmeter/metering/_generated/models/meter_error_entry_description.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['MeterErrorEntryDescription'] - -MeterErrorEntryDescription: TypeAlias = str | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_error_entry_location.py b/sparkmeter/metering/_generated/models/meter_error_entry_location.py deleted file mode 100644 index 5127d19..0000000 --- a/sparkmeter/metering/_generated/models/meter_error_entry_location.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['MeterErrorEntryLocation'] - -MeterErrorEntryLocation: TypeAlias = str | None -"""Alias for Vendor-specific source location (e.g. file:line).""" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_error_entry_timestamp_unix_seconds.py b/sparkmeter/metering/_generated/models/meter_error_entry_timestamp_unix_seconds.py deleted file mode 100644 index d7d2531..0000000 --- a/sparkmeter/metering/_generated/models/meter_error_entry_timestamp_unix_seconds.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['MeterErrorEntryTimestampUnixSeconds'] - -MeterErrorEntryTimestampUnixSeconds: TypeAlias = int | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_errors_event.py b/sparkmeter/metering/_generated/models/meter_errors_event.py deleted file mode 100644 index 5be0c00..0000000 --- a/sparkmeter/metering/_generated/models/meter_errors_event.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import List - -from .meter_error_entry import MeterErrorEntry -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterErrorsEvent"] - -@dataclass -class MeterErrorsEvent: - """ - Reply to `query_meter_errors`. - - Args: - correlation_id (str) : - errors (List[MeterErrorEntry]) - : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - meter_id (str) : - """ - correlation_id: str - errors: List[MeterErrorEntry] - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "errors": "errors", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "errors": "errors", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_firmware_changed_event.py b/sparkmeter/metering/_generated/models/meter_firmware_changed_event.py deleted file mode 100644 index c146b78..0000000 --- a/sparkmeter/metering/_generated/models/meter_firmware_changed_event.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .firmware_version import FirmwareVersion -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterFirmwareChangedEvent"] - -@dataclass -class MeterFirmwareChangedEvent: - """ - Provider observed a meter's firmware version, first time or changed. - - Args: - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - firmware_version (FirmwareVersion) - : Semantic version of meter firmware. - meter_id (str) : - """ - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - firmware_version: FirmwareVersion # Semantic version of meter firmware. - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "event_id": "event_id", - "event_type": "event_type", - "firmware_version": "firmware_version", - "meter_id": "meter_id", - } - key_transform_with_dump = { - "event_id": "event_id", - "event_type": "event_type", - "firmware_version": "firmware_version", - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_instant_reading_event.py b/sparkmeter/metering/_generated/models/meter_instant_reading_event.py deleted file mode 100644 index c7e42f7..0000000 --- a/sparkmeter/metering/_generated/models/meter_instant_reading_event.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .meter_state import MeterState -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterInstantReadingEvent"] - -@dataclass -class MeterInstantReadingEvent: - """ - Reply to `read_meter_now`. Single-shot, not period-aggregated. - - Args: - active_power_watts (float): - apparent_power_va (float): - correlation_id (str) : - current_amps (float) : - energy_wh (float) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - frequency_hz (float) : - meter_id (str) : - power_factor (float) : - state (MeterState) : Operational state of an electrical meter. Vendors may - not implement every state; subscribers should treat - unrecognised values as `unknown`. - uptime_seconds (int) : - voltage (float) : - """ - active_power_watts: float - apparent_power_va: float - correlation_id: str - current_amps: float - energy_wh: float - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - frequency_hz: float - meter_id: str - power_factor: float - state: MeterState # Operational state of an electrical meter. Vendors may not implement every state; subscribers should treat unrecognised values as `unknown`. - uptime_seconds: int - voltage: float - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "active_power_watts": "active_power_watts", - "apparent_power_va": "apparent_power_va", - "correlation_id": "correlation_id", - "current_amps": "current_amps", - "energy_wh": "energy_wh", - "event_id": "event_id", - "event_type": "event_type", - "frequency_hz": "frequency_hz", - "meter_id": "meter_id", - "power_factor": "power_factor", - "state": "state", - "uptime_seconds": "uptime_seconds", - "voltage": "voltage", - } - key_transform_with_dump = { - "active_power_watts": "active_power_watts", - "apparent_power_va": "apparent_power_va", - "correlation_id": "correlation_id", - "current_amps": "current_amps", - "energy_wh": "energy_wh", - "event_id": "event_id", - "event_type": "event_type", - "frequency_hz": "frequency_hz", - "meter_id": "meter_id", - "power_factor": "power_factor", - "state": "state", - "uptime_seconds": "uptime_seconds", - "voltage": "voltage", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_memory_event.py b/sparkmeter/metering/_generated/models/meter_memory_event.py deleted file mode 100644 index dd56b50..0000000 --- a/sparkmeter/metering/_generated/models/meter_memory_event.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterMemoryEvent"] - -@dataclass -class MeterMemoryEvent: - """ - Reply to `read_meter_memory`. - - Args: - address (int) : - correlation_id (str) : - data_ (str) : Maps from 'data' - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - meter_id (str) : - """ - address: int - correlation_id: str - data_: str # Maps from 'data' - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "address": "address", - "correlation_id": "correlation_id", - "data": "data_", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - } - key_transform_with_dump = { - "address": "address", - "correlation_id": "correlation_id", - "data_": "data", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_neighbor.py b/sparkmeter/metering/_generated/models/meter_neighbor.py deleted file mode 100644 index c24e6b4..0000000 --- a/sparkmeter/metering/_generated/models/meter_neighbor.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .meter_neighbor_last_seen_unix_seconds import MeterNeighborLastSeenUnixSeconds -from .meter_neighbor_link_quality import MeterNeighborLinkQuality -from .meter_neighbor_rssi_dbm import MeterNeighborRssiDbm - -__all__ = ["MeterNeighbor"] - -@dataclass -class MeterNeighbor: - """ - MeterNeighbor dataclass - - Args: - neighbor_id (str) : - last_seen_unix_seconds (MeterNeighborLastSeenUnixSeconds | None) - : - link_quality (MeterNeighborLinkQuality | None) - : - rssi_dbm (MeterNeighborRssiDbm | None) - : - """ - neighbor_id: str - last_seen_unix_seconds: MeterNeighborLastSeenUnixSeconds | None = None - link_quality: MeterNeighborLinkQuality | None = None - rssi_dbm: MeterNeighborRssiDbm | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "last_seen_unix_seconds": "last_seen_unix_seconds", - "link_quality": "link_quality", - "neighbor_id": "neighbor_id", - "rssi_dbm": "rssi_dbm", - } - key_transform_with_dump = { - "last_seen_unix_seconds": "last_seen_unix_seconds", - "link_quality": "link_quality", - "neighbor_id": "neighbor_id", - "rssi_dbm": "rssi_dbm", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_neighbor_last_seen_unix_seconds.py b/sparkmeter/metering/_generated/models/meter_neighbor_last_seen_unix_seconds.py deleted file mode 100644 index 147ce9d..0000000 --- a/sparkmeter/metering/_generated/models/meter_neighbor_last_seen_unix_seconds.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['MeterNeighborLastSeenUnixSeconds'] - -MeterNeighborLastSeenUnixSeconds: TypeAlias = int | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_neighbor_link_quality.py b/sparkmeter/metering/_generated/models/meter_neighbor_link_quality.py deleted file mode 100644 index 3e109ed..0000000 --- a/sparkmeter/metering/_generated/models/meter_neighbor_link_quality.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['MeterNeighborLinkQuality'] - -MeterNeighborLinkQuality: TypeAlias = float | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_neighbor_rssi_dbm.py b/sparkmeter/metering/_generated/models/meter_neighbor_rssi_dbm.py deleted file mode 100644 index a4e46ca..0000000 --- a/sparkmeter/metering/_generated/models/meter_neighbor_rssi_dbm.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['MeterNeighborRssiDbm'] - -MeterNeighborRssiDbm: TypeAlias = float | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_neighbors_event.py b/sparkmeter/metering/_generated/models/meter_neighbors_event.py deleted file mode 100644 index d065e95..0000000 --- a/sparkmeter/metering/_generated/models/meter_neighbors_event.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import List - -from .meter_neighbor import MeterNeighbor -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterNeighborsEvent"] - -@dataclass -class MeterNeighborsEvent: - """ - Reply to `query_meter_neighbors`. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - meter_id (str) : - neighbors (List[MeterNeighbor]) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - meter_id: str - neighbors: List[MeterNeighbor] - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - "neighbors": "neighbors", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - "neighbors": "neighbors", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_network_statistics.py b/sparkmeter/metering/_generated/models/meter_network_statistics.py deleted file mode 100644 index 860f740..0000000 --- a/sparkmeter/metering/_generated/models/meter_network_statistics.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .meter_network_statistics_read_reply_latency_ms import MeterNetworkStatisticsReadReplyLatencyMs -from .meter_network_statistics_set_config_reply_latency_ms import MeterNetworkStatisticsSetConfigReplyLatencyMs - -__all__ = ["MeterNetworkStatistics"] - -@dataclass -class MeterNetworkStatistics: - """ - MeterNetworkStatistics dataclass - - Args: - meter_id (str) : - packets_received_in_current_heartbeat (int) - : - packets_received_in_recent_heartbeats (int) - : - packets_sent_in_current_heartbeat (int) - : - packets_sent_in_recent_heartbeats (int) - : - total_packets_received (int) - : - total_packets_sent (int) : - read_reply_latency_ms (MeterNetworkStatisticsReadReplyLatencyMs | None) - : - set_config_reply_latency_ms (MeterNetworkStatisticsSetConfigReplyLatencyMs | None) - : - """ - meter_id: str - packets_received_in_current_heartbeat: int - packets_received_in_recent_heartbeats: int - packets_sent_in_current_heartbeat: int - packets_sent_in_recent_heartbeats: int - total_packets_received: int - total_packets_sent: int - read_reply_latency_ms: MeterNetworkStatisticsReadReplyLatencyMs | None = None - set_config_reply_latency_ms: MeterNetworkStatisticsSetConfigReplyLatencyMs | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - "packets_received_in_current_heartbeat": "packets_received_in_current_heartbeat", - "packets_received_in_recent_heartbeats": "packets_received_in_recent_heartbeats", - "packets_sent_in_current_heartbeat": "packets_sent_in_current_heartbeat", - "packets_sent_in_recent_heartbeats": "packets_sent_in_recent_heartbeats", - "read_reply_latency_ms": "read_reply_latency_ms", - "set_config_reply_latency_ms": "set_config_reply_latency_ms", - "total_packets_received": "total_packets_received", - "total_packets_sent": "total_packets_sent", - } - key_transform_with_dump = { - "meter_id": "meter_id", - "packets_received_in_current_heartbeat": "packets_received_in_current_heartbeat", - "packets_received_in_recent_heartbeats": "packets_received_in_recent_heartbeats", - "packets_sent_in_current_heartbeat": "packets_sent_in_current_heartbeat", - "packets_sent_in_recent_heartbeats": "packets_sent_in_recent_heartbeats", - "read_reply_latency_ms": "read_reply_latency_ms", - "set_config_reply_latency_ms": "set_config_reply_latency_ms", - "total_packets_received": "total_packets_received", - "total_packets_sent": "total_packets_sent", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_network_statistics_read_reply_latency_ms.py b/sparkmeter/metering/_generated/models/meter_network_statistics_read_reply_latency_ms.py deleted file mode 100644 index e3f4fa9..0000000 --- a/sparkmeter/metering/_generated/models/meter_network_statistics_read_reply_latency_ms.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -from .statistics_histogram import StatisticsHistogram - -from .statistics_histogram import StatisticsHistogram - -__all__ = ['MeterNetworkStatisticsReadReplyLatencyMs'] - -MeterNetworkStatisticsReadReplyLatencyMs: TypeAlias = StatisticsHistogram | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_network_statistics_set_config_reply_latency_ms.py b/sparkmeter/metering/_generated/models/meter_network_statistics_set_config_reply_latency_ms.py deleted file mode 100644 index 7d6f361..0000000 --- a/sparkmeter/metering/_generated/models/meter_network_statistics_set_config_reply_latency_ms.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -from .statistics_histogram import StatisticsHistogram - -from .statistics_histogram import StatisticsHistogram - -__all__ = ['MeterNetworkStatisticsSetConfigReplyLatencyMs'] - -MeterNetworkStatisticsSetConfigReplyLatencyMs: TypeAlias = StatisticsHistogram | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_reading_event.py b/sparkmeter/metering/_generated/models/meter_reading_event.py deleted file mode 100644 index dbddd18..0000000 --- a/sparkmeter/metering/_generated/models/meter_reading_event.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .meter_state import MeterState -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterReadingEvent"] - -@dataclass -class MeterReadingEvent: - """ - Period-aggregated reading from a meter. Emitted at the end of each provider heartbeat - for each meter that successfully responded. Unique on `(meter_id, period_start, - period_end)`. Single-phase or aggregated-three-phase shape; see `meter_reading_phased` - for per-phase data. - - Args: - apparent_power_avg_va (float) - : - current_avg_amps (float) : - current_max_amps (float) : - current_min_amps (float) : - energy_wh (float) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - frequency_hz (float) : - meter_id (str) : - period_end (int) : - period_start (int) : - power_factor_avg (float) : - state (MeterState) : Operational state of an electrical meter. Vendors may - not implement every state; subscribers should treat - unrecognised values as `unknown`. - true_power_avg_watts (float) - : - true_power_inst_watts (float) - : - uptime_seconds (int) : - user_power_limit_watts (float) - : - voltage_avg (float) : - voltage_max (float) : - voltage_min (float) : - """ - apparent_power_avg_va: float - current_avg_amps: float - current_max_amps: float - current_min_amps: float - energy_wh: float - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - frequency_hz: float - meter_id: str - period_end: int - period_start: int - power_factor_avg: float - state: MeterState # Operational state of an electrical meter. Vendors may not implement every state; subscribers should treat unrecognised values as `unknown`. - true_power_avg_watts: float - true_power_inst_watts: float - uptime_seconds: int - user_power_limit_watts: float - voltage_avg: float - voltage_max: float - voltage_min: float - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "apparent_power_avg_va": "apparent_power_avg_va", - "current_avg_amps": "current_avg_amps", - "current_max_amps": "current_max_amps", - "current_min_amps": "current_min_amps", - "energy_wh": "energy_wh", - "event_id": "event_id", - "event_type": "event_type", - "frequency_hz": "frequency_hz", - "meter_id": "meter_id", - "period_end": "period_end", - "period_start": "period_start", - "power_factor_avg": "power_factor_avg", - "state": "state", - "true_power_avg_watts": "true_power_avg_watts", - "true_power_inst_watts": "true_power_inst_watts", - "uptime_seconds": "uptime_seconds", - "user_power_limit_watts": "user_power_limit_watts", - "voltage_avg": "voltage_avg", - "voltage_max": "voltage_max", - "voltage_min": "voltage_min", - } - key_transform_with_dump = { - "apparent_power_avg_va": "apparent_power_avg_va", - "current_avg_amps": "current_avg_amps", - "current_max_amps": "current_max_amps", - "current_min_amps": "current_min_amps", - "energy_wh": "energy_wh", - "event_id": "event_id", - "event_type": "event_type", - "frequency_hz": "frequency_hz", - "meter_id": "meter_id", - "period_end": "period_end", - "period_start": "period_start", - "power_factor_avg": "power_factor_avg", - "state": "state", - "true_power_avg_watts": "true_power_avg_watts", - "true_power_inst_watts": "true_power_inst_watts", - "uptime_seconds": "uptime_seconds", - "user_power_limit_watts": "user_power_limit_watts", - "voltage_avg": "voltage_avg", - "voltage_max": "voltage_max", - "voltage_min": "voltage_min", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_reading_phased_event.py b/sparkmeter/metering/_generated/models/meter_reading_phased_event.py deleted file mode 100644 index 350dbf4..0000000 --- a/sparkmeter/metering/_generated/models/meter_reading_phased_event.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import List - -from .meter_reading_phased_event_per_phase import MeterReadingPhasedEventPerPhase -from .meter_state import MeterState -from .phase import Phase -from .phase_reading import PhaseReading -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterReadingPhasedEvent"] - -@dataclass -class MeterReadingPhasedEvent: - """ - Per-phase reading from a multi-phase meter. `phases` lists which phases are present; - `per_phase` holds the scalars for each. Aggregates across present phases are also - provided as the top-level fields. - - Args: - aggregate (PhaseReading) : Single-phase scalars within a `meter_reading_phased` - event. - energy_wh (float) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - meter_id (str) : - per_phase (MeterReadingPhasedEventPerPhase) - : - period_end (int) : - period_start (int) : - phases (List[Phase]) : - state (MeterState) : Operational state of an electrical meter. Vendors may - not implement every state; subscribers should treat - unrecognised values as `unknown`. - uptime_seconds (int) : - user_power_limit_watts (float) - : - computed_fields_version (int | None) - : Version of the aggregate-computation algorithm. Bump on - any change to how aggregate fields are derived from per- - phase data. - """ - aggregate: PhaseReading # Single-phase scalars within a `meter_reading_phased` event. - energy_wh: float - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - meter_id: str - per_phase: MeterReadingPhasedEventPerPhase - period_end: int - period_start: int - phases: List[Phase] - state: MeterState # Operational state of an electrical meter. Vendors may not implement every state; subscribers should treat unrecognised values as `unknown`. - uptime_seconds: int - user_power_limit_watts: float - computed_fields_version: int | None = 0 # Version of the aggregate-computation algorithm. Bump on any change to how aggregate fields are derived from per-phase data. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "aggregate": "aggregate", - "computed_fields_version": "computed_fields_version", - "energy_wh": "energy_wh", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - "per_phase": "per_phase", - "period_end": "period_end", - "period_start": "period_start", - "phases": "phases", - "state": "state", - "uptime_seconds": "uptime_seconds", - "user_power_limit_watts": "user_power_limit_watts", - } - key_transform_with_dump = { - "aggregate": "aggregate", - "computed_fields_version": "computed_fields_version", - "energy_wh": "energy_wh", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - "per_phase": "per_phase", - "period_end": "period_end", - "period_start": "period_start", - "phases": "phases", - "state": "state", - "uptime_seconds": "uptime_seconds", - "user_power_limit_watts": "user_power_limit_watts", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_reading_phased_event_per_phase.py b/sparkmeter/metering/_generated/models/meter_reading_phased_event_per_phase.py deleted file mode 100644 index b2d6f06..0000000 --- a/sparkmeter/metering/_generated/models/meter_reading_phased_event_per_phase.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from collections.abc import ItemsView, KeysView, ValuesView -from dataclasses import dataclass, field -from typing import Any, ClassVar, Iterator - -from .phase_reading import PhaseReading - -__all__ = ["MeterReadingPhasedEventPerPhase"] - -@dataclass -class MeterReadingPhasedEventPerPhase: - """ - Generic JSON value object that preserves arbitrary data. - - This class wraps a dictionary with typed values, providing dict-like access - while ensuring values are properly deserialized into PhaseReading instances. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - values become PhaseReading instances - obj = structure_from_dict({"key": {"field": "value"}}, MeterReadingPhasedEventPerPhase) - - # Access returns typed PhaseReading instance - item = obj["key"] - print(item.field) # "value" - direct attribute access - - # Serialize for API request - data = unstructure_to_dict(obj) - """ - - _data: dict[str, PhaseReading] = field(default_factory=dict, repr=False) - - # Runtime type information for cattrs deserialization - _value_type: ClassVar[str] = "PhaseReading" - - def get(self, key: str, default: PhaseReading | None = None) -> PhaseReading | None: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> PhaseReading: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: PhaseReading) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> KeysView[str]: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> ValuesView[PhaseReading]: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> ItemsView[str, PhaseReading]: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Iterator[str]: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for MeterReadingPhasedEventPerPhase -def _structure_meterreadingphasedeventperphase(data: dict[str, Any], _: type[MeterReadingPhasedEventPerPhase]) -> MeterReadingPhasedEventPerPhase: - """Structure hook for cattrs to handle MeterReadingPhasedEventPerPhase deserialization with typed values.""" - if data is None: - return MeterReadingPhasedEventPerPhase() - if isinstance(data, MeterReadingPhasedEventPerPhase): - return data - - # Import converter lazily to avoid circular imports - from sparkmeter.metering._generated.core.cattrs_converter import converter, _register_structure_hooks_recursively - - # Register hooks for dataclass value types (once, outside loop) - if hasattr(PhaseReading, '__dataclass_fields__'): - _register_structure_hooks_recursively(PhaseReading) - - # Deserialize each value into PhaseReading - # Using converter.structure() for all values - cattrs handles primitives, datetime, bytes, etc. - structured_data: dict[str, PhaseReading] = {} - for key, value in data.items(): - structured_data[key] = converter.structure(value, PhaseReading) - - return MeterReadingPhasedEventPerPhase(_data=structured_data) - - -def _unstructure_meterreadingphasedeventperphase(instance: MeterReadingPhasedEventPerPhase) -> dict[str, Any]: - """Unstructure hook for cattrs to handle MeterReadingPhasedEventPerPhase serialization.""" - from sparkmeter.metering._generated.core.cattrs_converter import converter - - # Unstructure each value - return { - key: converter.unstructure(value) - for key, value in instance._data.items() - } - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(MeterReadingPhasedEventPerPhase, _structure_meterreadingphasedeventperphase) -converter.register_unstructure_hook(MeterReadingPhasedEventPerPhase, _unstructure_meterreadingphasedeventperphase) diff --git a/sparkmeter/metering/_generated/models/meter_register_event.py b/sparkmeter/metering/_generated/models/meter_register_event.py deleted file mode 100644 index d957b00..0000000 --- a/sparkmeter/metering/_generated/models/meter_register_event.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterRegisterEvent"] - -@dataclass -class MeterRegisterEvent: - """ - Reply to `read_meter_register`. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - meter_id (str) : - register_address (int) : - register_value (int) : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - meter_id: str - register_address: int - register_value: int - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - "register_address": "register_address", - "register_value": "register_value", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - "register_address": "register_address", - "register_value": "register_value", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_state.py b/sparkmeter/metering/_generated/models/meter_state.py deleted file mode 100644 index 5d0445d..0000000 --- a/sparkmeter/metering/_generated/models/meter_state.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from enum import Enum, unique - -__all__ = ["MeterState"] - -@unique -class MeterState(str, Enum): - """ - Operational state of an electrical meter. Vendors may not implement every state; - subscribers should treat unrecognised values as `unknown`. - - Args: - unknown (str) : Value for UNKNOWN - startup (str) : Value for STARTUP - off (str) : Value for OFF - on (str) : Value for ON - throttle (str) : Value for THROTTLE - throttle_check (str) : Value for THROTTLE_CHECK - throttle_error (str) : Value for THROTTLE_ERROR - protect (str) : Value for PROTECT - meter_check (str) : Value for METER_CHECK - meter_disabled (str) : Value for METER_DISABLED - calibrate (str) : Value for CALIBRATE - tamper (str) : Value for TAMPER - error (str) : Value for ERROR - unprovisioned (str) : Value for UNPROVISIONED - """ - UNKNOWN = "unknown" - STARTUP = "startup" - OFF = "off" - ON = "on" - THROTTLE = "throttle" - THROTTLE_CHECK = "throttle_check" - THROTTLE_ERROR = "throttle_error" - PROTECT = "protect" - METER_CHECK = "meter_check" - METER_DISABLED = "meter_disabled" - CALIBRATE = "calibrate" - TAMPER = "tamper" - ERROR = "error" - UNPROVISIONED = "unprovisioned" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_version_event.py b/sparkmeter/metering/_generated/models/meter_version_event.py deleted file mode 100644 index 0a43f55..0000000 --- a/sparkmeter/metering/_generated/models/meter_version_event.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .firmware_version import FirmwareVersion -from .meter_version_event_bootloader_version import MeterVersionEventBootloaderVersion -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["MeterVersionEvent"] - -@dataclass -class MeterVersionEvent: - """ - Reply to `query_meter_version`. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - firmware_version (FirmwareVersion) - : Semantic version of meter firmware. - meter_id (str) : - bootloader_version (MeterVersionEventBootloaderVersion | None) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - firmware_version: FirmwareVersion # Semantic version of meter firmware. - meter_id: str - bootloader_version: MeterVersionEventBootloaderVersion | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "bootloader_version": "bootloader_version", - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "firmware_version": "firmware_version", - "meter_id": "meter_id", - } - key_transform_with_dump = { - "bootloader_version": "bootloader_version", - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "firmware_version": "firmware_version", - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/meter_version_event_bootloader_version.py b/sparkmeter/metering/_generated/models/meter_version_event_bootloader_version.py deleted file mode 100644 index 5fdb6a0..0000000 --- a/sparkmeter/metering/_generated/models/meter_version_event_bootloader_version.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -from .firmware_version import FirmwareVersion - -from .firmware_version import FirmwareVersion - -__all__ = ['MeterVersionEventBootloaderVersion'] - -MeterVersionEventBootloaderVersion: TypeAlias = FirmwareVersion | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/network_health_event.py b/sparkmeter/metering/_generated/models/network_health_event.py deleted file mode 100644 index ae53692..0000000 --- a/sparkmeter/metering/_generated/models/network_health_event.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import List - -from .meter_network_statistics import MeterNetworkStatistics -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["NetworkHealthEvent"] - -@dataclass -class NetworkHealthEvent: - """ - Reply to `query_network_health`. `stats` is per-meter; if the request specified a - single `meter_id`, the list contains that one; if not, the list is the full registered - roster (possibly empty if no meters are registered). - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - stats (List[MeterNetworkStatistics]) - : - timestamp (int) : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - stats: List[MeterNetworkStatistics] - timestamp: int - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "stats": "stats", - "timestamp": "timestamp", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "stats": "stats", - "timestamp": "timestamp", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/phase.py b/sparkmeter/metering/_generated/models/phase.py deleted file mode 100644 index c28cb9d..0000000 --- a/sparkmeter/metering/_generated/models/phase.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from enum import Enum, unique - -__all__ = ["Phase"] - -@unique -class Phase(str, Enum): - """ - Three-phase electrical phase identifier. - - Args: - a (str) : Value for A - b (str) : Value for B - c (str) : Value for C - """ - A = "a" - B = "b" - C = "c" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/phase_reading.py b/sparkmeter/metering/_generated/models/phase_reading.py deleted file mode 100644 index 2d8d177..0000000 --- a/sparkmeter/metering/_generated/models/phase_reading.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["PhaseReading"] - -@dataclass -class PhaseReading: - """ - Single-phase scalars within a `meter_reading_phased` event. - - Args: - apparent_power_avg_va (float) - : - current_avg_amps (float) : - current_max_amps (float) : - current_min_amps (float) : - frequency_hz (float) : - power_factor_avg (float) : - true_power_avg_watts (float) - : - true_power_inst_watts (float) - : - voltage_avg (float) : - voltage_max (float) : - voltage_min (float) : - """ - apparent_power_avg_va: float - current_avg_amps: float - current_max_amps: float - current_min_amps: float - frequency_hz: float - power_factor_avg: float - true_power_avg_watts: float - true_power_inst_watts: float - voltage_avg: float - voltage_max: float - voltage_min: float - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "apparent_power_avg_va": "apparent_power_avg_va", - "current_avg_amps": "current_avg_amps", - "current_max_amps": "current_max_amps", - "current_min_amps": "current_min_amps", - "frequency_hz": "frequency_hz", - "power_factor_avg": "power_factor_avg", - "true_power_avg_watts": "true_power_avg_watts", - "true_power_inst_watts": "true_power_inst_watts", - "voltage_avg": "voltage_avg", - "voltage_max": "voltage_max", - "voltage_min": "voltage_min", - } - key_transform_with_dump = { - "apparent_power_avg_va": "apparent_power_avg_va", - "current_avg_amps": "current_avg_amps", - "current_max_amps": "current_max_amps", - "current_min_amps": "current_min_amps", - "frequency_hz": "frequency_hz", - "power_factor_avg": "power_factor_avg", - "true_power_avg_watts": "true_power_avg_watts", - "true_power_inst_watts": "true_power_inst_watts", - "voltage_avg": "voltage_avg", - "voltage_max": "voltage_max", - "voltage_min": "voltage_min", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/ping_meter_command.py b/sparkmeter/metering/_generated/models/ping_meter_command.py deleted file mode 100644 index 7ecca90..0000000 --- a/sparkmeter/metering/_generated/models/ping_meter_command.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .ping_meter_command_vendor_options import PingMeterCommandVendorOptions -from .ping_meter_params import PingMeterParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["PingMeterCommand"] - -@dataclass -class PingMeterCommand: - """ - Send a per-meter ping over the radio. Reply contract: **two-stage**. `command_accepted` - once queued; `command_applied` carries vendor-supplied timing and signal info in - `result` (typically `rssi_dbm`, `round_trip_ms`). `command_failed` if the meter doesn't - respond. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (PingMeterParams) : - vendor_options (PingMeterCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: PingMeterParams - vendor_options: PingMeterCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/ping_meter_command_vendor_options.py b/sparkmeter/metering/_generated/models/ping_meter_command_vendor_options.py deleted file mode 100644 index 39c4450..0000000 --- a/sparkmeter/metering/_generated/models/ping_meter_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["PingMeterCommandVendorOptions"] - -@dataclass -class PingMeterCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, PingMeterCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for PingMeterCommandVendorOptions -def _structure_pingmetercommandvendoroptions(data: dict[str, Any], _: type[PingMeterCommandVendorOptions]) -> PingMeterCommandVendorOptions: - """Structure hook for cattrs to handle PingMeterCommandVendorOptions deserialization.""" - if data is None: - return PingMeterCommandVendorOptions() - if isinstance(data, PingMeterCommandVendorOptions): - return data - return PingMeterCommandVendorOptions(_data=data) - - -def _unstructure_pingmetercommandvendoroptions(instance: PingMeterCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle PingMeterCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(PingMeterCommandVendorOptions, _structure_pingmetercommandvendoroptions) -converter.register_unstructure_hook(PingMeterCommandVendorOptions, _unstructure_pingmetercommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/ping_meter_params.py b/sparkmeter/metering/_generated/models/ping_meter_params.py deleted file mode 100644 index 90aad61..0000000 --- a/sparkmeter/metering/_generated/models/ping_meter_params.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["PingMeterParams"] - -@dataclass -class PingMeterParams: - """ - PingMeterParams dataclass - - Args: - meter_id (str) : - """ - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - } - key_transform_with_dump = { - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/provider_status_event.py b/sparkmeter/metering/_generated/models/provider_status_event.py deleted file mode 100644 index 53acf34..0000000 --- a/sparkmeter/metering/_generated/models/provider_status_event.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .firmware_version import FirmwareVersion -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["ProviderStatusEvent"] - -@dataclass -class ProviderStatusEvent: - """ - Reply to `query_provider_status`. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - messages_received (int) : - messages_sent (int) : - network_connected (bool) : - provider_firmware_version (FirmwareVersion) - : Semantic version of meter firmware. - uptime_ms (int) : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - messages_received: int - messages_sent: int - network_connected: bool - provider_firmware_version: FirmwareVersion # Semantic version of meter firmware. - uptime_ms: int - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "messages_received": "messages_received", - "messages_sent": "messages_sent", - "network_connected": "network_connected", - "provider_firmware_version": "provider_firmware_version", - "uptime_ms": "uptime_ms", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "messages_received": "messages_received", - "messages_sent": "messages_sent", - "network_connected": "network_connected", - "provider_firmware_version": "provider_firmware_version", - "uptime_ms": "uptime_ms", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/py.typed b/sparkmeter/metering/_generated/models/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/sparkmeter/metering/_generated/models/query_capabilities_command.py b/sparkmeter/metering/_generated/models/query_capabilities_command.py deleted file mode 100644 index 4a77380..0000000 --- a/sparkmeter/metering/_generated/models/query_capabilities_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_capabilities_command_vendor_options import QueryCapabilitiesCommandVendorOptions -from .query_capabilities_params import QueryCapabilitiesParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["QueryCapabilitiesCommand"] - -@dataclass -class QueryCapabilitiesCommand: - """ - Ask which commands and optional features this provider implements. Reply contract: - **query reply**. Terminal event: `capabilities`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (QueryCapabilitiesParams | None) - : - vendor_options (QueryCapabilitiesCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: QueryCapabilitiesParams | None = None - vendor_options: QueryCapabilitiesCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_capabilities_command_vendor_options.py b/sparkmeter/metering/_generated/models/query_capabilities_command_vendor_options.py deleted file mode 100644 index 6ba33c2..0000000 --- a/sparkmeter/metering/_generated/models/query_capabilities_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["QueryCapabilitiesCommandVendorOptions"] - -@dataclass -class QueryCapabilitiesCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, QueryCapabilitiesCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for QueryCapabilitiesCommandVendorOptions -def _structure_querycapabilitiescommandvendoroptions(data: dict[str, Any], _: type[QueryCapabilitiesCommandVendorOptions]) -> QueryCapabilitiesCommandVendorOptions: - """Structure hook for cattrs to handle QueryCapabilitiesCommandVendorOptions deserialization.""" - if data is None: - return QueryCapabilitiesCommandVendorOptions() - if isinstance(data, QueryCapabilitiesCommandVendorOptions): - return data - return QueryCapabilitiesCommandVendorOptions(_data=data) - - -def _unstructure_querycapabilitiescommandvendoroptions(instance: QueryCapabilitiesCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle QueryCapabilitiesCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(QueryCapabilitiesCommandVendorOptions, _structure_querycapabilitiescommandvendoroptions) -converter.register_unstructure_hook(QueryCapabilitiesCommandVendorOptions, _unstructure_querycapabilitiescommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/query_capabilities_params.py b/sparkmeter/metering/_generated/models/query_capabilities_params.py deleted file mode 100644 index 53969e1..0000000 --- a/sparkmeter/metering/_generated/models/query_capabilities_params.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["QueryCapabilitiesParams"] - -@dataclass -class QueryCapabilitiesParams: - """ - QueryCapabilitiesParams dataclass - """ - # No properties defined in schema - pass \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_firmware_update_status_command.py b/sparkmeter/metering/_generated/models/query_firmware_update_status_command.py deleted file mode 100644 index 6ef114f..0000000 --- a/sparkmeter/metering/_generated/models/query_firmware_update_status_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_firmware_update_status_command_vendor_options import QueryFirmwareUpdateStatusCommandVendorOptions -from .query_firmware_update_status_params import QueryFirmwareUpdateStatusParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["QueryFirmwareUpdateStatusCommand"] - -@dataclass -class QueryFirmwareUpdateStatusCommand: - """ - Poll the status of one or all active firmware-update sessions. Reply contract: **query - reply**. Terminal event: `firmware_update_status`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (QueryFirmwareUpdateStatusParams | None) - : - vendor_options (QueryFirmwareUpdateStatusCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: QueryFirmwareUpdateStatusParams | None = None - vendor_options: QueryFirmwareUpdateStatusCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_firmware_update_status_command_vendor_options.py b/sparkmeter/metering/_generated/models/query_firmware_update_status_command_vendor_options.py deleted file mode 100644 index 6c6b966..0000000 --- a/sparkmeter/metering/_generated/models/query_firmware_update_status_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["QueryFirmwareUpdateStatusCommandVendorOptions"] - -@dataclass -class QueryFirmwareUpdateStatusCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, QueryFirmwareUpdateStatusCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for QueryFirmwareUpdateStatusCommandVendorOptions -def _structure_queryfirmwareupdatestatuscommandvendoroptions(data: dict[str, Any], _: type[QueryFirmwareUpdateStatusCommandVendorOptions]) -> QueryFirmwareUpdateStatusCommandVendorOptions: - """Structure hook for cattrs to handle QueryFirmwareUpdateStatusCommandVendorOptions deserialization.""" - if data is None: - return QueryFirmwareUpdateStatusCommandVendorOptions() - if isinstance(data, QueryFirmwareUpdateStatusCommandVendorOptions): - return data - return QueryFirmwareUpdateStatusCommandVendorOptions(_data=data) - - -def _unstructure_queryfirmwareupdatestatuscommandvendoroptions(instance: QueryFirmwareUpdateStatusCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle QueryFirmwareUpdateStatusCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(QueryFirmwareUpdateStatusCommandVendorOptions, _structure_queryfirmwareupdatestatuscommandvendoroptions) -converter.register_unstructure_hook(QueryFirmwareUpdateStatusCommandVendorOptions, _unstructure_queryfirmwareupdatestatuscommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/query_firmware_update_status_params.py b/sparkmeter/metering/_generated/models/query_firmware_update_status_params.py deleted file mode 100644 index ed6144e..0000000 --- a/sparkmeter/metering/_generated/models/query_firmware_update_status_params.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_firmware_update_status_params_session_id import QueryFirmwareUpdateStatusParamsSessionId - -__all__ = ["QueryFirmwareUpdateStatusParams"] - -@dataclass -class QueryFirmwareUpdateStatusParams: - """ - QueryFirmwareUpdateStatusParams dataclass - - Args: - session_id (QueryFirmwareUpdateStatusParamsSessionId | None) - : Specific session id; omit for the status of all active - sessions. - """ - session_id: QueryFirmwareUpdateStatusParamsSessionId | None = None # Specific session id; omit for the status of all active sessions. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "session_id": "session_id", - } - key_transform_with_dump = { - "session_id": "session_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_firmware_update_status_params_session_id.py b/sparkmeter/metering/_generated/models/query_firmware_update_status_params_session_id.py deleted file mode 100644 index a74e227..0000000 --- a/sparkmeter/metering/_generated/models/query_firmware_update_status_params_session_id.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['QueryFirmwareUpdateStatusParamsSessionId'] - -QueryFirmwareUpdateStatusParamsSessionId: TypeAlias = str | None -"""Alias for Specific session id; omit for the status of all active sessions.""" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_meter_config_command.py b/sparkmeter/metering/_generated/models/query_meter_config_command.py deleted file mode 100644 index 6289350..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_config_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_meter_config_command_vendor_options import QueryMeterConfigCommandVendorOptions -from .query_meter_config_params import QueryMeterConfigParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["QueryMeterConfigCommand"] - -@dataclass -class QueryMeterConfigCommand: - """ - Read back the meter's currently-effective configuration. Reply contract: **query - reply**. Terminal event: `meter_config`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (QueryMeterConfigParams) - : - vendor_options (QueryMeterConfigCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: QueryMeterConfigParams - vendor_options: QueryMeterConfigCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_meter_config_command_vendor_options.py b/sparkmeter/metering/_generated/models/query_meter_config_command_vendor_options.py deleted file mode 100644 index 1df5bc5..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_config_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["QueryMeterConfigCommandVendorOptions"] - -@dataclass -class QueryMeterConfigCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, QueryMeterConfigCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for QueryMeterConfigCommandVendorOptions -def _structure_querymeterconfigcommandvendoroptions(data: dict[str, Any], _: type[QueryMeterConfigCommandVendorOptions]) -> QueryMeterConfigCommandVendorOptions: - """Structure hook for cattrs to handle QueryMeterConfigCommandVendorOptions deserialization.""" - if data is None: - return QueryMeterConfigCommandVendorOptions() - if isinstance(data, QueryMeterConfigCommandVendorOptions): - return data - return QueryMeterConfigCommandVendorOptions(_data=data) - - -def _unstructure_querymeterconfigcommandvendoroptions(instance: QueryMeterConfigCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle QueryMeterConfigCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(QueryMeterConfigCommandVendorOptions, _structure_querymeterconfigcommandvendoroptions) -converter.register_unstructure_hook(QueryMeterConfigCommandVendorOptions, _unstructure_querymeterconfigcommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/query_meter_config_params.py b/sparkmeter/metering/_generated/models/query_meter_config_params.py deleted file mode 100644 index 3fe8d83..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_config_params.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["QueryMeterConfigParams"] - -@dataclass -class QueryMeterConfigParams: - """ - QueryMeterConfigParams dataclass - - Args: - meter_id (str) : - """ - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - } - key_transform_with_dump = { - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_meter_errors_command.py b/sparkmeter/metering/_generated/models/query_meter_errors_command.py deleted file mode 100644 index 54a139c..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_errors_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_meter_errors_command_vendor_options import QueryMeterErrorsCommandVendorOptions -from .query_meter_errors_params import QueryMeterErrorsParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["QueryMeterErrorsCommand"] - -@dataclass -class QueryMeterErrorsCommand: - """ - Read the meter's stored error log. Reply contract: **query reply**. Terminal event: - `meter_errors`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (QueryMeterErrorsParams) - : - vendor_options (QueryMeterErrorsCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: QueryMeterErrorsParams - vendor_options: QueryMeterErrorsCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_meter_errors_command_vendor_options.py b/sparkmeter/metering/_generated/models/query_meter_errors_command_vendor_options.py deleted file mode 100644 index 75a517a..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_errors_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["QueryMeterErrorsCommandVendorOptions"] - -@dataclass -class QueryMeterErrorsCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, QueryMeterErrorsCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for QueryMeterErrorsCommandVendorOptions -def _structure_querymetererrorscommandvendoroptions(data: dict[str, Any], _: type[QueryMeterErrorsCommandVendorOptions]) -> QueryMeterErrorsCommandVendorOptions: - """Structure hook for cattrs to handle QueryMeterErrorsCommandVendorOptions deserialization.""" - if data is None: - return QueryMeterErrorsCommandVendorOptions() - if isinstance(data, QueryMeterErrorsCommandVendorOptions): - return data - return QueryMeterErrorsCommandVendorOptions(_data=data) - - -def _unstructure_querymetererrorscommandvendoroptions(instance: QueryMeterErrorsCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle QueryMeterErrorsCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(QueryMeterErrorsCommandVendorOptions, _structure_querymetererrorscommandvendoroptions) -converter.register_unstructure_hook(QueryMeterErrorsCommandVendorOptions, _unstructure_querymetererrorscommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/query_meter_errors_params.py b/sparkmeter/metering/_generated/models/query_meter_errors_params.py deleted file mode 100644 index 81c9745..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_errors_params.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["QueryMeterErrorsParams"] - -@dataclass -class QueryMeterErrorsParams: - """ - QueryMeterErrorsParams dataclass - - Args: - meter_id (str) : - """ - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - } - key_transform_with_dump = { - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_meter_neighbors_command.py b/sparkmeter/metering/_generated/models/query_meter_neighbors_command.py deleted file mode 100644 index 532e56a..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_neighbors_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_meter_neighbors_command_vendor_options import QueryMeterNeighborsCommandVendorOptions -from .query_meter_neighbors_params import QueryMeterNeighborsParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["QueryMeterNeighborsCommand"] - -@dataclass -class QueryMeterNeighborsCommand: - """ - Ask a meter for its current view of its radio neighbors. Reply contract: **query - reply**. Terminal event: `meter_neighbors` (success) or `command_failed`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (QueryMeterNeighborsParams) - : - vendor_options (QueryMeterNeighborsCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: QueryMeterNeighborsParams - vendor_options: QueryMeterNeighborsCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_meter_neighbors_command_vendor_options.py b/sparkmeter/metering/_generated/models/query_meter_neighbors_command_vendor_options.py deleted file mode 100644 index 4bde101..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_neighbors_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["QueryMeterNeighborsCommandVendorOptions"] - -@dataclass -class QueryMeterNeighborsCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, QueryMeterNeighborsCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for QueryMeterNeighborsCommandVendorOptions -def _structure_querymeterneighborscommandvendoroptions(data: dict[str, Any], _: type[QueryMeterNeighborsCommandVendorOptions]) -> QueryMeterNeighborsCommandVendorOptions: - """Structure hook for cattrs to handle QueryMeterNeighborsCommandVendorOptions deserialization.""" - if data is None: - return QueryMeterNeighborsCommandVendorOptions() - if isinstance(data, QueryMeterNeighborsCommandVendorOptions): - return data - return QueryMeterNeighborsCommandVendorOptions(_data=data) - - -def _unstructure_querymeterneighborscommandvendoroptions(instance: QueryMeterNeighborsCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle QueryMeterNeighborsCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(QueryMeterNeighborsCommandVendorOptions, _structure_querymeterneighborscommandvendoroptions) -converter.register_unstructure_hook(QueryMeterNeighborsCommandVendorOptions, _unstructure_querymeterneighborscommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/query_meter_neighbors_params.py b/sparkmeter/metering/_generated/models/query_meter_neighbors_params.py deleted file mode 100644 index 368ed8b..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_neighbors_params.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["QueryMeterNeighborsParams"] - -@dataclass -class QueryMeterNeighborsParams: - """ - QueryMeterNeighborsParams dataclass - - Args: - meter_id (str) : - """ - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - } - key_transform_with_dump = { - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_meter_version_command.py b/sparkmeter/metering/_generated/models/query_meter_version_command.py deleted file mode 100644 index 339bd16..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_version_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_meter_version_command_vendor_options import QueryMeterVersionCommandVendorOptions -from .query_meter_version_params import QueryMeterVersionParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["QueryMeterVersionCommand"] - -@dataclass -class QueryMeterVersionCommand: - """ - Read the firmware version reported by a meter. Reply contract: **query reply**. - Terminal event: `meter_version`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (QueryMeterVersionParams) - : - vendor_options (QueryMeterVersionCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: QueryMeterVersionParams - vendor_options: QueryMeterVersionCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_meter_version_command_vendor_options.py b/sparkmeter/metering/_generated/models/query_meter_version_command_vendor_options.py deleted file mode 100644 index 28dc298..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_version_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["QueryMeterVersionCommandVendorOptions"] - -@dataclass -class QueryMeterVersionCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, QueryMeterVersionCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for QueryMeterVersionCommandVendorOptions -def _structure_querymeterversioncommandvendoroptions(data: dict[str, Any], _: type[QueryMeterVersionCommandVendorOptions]) -> QueryMeterVersionCommandVendorOptions: - """Structure hook for cattrs to handle QueryMeterVersionCommandVendorOptions deserialization.""" - if data is None: - return QueryMeterVersionCommandVendorOptions() - if isinstance(data, QueryMeterVersionCommandVendorOptions): - return data - return QueryMeterVersionCommandVendorOptions(_data=data) - - -def _unstructure_querymeterversioncommandvendoroptions(instance: QueryMeterVersionCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle QueryMeterVersionCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(QueryMeterVersionCommandVendorOptions, _structure_querymeterversioncommandvendoroptions) -converter.register_unstructure_hook(QueryMeterVersionCommandVendorOptions, _unstructure_querymeterversioncommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/query_meter_version_params.py b/sparkmeter/metering/_generated/models/query_meter_version_params.py deleted file mode 100644 index 90fcb6f..0000000 --- a/sparkmeter/metering/_generated/models/query_meter_version_params.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["QueryMeterVersionParams"] - -@dataclass -class QueryMeterVersionParams: - """ - QueryMeterVersionParams dataclass - - Args: - meter_id (str) : - """ - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - } - key_transform_with_dump = { - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_network_health_command.py b/sparkmeter/metering/_generated/models/query_network_health_command.py deleted file mode 100644 index 4d491a7..0000000 --- a/sparkmeter/metering/_generated/models/query_network_health_command.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_network_health_command_vendor_options import QueryNetworkHealthCommandVendorOptions -from .query_network_health_params import QueryNetworkHealthParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["QueryNetworkHealthCommand"] - -@dataclass -class QueryNetworkHealthCommand: - """ - Ask the provider for packet-success / latency statistics. Reply contract: **query - reply**. Terminal event: `network_health` (success) or `command_failed`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (QueryNetworkHealthParams | None) - : Optional `meter_id` filters the response to one meter; - omit for the whole network. - vendor_options (QueryNetworkHealthCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: QueryNetworkHealthParams | None = None # Optional `meter_id` filters the response to one meter; omit for the whole network. - vendor_options: QueryNetworkHealthCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_network_health_command_vendor_options.py b/sparkmeter/metering/_generated/models/query_network_health_command_vendor_options.py deleted file mode 100644 index e0649e8..0000000 --- a/sparkmeter/metering/_generated/models/query_network_health_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["QueryNetworkHealthCommandVendorOptions"] - -@dataclass -class QueryNetworkHealthCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, QueryNetworkHealthCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for QueryNetworkHealthCommandVendorOptions -def _structure_querynetworkhealthcommandvendoroptions(data: dict[str, Any], _: type[QueryNetworkHealthCommandVendorOptions]) -> QueryNetworkHealthCommandVendorOptions: - """Structure hook for cattrs to handle QueryNetworkHealthCommandVendorOptions deserialization.""" - if data is None: - return QueryNetworkHealthCommandVendorOptions() - if isinstance(data, QueryNetworkHealthCommandVendorOptions): - return data - return QueryNetworkHealthCommandVendorOptions(_data=data) - - -def _unstructure_querynetworkhealthcommandvendoroptions(instance: QueryNetworkHealthCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle QueryNetworkHealthCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(QueryNetworkHealthCommandVendorOptions, _structure_querynetworkhealthcommandvendoroptions) -converter.register_unstructure_hook(QueryNetworkHealthCommandVendorOptions, _unstructure_querynetworkhealthcommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/query_network_health_params.py b/sparkmeter/metering/_generated/models/query_network_health_params.py deleted file mode 100644 index 6974cfd..0000000 --- a/sparkmeter/metering/_generated/models/query_network_health_params.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_network_health_params_meter_id import QueryNetworkHealthParamsMeterId - -__all__ = ["QueryNetworkHealthParams"] - -@dataclass -class QueryNetworkHealthParams: - """ - Optional `meter_id` filters the response to one meter; omit for the whole network. - - Args: - meter_id (QueryNetworkHealthParamsMeterId | None) - : - """ - meter_id: QueryNetworkHealthParamsMeterId | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - } - key_transform_with_dump = { - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_network_health_params_meter_id.py b/sparkmeter/metering/_generated/models/query_network_health_params_meter_id.py deleted file mode 100644 index 3fc52e6..0000000 --- a/sparkmeter/metering/_generated/models/query_network_health_params_meter_id.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['QueryNetworkHealthParamsMeterId'] - -QueryNetworkHealthParamsMeterId: TypeAlias = str | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_provider_status_command.py b/sparkmeter/metering/_generated/models/query_provider_status_command.py deleted file mode 100644 index 1f317ee..0000000 --- a/sparkmeter/metering/_generated/models/query_provider_status_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .query_provider_status_command_vendor_options import QueryProviderStatusCommandVendorOptions -from .query_provider_status_params import QueryProviderStatusParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["QueryProviderStatusCommand"] - -@dataclass -class QueryProviderStatusCommand: - """ - Ask the provider for its current health and identity. Reply contract: **query reply**. - Terminal event: `provider_status` (success) or `command_failed`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (QueryProviderStatusParams | None) - : - vendor_options (QueryProviderStatusCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: QueryProviderStatusParams | None = None - vendor_options: QueryProviderStatusCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/query_provider_status_command_vendor_options.py b/sparkmeter/metering/_generated/models/query_provider_status_command_vendor_options.py deleted file mode 100644 index 444be70..0000000 --- a/sparkmeter/metering/_generated/models/query_provider_status_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["QueryProviderStatusCommandVendorOptions"] - -@dataclass -class QueryProviderStatusCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, QueryProviderStatusCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for QueryProviderStatusCommandVendorOptions -def _structure_queryproviderstatuscommandvendoroptions(data: dict[str, Any], _: type[QueryProviderStatusCommandVendorOptions]) -> QueryProviderStatusCommandVendorOptions: - """Structure hook for cattrs to handle QueryProviderStatusCommandVendorOptions deserialization.""" - if data is None: - return QueryProviderStatusCommandVendorOptions() - if isinstance(data, QueryProviderStatusCommandVendorOptions): - return data - return QueryProviderStatusCommandVendorOptions(_data=data) - - -def _unstructure_queryproviderstatuscommandvendoroptions(instance: QueryProviderStatusCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle QueryProviderStatusCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(QueryProviderStatusCommandVendorOptions, _structure_queryproviderstatuscommandvendoroptions) -converter.register_unstructure_hook(QueryProviderStatusCommandVendorOptions, _unstructure_queryproviderstatuscommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/query_provider_status_params.py b/sparkmeter/metering/_generated/models/query_provider_status_params.py deleted file mode 100644 index 53383e7..0000000 --- a/sparkmeter/metering/_generated/models/query_provider_status_params.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["QueryProviderStatusParams"] - -@dataclass -class QueryProviderStatusParams: - """ - QueryProviderStatusParams dataclass - """ - # No properties defined in schema - pass \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/read_meter_memory_command.py b/sparkmeter/metering/_generated/models/read_meter_memory_command.py deleted file mode 100644 index af387a8..0000000 --- a/sparkmeter/metering/_generated/models/read_meter_memory_command.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .read_meter_memory_command_vendor_options import ReadMeterMemoryCommandVendorOptions -from .read_meter_memory_params import ReadMeterMemoryParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["ReadMeterMemoryCommand"] - -@dataclass -class ReadMeterMemoryCommand: - """ - Read a span of meter memory (debug primitive). Address layout is vendor-specific. - Length bounded by the underlying radio packet size; large reads are issued as multiple - commands. Reply contract: **query reply**. Terminal event: `meter_memory`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (ReadMeterMemoryParams) - : - vendor_options (ReadMeterMemoryCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: ReadMeterMemoryParams - vendor_options: ReadMeterMemoryCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/read_meter_memory_command_vendor_options.py b/sparkmeter/metering/_generated/models/read_meter_memory_command_vendor_options.py deleted file mode 100644 index 5f6a1d0..0000000 --- a/sparkmeter/metering/_generated/models/read_meter_memory_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["ReadMeterMemoryCommandVendorOptions"] - -@dataclass -class ReadMeterMemoryCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, ReadMeterMemoryCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for ReadMeterMemoryCommandVendorOptions -def _structure_readmetermemorycommandvendoroptions(data: dict[str, Any], _: type[ReadMeterMemoryCommandVendorOptions]) -> ReadMeterMemoryCommandVendorOptions: - """Structure hook for cattrs to handle ReadMeterMemoryCommandVendorOptions deserialization.""" - if data is None: - return ReadMeterMemoryCommandVendorOptions() - if isinstance(data, ReadMeterMemoryCommandVendorOptions): - return data - return ReadMeterMemoryCommandVendorOptions(_data=data) - - -def _unstructure_readmetermemorycommandvendoroptions(instance: ReadMeterMemoryCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle ReadMeterMemoryCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(ReadMeterMemoryCommandVendorOptions, _structure_readmetermemorycommandvendoroptions) -converter.register_unstructure_hook(ReadMeterMemoryCommandVendorOptions, _unstructure_readmetermemorycommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/read_meter_memory_params.py b/sparkmeter/metering/_generated/models/read_meter_memory_params.py deleted file mode 100644 index 7557b07..0000000 --- a/sparkmeter/metering/_generated/models/read_meter_memory_params.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["ReadMeterMemoryParams"] - -@dataclass -class ReadMeterMemoryParams: - """ - ReadMeterMemoryParams dataclass - - Args: - address (int) : - length (int) : - meter_id (str) : - """ - address: int - length: int - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "address": "address", - "length": "length", - "meter_id": "meter_id", - } - key_transform_with_dump = { - "address": "address", - "length": "length", - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/read_meter_now_command.py b/sparkmeter/metering/_generated/models/read_meter_now_command.py deleted file mode 100644 index 6a40f14..0000000 --- a/sparkmeter/metering/_generated/models/read_meter_now_command.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .read_meter_now_command_vendor_options import ReadMeterNowCommandVendorOptions -from .read_meter_now_params import ReadMeterNowParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["ReadMeterNowCommand"] - -@dataclass -class ReadMeterNowCommand: - """ - Request a single-shot instantaneous reading. Distinct from heartbeat-aligned - `meter_reading` push events; this command synchronously asks for the current values. - Reply contract: **query reply**. Terminal event: `meter_instant_reading`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (ReadMeterNowParams) - : - vendor_options (ReadMeterNowCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: ReadMeterNowParams - vendor_options: ReadMeterNowCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/read_meter_now_command_vendor_options.py b/sparkmeter/metering/_generated/models/read_meter_now_command_vendor_options.py deleted file mode 100644 index 528fdb8..0000000 --- a/sparkmeter/metering/_generated/models/read_meter_now_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["ReadMeterNowCommandVendorOptions"] - -@dataclass -class ReadMeterNowCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, ReadMeterNowCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for ReadMeterNowCommandVendorOptions -def _structure_readmeternowcommandvendoroptions(data: dict[str, Any], _: type[ReadMeterNowCommandVendorOptions]) -> ReadMeterNowCommandVendorOptions: - """Structure hook for cattrs to handle ReadMeterNowCommandVendorOptions deserialization.""" - if data is None: - return ReadMeterNowCommandVendorOptions() - if isinstance(data, ReadMeterNowCommandVendorOptions): - return data - return ReadMeterNowCommandVendorOptions(_data=data) - - -def _unstructure_readmeternowcommandvendoroptions(instance: ReadMeterNowCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle ReadMeterNowCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(ReadMeterNowCommandVendorOptions, _structure_readmeternowcommandvendoroptions) -converter.register_unstructure_hook(ReadMeterNowCommandVendorOptions, _unstructure_readmeternowcommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/read_meter_now_params.py b/sparkmeter/metering/_generated/models/read_meter_now_params.py deleted file mode 100644 index 6e9110c..0000000 --- a/sparkmeter/metering/_generated/models/read_meter_now_params.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["ReadMeterNowParams"] - -@dataclass -class ReadMeterNowParams: - """ - ReadMeterNowParams dataclass - - Args: - meter_id (str) : - """ - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - } - key_transform_with_dump = { - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/read_meter_register_command.py b/sparkmeter/metering/_generated/models/read_meter_register_command.py deleted file mode 100644 index 58962c5..0000000 --- a/sparkmeter/metering/_generated/models/read_meter_register_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .read_meter_register_command_vendor_options import ReadMeterRegisterCommandVendorOptions -from .read_meter_register_params import ReadMeterRegisterParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["ReadMeterRegisterCommand"] - -@dataclass -class ReadMeterRegisterCommand: - """ - Read a single meter register (debug primitive). Reply contract: **query reply**. - Terminal event: `meter_register`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (ReadMeterRegisterParams) - : - vendor_options (ReadMeterRegisterCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: ReadMeterRegisterParams - vendor_options: ReadMeterRegisterCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/read_meter_register_command_vendor_options.py b/sparkmeter/metering/_generated/models/read_meter_register_command_vendor_options.py deleted file mode 100644 index edb4381..0000000 --- a/sparkmeter/metering/_generated/models/read_meter_register_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["ReadMeterRegisterCommandVendorOptions"] - -@dataclass -class ReadMeterRegisterCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, ReadMeterRegisterCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for ReadMeterRegisterCommandVendorOptions -def _structure_readmeterregistercommandvendoroptions(data: dict[str, Any], _: type[ReadMeterRegisterCommandVendorOptions]) -> ReadMeterRegisterCommandVendorOptions: - """Structure hook for cattrs to handle ReadMeterRegisterCommandVendorOptions deserialization.""" - if data is None: - return ReadMeterRegisterCommandVendorOptions() - if isinstance(data, ReadMeterRegisterCommandVendorOptions): - return data - return ReadMeterRegisterCommandVendorOptions(_data=data) - - -def _unstructure_readmeterregistercommandvendoroptions(instance: ReadMeterRegisterCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle ReadMeterRegisterCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(ReadMeterRegisterCommandVendorOptions, _structure_readmeterregistercommandvendoroptions) -converter.register_unstructure_hook(ReadMeterRegisterCommandVendorOptions, _unstructure_readmeterregistercommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/read_meter_register_params.py b/sparkmeter/metering/_generated/models/read_meter_register_params.py deleted file mode 100644 index d800bc7..0000000 --- a/sparkmeter/metering/_generated/models/read_meter_register_params.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["ReadMeterRegisterParams"] - -@dataclass -class ReadMeterRegisterParams: - """ - ReadMeterRegisterParams dataclass - - Args: - meter_id (str) : - register_address (int) : - """ - meter_id: str - register_address: int - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - "register_address": "register_address", - } - key_transform_with_dump = { - "meter_id": "meter_id", - "register_address": "register_address", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/register_meter_command.py b/sparkmeter/metering/_generated/models/register_meter_command.py deleted file mode 100644 index af2200d..0000000 --- a/sparkmeter/metering/_generated/models/register_meter_command.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .register_meter_command_vendor_options import RegisterMeterCommandVendorOptions -from .register_meter_params import RegisterMeterParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["RegisterMeterCommand"] - -@dataclass -class RegisterMeterCommand: - """ - Add a meter. Reply contract: **single reply**. Possible terminal events: - `command_accepted`, `command_rejected`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (RegisterMeterParams) - : Add a meter to the provider's roster. `meter_type` is a - vendor-defined model code (e.g. `SM5R`, `SMHCE`). Set it - to whatever the vendor's capabilities report supports. - `firmware_version` is optional and seeds capability - detection on vendors that gate features by version. - `initial_balance` and `initial_low_balance` set the - meter's display credit at registration. They have the - same semantics as a subsequent `set_balance` call: cached - at the provider, delivered opportunistically. - `request_phased_readings`, when true on a multi-phase- - capable meter, causes the provider to emit - `meter_reading_phased` events instead of `meter_reading`. - Cannot be flipped after registration: unregister and re- - register to switch. - vendor_options (RegisterMeterCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: RegisterMeterParams # Add a meter to the provider's roster. `meter_type` is a vendor-defined model code (e.g. `SM5R`, `SMHCE`). Set it to whatever the vendor's capabilities report supports. `firmware_version` is optional and seeds capability detection on vendors that gate features by version. `initial_balance` and `initial_low_balance` set the meter's display credit at registration. They have the same semantics as a subsequent `set_balance` call: cached at the provider, delivered opportunistically. `request_phased_readings`, when true on a multi-phase-capable meter, causes the provider to emit `meter_reading_phased` events instead of `meter_reading`. Cannot be flipped after registration: unregister and re-register to switch. - vendor_options: RegisterMeterCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/register_meter_command_vendor_options.py b/sparkmeter/metering/_generated/models/register_meter_command_vendor_options.py deleted file mode 100644 index 0819163..0000000 --- a/sparkmeter/metering/_generated/models/register_meter_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["RegisterMeterCommandVendorOptions"] - -@dataclass -class RegisterMeterCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, RegisterMeterCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for RegisterMeterCommandVendorOptions -def _structure_registermetercommandvendoroptions(data: dict[str, Any], _: type[RegisterMeterCommandVendorOptions]) -> RegisterMeterCommandVendorOptions: - """Structure hook for cattrs to handle RegisterMeterCommandVendorOptions deserialization.""" - if data is None: - return RegisterMeterCommandVendorOptions() - if isinstance(data, RegisterMeterCommandVendorOptions): - return data - return RegisterMeterCommandVendorOptions(_data=data) - - -def _unstructure_registermetercommandvendoroptions(instance: RegisterMeterCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle RegisterMeterCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(RegisterMeterCommandVendorOptions, _structure_registermetercommandvendoroptions) -converter.register_unstructure_hook(RegisterMeterCommandVendorOptions, _unstructure_registermetercommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/register_meter_params.py b/sparkmeter/metering/_generated/models/register_meter_params.py deleted file mode 100644 index d2d7300..0000000 --- a/sparkmeter/metering/_generated/models/register_meter_params.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .register_meter_params_firmware_version import RegisterMeterParamsFirmwareVersion -from .register_meter_params_initial_balance import RegisterMeterParamsInitialBalance - -__all__ = ["RegisterMeterParams"] - -@dataclass -class RegisterMeterParams: - """ - Add a meter to the provider's roster. `meter_type` is a vendor-defined model code (e.g. - `SM5R`, `SMHCE`). Set it to whatever the vendor's capabilities report supports. - `firmware_version` is optional and seeds capability detection on vendors that gate - features by version. `initial_balance` and `initial_low_balance` set the meter's - display credit at registration. They have the same semantics as a subsequent - `set_balance` call: cached at the provider, delivered opportunistically. - `request_phased_readings`, when true on a multi-phase-capable meter, causes the provider - to emit `meter_reading_phased` events instead of `meter_reading`. Cannot be flipped - after registration: unregister and re-register to switch. - - Args: - meter_id (str) : - meter_type (str) : - firmware_version (RegisterMeterParamsFirmwareVersion | None) - : - initial_balance (RegisterMeterParamsInitialBalance | None) - : - initial_low_balance (bool | None) - : - request_phased_readings (bool | None) - : - """ - meter_id: str - meter_type: str - firmware_version: RegisterMeterParamsFirmwareVersion | None = None - initial_balance: RegisterMeterParamsInitialBalance | None = None - initial_low_balance: bool | None = False - request_phased_readings: bool | None = False - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "firmware_version": "firmware_version", - "initial_balance": "initial_balance", - "initial_low_balance": "initial_low_balance", - "meter_id": "meter_id", - "meter_type": "meter_type", - "request_phased_readings": "request_phased_readings", - } - key_transform_with_dump = { - "firmware_version": "firmware_version", - "initial_balance": "initial_balance", - "initial_low_balance": "initial_low_balance", - "meter_id": "meter_id", - "meter_type": "meter_type", - "request_phased_readings": "request_phased_readings", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/register_meter_params_firmware_version.py b/sparkmeter/metering/_generated/models/register_meter_params_firmware_version.py deleted file mode 100644 index e6eb32f..0000000 --- a/sparkmeter/metering/_generated/models/register_meter_params_firmware_version.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -from .firmware_version import FirmwareVersion - -from .firmware_version import FirmwareVersion - -__all__ = ['RegisterMeterParamsFirmwareVersion'] - -RegisterMeterParamsFirmwareVersion: TypeAlias = FirmwareVersion | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/register_meter_params_initial_balance.py b/sparkmeter/metering/_generated/models/register_meter_params_initial_balance.py deleted file mode 100644 index 00d77c6..0000000 --- a/sparkmeter/metering/_generated/models/register_meter_params_initial_balance.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias, Union - -__all__ = ['RegisterMeterParamsInitialBalance'] - -RegisterMeterParamsInitialBalance: TypeAlias = Union[float, str] | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/reset_meter_errors_command.py b/sparkmeter/metering/_generated/models/reset_meter_errors_command.py deleted file mode 100644 index 2d2fe7d..0000000 --- a/sparkmeter/metering/_generated/models/reset_meter_errors_command.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .reset_meter_errors_command_vendor_options import ResetMeterErrorsCommandVendorOptions -from .reset_meter_errors_params import ResetMeterErrorsParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["ResetMeterErrorsCommand"] - -@dataclass -class ResetMeterErrorsCommand: - """ - Clear the meter's stored error log. Reply contract: **two-stage**. `command_accepted` - then `command_applied` once the meter ACKs. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (ResetMeterErrorsParams) - : - vendor_options (ResetMeterErrorsCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: ResetMeterErrorsParams - vendor_options: ResetMeterErrorsCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/reset_meter_errors_command_vendor_options.py b/sparkmeter/metering/_generated/models/reset_meter_errors_command_vendor_options.py deleted file mode 100644 index d6e3526..0000000 --- a/sparkmeter/metering/_generated/models/reset_meter_errors_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["ResetMeterErrorsCommandVendorOptions"] - -@dataclass -class ResetMeterErrorsCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, ResetMeterErrorsCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for ResetMeterErrorsCommandVendorOptions -def _structure_resetmetererrorscommandvendoroptions(data: dict[str, Any], _: type[ResetMeterErrorsCommandVendorOptions]) -> ResetMeterErrorsCommandVendorOptions: - """Structure hook for cattrs to handle ResetMeterErrorsCommandVendorOptions deserialization.""" - if data is None: - return ResetMeterErrorsCommandVendorOptions() - if isinstance(data, ResetMeterErrorsCommandVendorOptions): - return data - return ResetMeterErrorsCommandVendorOptions(_data=data) - - -def _unstructure_resetmetererrorscommandvendoroptions(instance: ResetMeterErrorsCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle ResetMeterErrorsCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(ResetMeterErrorsCommandVendorOptions, _structure_resetmetererrorscommandvendoroptions) -converter.register_unstructure_hook(ResetMeterErrorsCommandVendorOptions, _unstructure_resetmetererrorscommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/reset_meter_errors_params.py b/sparkmeter/metering/_generated/models/reset_meter_errors_params.py deleted file mode 100644 index 8735be6..0000000 --- a/sparkmeter/metering/_generated/models/reset_meter_errors_params.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["ResetMeterErrorsParams"] - -@dataclass -class ResetMeterErrorsParams: - """ - ResetMeterErrorsParams dataclass - - Args: - meter_id (str) : - """ - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - } - key_transform_with_dump = { - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/rf_test_command.py b/sparkmeter/metering/_generated/models/rf_test_command.py deleted file mode 100644 index 9e37697..0000000 --- a/sparkmeter/metering/_generated/models/rf_test_command.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .rf_test_command_vendor_options import RfTestCommandVendorOptions -from .rf_test_params import RfTestParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["RfTestCommand"] - -@dataclass -class RfTestCommand: - """ - Run an RF test against a meter. Reply contract: **query reply**. Terminal event: - `rf_test_result`. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (RfTestParams) : - vendor_options (RfTestCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: RfTestParams - vendor_options: RfTestCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/rf_test_command_vendor_options.py b/sparkmeter/metering/_generated/models/rf_test_command_vendor_options.py deleted file mode 100644 index e9afcb8..0000000 --- a/sparkmeter/metering/_generated/models/rf_test_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["RfTestCommandVendorOptions"] - -@dataclass -class RfTestCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, RfTestCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for RfTestCommandVendorOptions -def _structure_rftestcommandvendoroptions(data: dict[str, Any], _: type[RfTestCommandVendorOptions]) -> RfTestCommandVendorOptions: - """Structure hook for cattrs to handle RfTestCommandVendorOptions deserialization.""" - if data is None: - return RfTestCommandVendorOptions() - if isinstance(data, RfTestCommandVendorOptions): - return data - return RfTestCommandVendorOptions(_data=data) - - -def _unstructure_rftestcommandvendoroptions(instance: RfTestCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle RfTestCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(RfTestCommandVendorOptions, _structure_rftestcommandvendoroptions) -converter.register_unstructure_hook(RfTestCommandVendorOptions, _unstructure_rftestcommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/rf_test_params.py b/sparkmeter/metering/_generated/models/rf_test_params.py deleted file mode 100644 index 4bbd3b1..0000000 --- a/sparkmeter/metering/_generated/models/rf_test_params.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["RfTestParams"] - -@dataclass -class RfTestParams: - """ - RfTestParams dataclass - - Args: - meter_id (str) : - duration_seconds (int | None) - : - test_mode (str | None) : Vendor-defined test mode (e.g. 'carrier', 'prbs'). - """ - meter_id: str - duration_seconds: int | None = 30 - test_mode: str | None = "carrier" # Vendor-defined test mode (e.g. 'carrier', 'prbs'). - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "duration_seconds": "duration_seconds", - "meter_id": "meter_id", - "test_mode": "test_mode", - } - key_transform_with_dump = { - "duration_seconds": "duration_seconds", - "meter_id": "meter_id", - "test_mode": "test_mode", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/rf_test_result_event.py b/sparkmeter/metering/_generated/models/rf_test_result_event.py deleted file mode 100644 index 1498a55..0000000 --- a/sparkmeter/metering/_generated/models/rf_test_result_event.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .rf_test_result_event_rssi_dbm import RfTestResultEventRssiDbm -from .stream_events_v_1_events_get_200_response_event_type_enum import StreamEventsV1EventsGet200ResponseEventTypeEnum - -__all__ = ["RfTestResultEvent"] - -@dataclass -class RfTestResultEvent: - """ - Reply to `rf_test`. - - Args: - correlation_id (str) : - event_id (int) : Monotonically increasing per-server event id. Use as the - SSE `Last-Event-ID` for resuming after a disconnect. - event_type (StreamEventsV1EventsGet200ResponseEventTypeEnum) - : - meter_id (str) : - test_mode (str) : - rssi_dbm (RfTestResultEventRssiDbm | None) - : - samples_expected (int | None) - : - samples_received (int | None) - : - """ - correlation_id: str - event_id: int # Monotonically increasing per-server event id. Use as the SSE `Last-Event-ID` for resuming after a disconnect. - event_type: StreamEventsV1EventsGet200ResponseEventTypeEnum - meter_id: str - test_mode: str - rssi_dbm: RfTestResultEventRssiDbm | None = None - samples_expected: int | None = 0 - samples_received: int | None = 0 - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - "rssi_dbm": "rssi_dbm", - "samples_expected": "samples_expected", - "samples_received": "samples_received", - "test_mode": "test_mode", - } - key_transform_with_dump = { - "correlation_id": "correlation_id", - "event_id": "event_id", - "event_type": "event_type", - "meter_id": "meter_id", - "rssi_dbm": "rssi_dbm", - "samples_expected": "samples_expected", - "samples_received": "samples_received", - "test_mode": "test_mode", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/rf_test_result_event_rssi_dbm.py b/sparkmeter/metering/_generated/models/rf_test_result_event_rssi_dbm.py deleted file mode 100644 index af56de9..0000000 --- a/sparkmeter/metering/_generated/models/rf_test_result_event_rssi_dbm.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['RfTestResultEventRssiDbm'] - -RfTestResultEventRssiDbm: TypeAlias = float | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/set_balance_command.py b/sparkmeter/metering/_generated/models/set_balance_command.py deleted file mode 100644 index fede95b..0000000 --- a/sparkmeter/metering/_generated/models/set_balance_command.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .set_balance_command_vendor_options import SetBalanceCommandVendorOptions -from .set_balance_params import SetBalanceParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["SetBalanceCommand"] - -@dataclass -class SetBalanceCommand: - """ - Update the credit value displayed at the meter. Reply contract: **cache reply**. The - provider caches the value and pushes it to the meter opportunistically (typically piggy- - backed on the next radio round-trip). Delivery is NOT confirmed by this command. To - verify a meter actually received an update, query the meter via `query_meter_config` and - inspect the echoed balance. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (SetBalanceParams): Push display credit to be shown to the customer at the - meter. The `balance` is a currency value with arbitrary - precision. The `low_balance` flag is an out-of-band - signal independent of the balance value (vendors that - can't render arbitrary balances may still use this flag - to drive a low-credit indicator). - vendor_options (SetBalanceCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: SetBalanceParams # Push display credit to be shown to the customer at the meter. The `balance` is a currency value with arbitrary precision. The `low_balance` flag is an out-of-band signal independent of the balance value (vendors that can't render arbitrary balances may still use this flag to drive a low-credit indicator). - vendor_options: SetBalanceCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/set_balance_command_vendor_options.py b/sparkmeter/metering/_generated/models/set_balance_command_vendor_options.py deleted file mode 100644 index 39ddd81..0000000 --- a/sparkmeter/metering/_generated/models/set_balance_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["SetBalanceCommandVendorOptions"] - -@dataclass -class SetBalanceCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, SetBalanceCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for SetBalanceCommandVendorOptions -def _structure_setbalancecommandvendoroptions(data: dict[str, Any], _: type[SetBalanceCommandVendorOptions]) -> SetBalanceCommandVendorOptions: - """Structure hook for cattrs to handle SetBalanceCommandVendorOptions deserialization.""" - if data is None: - return SetBalanceCommandVendorOptions() - if isinstance(data, SetBalanceCommandVendorOptions): - return data - return SetBalanceCommandVendorOptions(_data=data) - - -def _unstructure_setbalancecommandvendoroptions(instance: SetBalanceCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle SetBalanceCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(SetBalanceCommandVendorOptions, _structure_setbalancecommandvendoroptions) -converter.register_unstructure_hook(SetBalanceCommandVendorOptions, _unstructure_setbalancecommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/set_balance_params.py b/sparkmeter/metering/_generated/models/set_balance_params.py deleted file mode 100644 index d46c766..0000000 --- a/sparkmeter/metering/_generated/models/set_balance_params.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .set_balance_params_balance import SetBalanceParamsBalance - -__all__ = ["SetBalanceParams"] - -@dataclass -class SetBalanceParams: - """ - Push display credit to be shown to the customer at the meter. The `balance` is a - currency value with arbitrary precision. The `low_balance` flag is an out-of-band signal - independent of the balance value (vendors that can't render arbitrary balances may still - use this flag to drive a low-credit indicator). - - Args: - balance (SetBalanceParamsBalance) - : - meter_id (str) : - low_balance (bool | None): - """ - balance: SetBalanceParamsBalance - meter_id: str - low_balance: bool | None = False - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "balance": "balance", - "low_balance": "low_balance", - "meter_id": "meter_id", - } - key_transform_with_dump = { - "balance": "balance", - "low_balance": "low_balance", - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/set_balance_params_balance.py b/sparkmeter/metering/_generated/models/set_balance_params_balance.py deleted file mode 100644 index 63d9d2c..0000000 --- a/sparkmeter/metering/_generated/models/set_balance_params_balance.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias, Union - -__all__ = ['SetBalanceParamsBalance'] - -SetBalanceParamsBalance: TypeAlias = Union[float, str] \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/start_firmware_update_command.py b/sparkmeter/metering/_generated/models/start_firmware_update_command.py deleted file mode 100644 index 43eedcc..0000000 --- a/sparkmeter/metering/_generated/models/start_firmware_update_command.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .start_firmware_update_command_vendor_options import StartFirmwareUpdateCommandVendorOptions -from .start_firmware_update_params import StartFirmwareUpdateParams -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - -__all__ = ["StartFirmwareUpdateCommand"] - -@dataclass -class StartFirmwareUpdateCommand: - """ - Begin a broadcast firmware-update session. Reply contract: **two-stage**. Sync - `command_accepted` returns a `session_id` in `result`. Long-running progress is - observable via `query_firmware_update_status`. Terminal `command_applied` once all - targeted meters complete; `command_failed` if the session aborts. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (StartFirmwareUpdateParams) - : Begin a firmware-update session. `target_meters` is the - set of meters to upgrade; null means all registered - meters. `target_firmware_version` is what the meters - should report after upgrade. The image is identified by - `image_id`, which the provider resolves to the actual - binary (vendor-specific convention). For vendors that - need the raw bytes inline, pass them as base64 in - `vendor_options.image_bytes`. - vendor_options (StartFirmwareUpdateCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: StartFirmwareUpdateParams # Begin a firmware-update session. `target_meters` is the set of meters to upgrade; null means all registered meters. `target_firmware_version` is what the meters should report after upgrade. The image is identified by `image_id`, which the provider resolves to the actual binary (vendor-specific convention). For vendors that need the raw bytes inline, pass them as base64 in `vendor_options.image_bytes`. - vendor_options: StartFirmwareUpdateCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/start_firmware_update_command_vendor_options.py b/sparkmeter/metering/_generated/models/start_firmware_update_command_vendor_options.py deleted file mode 100644 index 66b1b22..0000000 --- a/sparkmeter/metering/_generated/models/start_firmware_update_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["StartFirmwareUpdateCommandVendorOptions"] - -@dataclass -class StartFirmwareUpdateCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, StartFirmwareUpdateCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for StartFirmwareUpdateCommandVendorOptions -def _structure_startfirmwareupdatecommandvendoroptions(data: dict[str, Any], _: type[StartFirmwareUpdateCommandVendorOptions]) -> StartFirmwareUpdateCommandVendorOptions: - """Structure hook for cattrs to handle StartFirmwareUpdateCommandVendorOptions deserialization.""" - if data is None: - return StartFirmwareUpdateCommandVendorOptions() - if isinstance(data, StartFirmwareUpdateCommandVendorOptions): - return data - return StartFirmwareUpdateCommandVendorOptions(_data=data) - - -def _unstructure_startfirmwareupdatecommandvendoroptions(instance: StartFirmwareUpdateCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle StartFirmwareUpdateCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(StartFirmwareUpdateCommandVendorOptions, _structure_startfirmwareupdatecommandvendoroptions) -converter.register_unstructure_hook(StartFirmwareUpdateCommandVendorOptions, _unstructure_startfirmwareupdatecommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/start_firmware_update_params.py b/sparkmeter/metering/_generated/models/start_firmware_update_params.py deleted file mode 100644 index e17a617..0000000 --- a/sparkmeter/metering/_generated/models/start_firmware_update_params.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .firmware_version import FirmwareVersion -from .start_firmware_update_params_target_meters import StartFirmwareUpdateParamsTargetMeters - -__all__ = ["StartFirmwareUpdateParams"] - -@dataclass -class StartFirmwareUpdateParams: - """ - Begin a firmware-update session. `target_meters` is the set of meters to upgrade; null - means all registered meters. `target_firmware_version` is what the meters should report - after upgrade. The image is identified by `image_id`, which the provider resolves to the - actual binary (vendor-specific convention). For vendors that need the raw bytes inline, - pass them as base64 in `vendor_options.image_bytes`. - - Args: - image_id (str) : - target_firmware_version (FirmwareVersion) - : Semantic version of meter firmware. - target_meters (StartFirmwareUpdateParamsTargetMeters | None) - : - """ - image_id: str - target_firmware_version: FirmwareVersion # Semantic version of meter firmware. - target_meters: StartFirmwareUpdateParamsTargetMeters | None = None - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "image_id": "image_id", - "target_firmware_version": "target_firmware_version", - "target_meters": "target_meters", - } - key_transform_with_dump = { - "image_id": "image_id", - "target_firmware_version": "target_firmware_version", - "target_meters": "target_meters", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/start_firmware_update_params_target_meters.py b/sparkmeter/metering/_generated/models/start_firmware_update_params_target_meters.py deleted file mode 100644 index c198ee3..0000000 --- a/sparkmeter/metering/_generated/models/start_firmware_update_params_target_meters.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import List, TypeAlias - -__all__ = ['StartFirmwareUpdateParamsTargetMeters'] - -StartFirmwareUpdateParamsTargetMeters: TypeAlias = List[str] | None \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/statistics_histogram.py b/sparkmeter/metering/_generated/models/statistics_histogram.py deleted file mode 100644 index 0b6db48..0000000 --- a/sparkmeter/metering/_generated/models/statistics_histogram.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["StatisticsHistogram"] - -@dataclass -class StatisticsHistogram: - """ - Five-number summary used by network-health events. Generic shape; what's being measured - (latency, RSSI, packet count, etc.) depends on the containing event. - - Args: - avg (float) : - count (int) : - last_value (float) : - max_ (float) : Maps from 'max' - min_ (float) : Maps from 'min' - """ - avg: float - count: int - last_value: float - max_: float # Maps from 'max' - min_: float # Maps from 'min' - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "avg": "avg", - "count": "count", - "last_value": "last_value", - "max": "max_", - "min": "min_", - } - key_transform_with_dump = { - "avg": "avg", - "count": "count", - "last_value": "last_value", - "max_": "max", - "min_": "min", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_200_response.py b/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_200_response.py deleted file mode 100644 index fe56bdd..0000000 --- a/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_200_response.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Annotated, TypeAlias, Union - -from .capabilities_event import CapabilitiesEvent -from .command_accepted_event import CommandAcceptedEvent -from .command_applied_event import CommandAppliedEvent -from .command_cached_event import CommandCachedEvent -from .command_failed_event import CommandFailedEvent -from .command_rejected_event import CommandRejectedEvent -from .command_timed_out_event import CommandTimedOutEvent -from .firmware_update_status_event import FirmwareUpdateStatusEvent -from .heartbeat_summary_event import HeartbeatSummaryEvent -from .log_event import LogEvent -from .meter_config_event import MeterConfigEvent -from .meter_errors_event import MeterErrorsEvent -from .meter_firmware_changed_event import MeterFirmwareChangedEvent -from .meter_instant_reading_event import MeterInstantReadingEvent -from .meter_memory_event import MeterMemoryEvent -from .meter_neighbors_event import MeterNeighborsEvent -from .meter_reading_event import MeterReadingEvent -from .meter_reading_phased_event import MeterReadingPhasedEvent -from .meter_register_event import MeterRegisterEvent -from .meter_version_event import MeterVersionEvent -from .network_health_event import NetworkHealthEvent -from .provider_status_event import ProviderStatusEvent -from .rf_test_result_event import RfTestResultEvent - -from .capabilities_event import CapabilitiesEvent -from .command_accepted_event import CommandAcceptedEvent -from .command_applied_event import CommandAppliedEvent -from .command_cached_event import CommandCachedEvent -from .command_failed_event import CommandFailedEvent -from .command_rejected_event import CommandRejectedEvent -from .command_timed_out_event import CommandTimedOutEvent -from .firmware_update_status_event import FirmwareUpdateStatusEvent -from .heartbeat_summary_event import HeartbeatSummaryEvent -from .log_event import LogEvent -from .meter_config_event import MeterConfigEvent -from .meter_errors_event import MeterErrorsEvent -from .meter_firmware_changed_event import MeterFirmwareChangedEvent -from .meter_instant_reading_event import MeterInstantReadingEvent -from .meter_memory_event import MeterMemoryEvent -from .meter_neighbors_event import MeterNeighborsEvent -from .meter_reading_event import MeterReadingEvent -from .meter_reading_phased_event import MeterReadingPhasedEvent -from .meter_register_event import MeterRegisterEvent -from .meter_version_event import MeterVersionEvent -from .network_health_event import NetworkHealthEvent -from .provider_status_event import ProviderStatusEvent -from .rf_test_result_event import RfTestResultEvent - -__all__ = ['StreamEventsV1EventsGet200Response', 'StreamEventsV1EventsGet200ResponseDiscriminator'] - -@dataclass(frozen=True) -class StreamEventsV1EventsGet200ResponseDiscriminator: - """Discriminator metadata for StreamEventsV1EventsGet200Response union.""" - - property_name: str = "event_type" - """The discriminator property name""" - - # Mapping stored as tuple for frozen dataclass compatibility - _mapping_data: tuple[tuple[str, str], ...] = ( - ("command_accepted", "CommandAcceptedEvent"), - ("command_rejected", "CommandRejectedEvent"), - ("command_applied", "CommandAppliedEvent"), - ("command_failed", "CommandFailedEvent"), - ("command_timed_out", "CommandTimedOutEvent"), - ("command_cached", "CommandCachedEvent"), - ("meter_reading", "MeterReadingEvent"), - ("meter_reading_phased", "MeterReadingPhasedEvent"), - ("meter_firmware_changed", "MeterFirmwareChangedEvent"), - ("heartbeat_summary", "HeartbeatSummaryEvent"), - ("log", "LogEvent"), - ("provider_status", "ProviderStatusEvent"), - ("network_health", "NetworkHealthEvent"), - ("capabilities", "CapabilitiesEvent"), - ("meter_neighbors", "MeterNeighborsEvent"), - ("meter_config", "MeterConfigEvent"), - ("meter_version", "MeterVersionEvent"), - ("meter_instant_reading", "MeterInstantReadingEvent"), - ("meter_errors", "MeterErrorsEvent"), - ("firmware_update_status", "FirmwareUpdateStatusEvent"), - ("rf_test_result", "RfTestResultEvent"), - ("meter_memory", "MeterMemoryEvent"), - ("meter_register", "MeterRegisterEvent"), - ) - - def get_mapping(self) -> dict[str, type]: - """Get discriminator mapping with actual type references.""" - from .command_accepted_event import CommandAcceptedEvent - from .command_rejected_event import CommandRejectedEvent - from .command_applied_event import CommandAppliedEvent - from .command_failed_event import CommandFailedEvent - from .command_timed_out_event import CommandTimedOutEvent - from .command_cached_event import CommandCachedEvent - from .meter_reading_event import MeterReadingEvent - from .meter_reading_phased_event import MeterReadingPhasedEvent - from .meter_firmware_changed_event import MeterFirmwareChangedEvent - from .heartbeat_summary_event import HeartbeatSummaryEvent - from .log_event import LogEvent - from .provider_status_event import ProviderStatusEvent - from .network_health_event import NetworkHealthEvent - from .capabilities_event import CapabilitiesEvent - from .meter_neighbors_event import MeterNeighborsEvent - from .meter_config_event import MeterConfigEvent - from .meter_version_event import MeterVersionEvent - from .meter_instant_reading_event import MeterInstantReadingEvent - from .meter_errors_event import MeterErrorsEvent - from .firmware_update_status_event import FirmwareUpdateStatusEvent - from .rf_test_result_event import RfTestResultEvent - from .meter_memory_event import MeterMemoryEvent - from .meter_register_event import MeterRegisterEvent - return { - "command_accepted": CommandAcceptedEvent, - "command_rejected": CommandRejectedEvent, - "command_applied": CommandAppliedEvent, - "command_failed": CommandFailedEvent, - "command_timed_out": CommandTimedOutEvent, - "command_cached": CommandCachedEvent, - "meter_reading": MeterReadingEvent, - "meter_reading_phased": MeterReadingPhasedEvent, - "meter_firmware_changed": MeterFirmwareChangedEvent, - "heartbeat_summary": HeartbeatSummaryEvent, - "log": LogEvent, - "provider_status": ProviderStatusEvent, - "network_health": NetworkHealthEvent, - "capabilities": CapabilitiesEvent, - "meter_neighbors": MeterNeighborsEvent, - "meter_config": MeterConfigEvent, - "meter_version": MeterVersionEvent, - "meter_instant_reading": MeterInstantReadingEvent, - "meter_errors": MeterErrorsEvent, - "firmware_update_status": FirmwareUpdateStatusEvent, - "rf_test_result": RfTestResultEvent, - "meter_memory": MeterMemoryEvent, - "meter_register": MeterRegisterEvent, - } - - -StreamEventsV1EventsGet200Response: TypeAlias = Annotated[ - Union[CommandAcceptedEvent, CommandRejectedEvent, CommandAppliedEvent, CommandFailedEvent, CommandTimedOutEvent, CommandCachedEvent, MeterReadingEvent, MeterReadingPhasedEvent, MeterFirmwareChangedEvent, HeartbeatSummaryEvent, LogEvent, ProviderStatusEvent, NetworkHealthEvent, CapabilitiesEvent, MeterNeighborsEvent, MeterConfigEvent, MeterVersionEvent, MeterInstantReadingEvent, MeterErrorsEvent, FirmwareUpdateStatusEvent, RfTestResultEvent, MeterMemoryEvent, MeterRegisterEvent], - StreamEventsV1EventsGet200ResponseDiscriminator() -] \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_200_response_event_type_enum.py b/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_200_response_event_type_enum.py deleted file mode 100644 index 6e9cfc2..0000000 --- a/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_200_response_event_type_enum.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from enum import Enum, unique - -__all__ = ["StreamEventsV1EventsGet200ResponseEventTypeEnum"] - -@unique -class StreamEventsV1EventsGet200ResponseEventTypeEnum(str, Enum): - """ - Discriminator enum for StreamEventsV1EventsGet200Response union types. - - Args: - command_accepted (str) : Value for COMMAND_ACCEPTED - command_rejected (str) : Value for COMMAND_REJECTED - command_applied (str) : Value for COMMAND_APPLIED - command_failed (str) : Value for COMMAND_FAILED - command_timed_out (str) : Value for COMMAND_TIMED_OUT - command_cached (str) : Value for COMMAND_CACHED - meter_reading (str) : Value for METER_READING - meter_reading_phased (str): Value for METER_READING_PHASED - meter_firmware_changed (str) - : Value for METER_FIRMWARE_CHANGED - heartbeat_summary (str) : Value for HEARTBEAT_SUMMARY - log (str) : Value for LOG - provider_status (str) : Value for PROVIDER_STATUS - network_health (str) : Value for NETWORK_HEALTH - capabilities (str) : Value for CAPABILITIES - meter_neighbors (str) : Value for METER_NEIGHBORS - meter_config (str) : Value for METER_CONFIG - meter_version (str) : Value for METER_VERSION - meter_instant_reading (str) - : Value for METER_INSTANT_READING - meter_errors (str) : Value for METER_ERRORS - firmware_update_status (str) - : Value for FIRMWARE_UPDATE_STATUS - rf_test_result (str) : Value for RF_TEST_RESULT - meter_memory (str) : Value for METER_MEMORY - meter_register (str) : Value for METER_REGISTER - """ - COMMAND_ACCEPTED = "command_accepted" - COMMAND_REJECTED = "command_rejected" - COMMAND_APPLIED = "command_applied" - COMMAND_FAILED = "command_failed" - COMMAND_TIMED_OUT = "command_timed_out" - COMMAND_CACHED = "command_cached" - METER_READING = "meter_reading" - METER_READING_PHASED = "meter_reading_phased" - METER_FIRMWARE_CHANGED = "meter_firmware_changed" - HEARTBEAT_SUMMARY = "heartbeat_summary" - LOG = "log" - PROVIDER_STATUS = "provider_status" - NETWORK_HEALTH = "network_health" - CAPABILITIES = "capabilities" - METER_NEIGHBORS = "meter_neighbors" - METER_CONFIG = "meter_config" - METER_VERSION = "meter_version" - METER_INSTANT_READING = "meter_instant_reading" - METER_ERRORS = "meter_errors" - FIRMWARE_UPDATE_STATUS = "firmware_update_status" - RF_TEST_RESULT = "rf_test_result" - METER_MEMORY = "meter_memory" - METER_REGISTER = "meter_register" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_param_client_id.py b/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_param_client_id.py deleted file mode 100644 index 5430750..0000000 --- a/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_param_client_id.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['StreamEventsV1EventsGetParamClientId'] - -StreamEventsV1EventsGetParamClientId: TypeAlias = str | None -"""Alias for Stable client identifier for command-reply routing. If omitted, the server assigns a fresh UUID.""" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_param_types.py b/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_param_types.py deleted file mode 100644 index 21d9cd8..0000000 --- a/sparkmeter/metering/_generated/models/stream_events_v_1_events_get_param_types.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias - -__all__ = ['StreamEventsV1EventsGetParamTypes'] - -StreamEventsV1EventsGetParamTypes: TypeAlias = str | None -"""Alias for Comma-separated event_type filter; omit for all events.""" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/submit_command_v_1_commands_post_request_body.py b/sparkmeter/metering/_generated/models/submit_command_v_1_commands_post_request_body.py deleted file mode 100644 index 63c8f0b..0000000 --- a/sparkmeter/metering/_generated/models/submit_command_v_1_commands_post_request_body.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Annotated, TypeAlias, Union - -from .associate_meter_command import AssociateMeterCommand -from .cancel_firmware_update_command import CancelFirmwareUpdateCommand -from .configure_high_capacity_meter_command import ConfigureHighCapacityMeterCommand -from .configure_meter_command import ConfigureMeterCommand -from .configure_provider_command import ConfigureProviderCommand -from .enter_configuration_mode_command import EnterConfigurationModeCommand -from .ping_meter_command import PingMeterCommand -from .query_capabilities_command import QueryCapabilitiesCommand -from .query_firmware_update_status_command import QueryFirmwareUpdateStatusCommand -from .query_meter_config_command import QueryMeterConfigCommand -from .query_meter_errors_command import QueryMeterErrorsCommand -from .query_meter_neighbors_command import QueryMeterNeighborsCommand -from .query_meter_version_command import QueryMeterVersionCommand -from .query_network_health_command import QueryNetworkHealthCommand -from .query_provider_status_command import QueryProviderStatusCommand -from .read_meter_memory_command import ReadMeterMemoryCommand -from .read_meter_now_command import ReadMeterNowCommand -from .read_meter_register_command import ReadMeterRegisterCommand -from .register_meter_command import RegisterMeterCommand -from .reset_meter_errors_command import ResetMeterErrorsCommand -from .rf_test_command import RfTestCommand -from .set_balance_command import SetBalanceCommand -from .start_firmware_update_command import StartFirmwareUpdateCommand -from .unregister_meter_command import UnregisterMeterCommand -from .write_meter_register_command import WriteMeterRegisterCommand - -from .associate_meter_command import AssociateMeterCommand -from .cancel_firmware_update_command import CancelFirmwareUpdateCommand -from .configure_high_capacity_meter_command import ConfigureHighCapacityMeterCommand -from .configure_meter_command import ConfigureMeterCommand -from .configure_provider_command import ConfigureProviderCommand -from .enter_configuration_mode_command import EnterConfigurationModeCommand -from .ping_meter_command import PingMeterCommand -from .query_capabilities_command import QueryCapabilitiesCommand -from .query_firmware_update_status_command import QueryFirmwareUpdateStatusCommand -from .query_meter_config_command import QueryMeterConfigCommand -from .query_meter_errors_command import QueryMeterErrorsCommand -from .query_meter_neighbors_command import QueryMeterNeighborsCommand -from .query_meter_version_command import QueryMeterVersionCommand -from .query_network_health_command import QueryNetworkHealthCommand -from .query_provider_status_command import QueryProviderStatusCommand -from .read_meter_memory_command import ReadMeterMemoryCommand -from .read_meter_now_command import ReadMeterNowCommand -from .read_meter_register_command import ReadMeterRegisterCommand -from .register_meter_command import RegisterMeterCommand -from .reset_meter_errors_command import ResetMeterErrorsCommand -from .rf_test_command import RfTestCommand -from .set_balance_command import SetBalanceCommand -from .start_firmware_update_command import StartFirmwareUpdateCommand -from .unregister_meter_command import UnregisterMeterCommand -from .write_meter_register_command import WriteMeterRegisterCommand - -__all__ = ['SubmitCommandV1CommandsPostRequestBody', 'SubmitCommandV1CommandsPostRequestBodyDiscriminator'] - -@dataclass(frozen=True) -class SubmitCommandV1CommandsPostRequestBodyDiscriminator: - """Discriminator metadata for SubmitCommandV1CommandsPostRequestBody union.""" - - property_name: str = "command_type" - """The discriminator property name""" - - # Mapping stored as tuple for frozen dataclass compatibility - _mapping_data: tuple[tuple[str, str], ...] = ( - ("register_meter", "RegisterMeterCommand"), - ("unregister_meter", "UnregisterMeterCommand"), - ("configure_meter", "ConfigureMeterCommand"), - ("set_balance", "SetBalanceCommand"), - ("configure_provider", "ConfigureProviderCommand"), - ("query_provider_status", "QueryProviderStatusCommand"), - ("query_network_health", "QueryNetworkHealthCommand"), - ("query_capabilities", "QueryCapabilitiesCommand"), - ("ping_meter", "PingMeterCommand"), - ("query_meter_neighbors", "QueryMeterNeighborsCommand"), - ("query_meter_config", "QueryMeterConfigCommand"), - ("query_meter_version", "QueryMeterVersionCommand"), - ("read_meter_now", "ReadMeterNowCommand"), - ("query_meter_errors", "QueryMeterErrorsCommand"), - ("reset_meter_errors", "ResetMeterErrorsCommand"), - ("associate_meter", "AssociateMeterCommand"), - ("configure_high_capacity_meter", "ConfigureHighCapacityMeterCommand"), - ("start_firmware_update", "StartFirmwareUpdateCommand"), - ("query_firmware_update_status", "QueryFirmwareUpdateStatusCommand"), - ("cancel_firmware_update", "CancelFirmwareUpdateCommand"), - ("enter_configuration_mode", "EnterConfigurationModeCommand"), - ("rf_test", "RfTestCommand"), - ("read_meter_memory", "ReadMeterMemoryCommand"), - ("read_meter_register", "ReadMeterRegisterCommand"), - ("write_meter_register", "WriteMeterRegisterCommand"), - ) - - def get_mapping(self) -> dict[str, type]: - """Get discriminator mapping with actual type references.""" - from .register_meter_command import RegisterMeterCommand - from .unregister_meter_command import UnregisterMeterCommand - from .configure_meter_command import ConfigureMeterCommand - from .set_balance_command import SetBalanceCommand - from .configure_provider_command import ConfigureProviderCommand - from .query_provider_status_command import QueryProviderStatusCommand - from .query_network_health_command import QueryNetworkHealthCommand - from .query_capabilities_command import QueryCapabilitiesCommand - from .ping_meter_command import PingMeterCommand - from .query_meter_neighbors_command import QueryMeterNeighborsCommand - from .query_meter_config_command import QueryMeterConfigCommand - from .query_meter_version_command import QueryMeterVersionCommand - from .read_meter_now_command import ReadMeterNowCommand - from .query_meter_errors_command import QueryMeterErrorsCommand - from .reset_meter_errors_command import ResetMeterErrorsCommand - from .associate_meter_command import AssociateMeterCommand - from .configure_high_capacity_meter_command import ConfigureHighCapacityMeterCommand - from .start_firmware_update_command import StartFirmwareUpdateCommand - from .query_firmware_update_status_command import QueryFirmwareUpdateStatusCommand - from .cancel_firmware_update_command import CancelFirmwareUpdateCommand - from .enter_configuration_mode_command import EnterConfigurationModeCommand - from .rf_test_command import RfTestCommand - from .read_meter_memory_command import ReadMeterMemoryCommand - from .read_meter_register_command import ReadMeterRegisterCommand - from .write_meter_register_command import WriteMeterRegisterCommand - return { - "register_meter": RegisterMeterCommand, - "unregister_meter": UnregisterMeterCommand, - "configure_meter": ConfigureMeterCommand, - "set_balance": SetBalanceCommand, - "configure_provider": ConfigureProviderCommand, - "query_provider_status": QueryProviderStatusCommand, - "query_network_health": QueryNetworkHealthCommand, - "query_capabilities": QueryCapabilitiesCommand, - "ping_meter": PingMeterCommand, - "query_meter_neighbors": QueryMeterNeighborsCommand, - "query_meter_config": QueryMeterConfigCommand, - "query_meter_version": QueryMeterVersionCommand, - "read_meter_now": ReadMeterNowCommand, - "query_meter_errors": QueryMeterErrorsCommand, - "reset_meter_errors": ResetMeterErrorsCommand, - "associate_meter": AssociateMeterCommand, - "configure_high_capacity_meter": ConfigureHighCapacityMeterCommand, - "start_firmware_update": StartFirmwareUpdateCommand, - "query_firmware_update_status": QueryFirmwareUpdateStatusCommand, - "cancel_firmware_update": CancelFirmwareUpdateCommand, - "enter_configuration_mode": EnterConfigurationModeCommand, - "rf_test": RfTestCommand, - "read_meter_memory": ReadMeterMemoryCommand, - "read_meter_register": ReadMeterRegisterCommand, - "write_meter_register": WriteMeterRegisterCommand, - } - - -SubmitCommandV1CommandsPostRequestBody: TypeAlias = Annotated[ - Union[RegisterMeterCommand, UnregisterMeterCommand, ConfigureMeterCommand, SetBalanceCommand, ConfigureProviderCommand, QueryProviderStatusCommand, QueryNetworkHealthCommand, QueryCapabilitiesCommand, PingMeterCommand, QueryMeterNeighborsCommand, QueryMeterConfigCommand, QueryMeterVersionCommand, ReadMeterNowCommand, QueryMeterErrorsCommand, ResetMeterErrorsCommand, AssociateMeterCommand, ConfigureHighCapacityMeterCommand, StartFirmwareUpdateCommand, QueryFirmwareUpdateStatusCommand, CancelFirmwareUpdateCommand, EnterConfigurationModeCommand, RfTestCommand, ReadMeterMemoryCommand, ReadMeterRegisterCommand, WriteMeterRegisterCommand], - SubmitCommandV1CommandsPostRequestBodyDiscriminator() -] \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/submit_command_v_1_commands_post_request_body_command_type_enum.py b/sparkmeter/metering/_generated/models/submit_command_v_1_commands_post_request_body_command_type_enum.py deleted file mode 100644 index ed0f0de..0000000 --- a/sparkmeter/metering/_generated/models/submit_command_v_1_commands_post_request_body_command_type_enum.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from enum import Enum, unique - -__all__ = ["SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum"] - -@unique -class SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum(str, Enum): - """ - Discriminator enum for SubmitCommandV1CommandsPostRequestBody union types. - - Args: - register_meter (str) : Value for REGISTER_METER - unregister_meter (str) : Value for UNREGISTER_METER - configure_meter (str) : Value for CONFIGURE_METER - set_balance (str) : Value for SET_BALANCE - configure_provider (str) : Value for CONFIGURE_PROVIDER - query_provider_status (str) - : Value for QUERY_PROVIDER_STATUS - query_network_health (str): Value for QUERY_NETWORK_HEALTH - query_capabilities (str) : Value for QUERY_CAPABILITIES - ping_meter (str) : Value for PING_METER - query_meter_neighbors (str) - : Value for QUERY_METER_NEIGHBORS - query_meter_config (str) : Value for QUERY_METER_CONFIG - query_meter_version (str): Value for QUERY_METER_VERSION - read_meter_now (str) : Value for READ_METER_NOW - query_meter_errors (str) : Value for QUERY_METER_ERRORS - reset_meter_errors (str) : Value for RESET_METER_ERRORS - associate_meter (str) : Value for ASSOCIATE_METER - configure_high_capacity_meter (str) - : Value for CONFIGURE_HIGH_CAPACITY_METER - start_firmware_update (str) - : Value for START_FIRMWARE_UPDATE - query_firmware_update_status (str) - : Value for QUERY_FIRMWARE_UPDATE_STATUS - cancel_firmware_update (str) - : Value for CANCEL_FIRMWARE_UPDATE - enter_configuration_mode (str) - : Value for ENTER_CONFIGURATION_MODE - rf_test (str) : Value for RF_TEST - read_meter_memory (str) : Value for READ_METER_MEMORY - read_meter_register (str): Value for READ_METER_REGISTER - write_meter_register (str): Value for WRITE_METER_REGISTER - """ - REGISTER_METER = "register_meter" - UNREGISTER_METER = "unregister_meter" - CONFIGURE_METER = "configure_meter" - SET_BALANCE = "set_balance" - CONFIGURE_PROVIDER = "configure_provider" - QUERY_PROVIDER_STATUS = "query_provider_status" - QUERY_NETWORK_HEALTH = "query_network_health" - QUERY_CAPABILITIES = "query_capabilities" - PING_METER = "ping_meter" - QUERY_METER_NEIGHBORS = "query_meter_neighbors" - QUERY_METER_CONFIG = "query_meter_config" - QUERY_METER_VERSION = "query_meter_version" - READ_METER_NOW = "read_meter_now" - QUERY_METER_ERRORS = "query_meter_errors" - RESET_METER_ERRORS = "reset_meter_errors" - ASSOCIATE_METER = "associate_meter" - CONFIGURE_HIGH_CAPACITY_METER = "configure_high_capacity_meter" - START_FIRMWARE_UPDATE = "start_firmware_update" - QUERY_FIRMWARE_UPDATE_STATUS = "query_firmware_update_status" - CANCEL_FIRMWARE_UPDATE = "cancel_firmware_update" - ENTER_CONFIGURATION_MODE = "enter_configuration_mode" - RF_TEST = "rf_test" - READ_METER_MEMORY = "read_meter_memory" - READ_METER_REGISTER = "read_meter_register" - WRITE_METER_REGISTER = "write_meter_register" \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/throttle_config.py b/sparkmeter/metering/_generated/models/throttle_config.py deleted file mode 100644 index a7568c7..0000000 --- a/sparkmeter/metering/_generated/models/throttle_config.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["ThrottleConfig"] - -@dataclass -class ThrottleConfig: - """ - Throttle behaviour applied when current/power exceeds limits. `on_seconds` and - `off_seconds` describe the duty cycle while throttling; `count_limit` is the number of - full cycles after which the meter transitions to a protective shutoff. - - Args: - count_limit (int) : - off_seconds (int) : - on_seconds (int) : - """ - count_limit: int - off_seconds: int - on_seconds: int - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "count_limit": "count_limit", - "off_seconds": "off_seconds", - "on_seconds": "on_seconds", - } - key_transform_with_dump = { - "count_limit": "count_limit", - "off_seconds": "off_seconds", - "on_seconds": "on_seconds", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/unregister_meter_command.py b/sparkmeter/metering/_generated/models/unregister_meter_command.py deleted file mode 100644 index 1ca8b61..0000000 --- a/sparkmeter/metering/_generated/models/unregister_meter_command.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum -from .unregister_meter_command_vendor_options import UnregisterMeterCommandVendorOptions -from .unregister_meter_params import UnregisterMeterParams - -__all__ = ["UnregisterMeterCommand"] - -@dataclass -class UnregisterMeterCommand: - """ - Remove a meter from the roster. Reply contract: **single reply**. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (UnregisterMeterParams) - : - vendor_options (UnregisterMeterCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: UnregisterMeterParams - vendor_options: UnregisterMeterCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/unregister_meter_command_vendor_options.py b/sparkmeter/metering/_generated/models/unregister_meter_command_vendor_options.py deleted file mode 100644 index 1d92de2..0000000 --- a/sparkmeter/metering/_generated/models/unregister_meter_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["UnregisterMeterCommandVendorOptions"] - -@dataclass -class UnregisterMeterCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, UnregisterMeterCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for UnregisterMeterCommandVendorOptions -def _structure_unregistermetercommandvendoroptions(data: dict[str, Any], _: type[UnregisterMeterCommandVendorOptions]) -> UnregisterMeterCommandVendorOptions: - """Structure hook for cattrs to handle UnregisterMeterCommandVendorOptions deserialization.""" - if data is None: - return UnregisterMeterCommandVendorOptions() - if isinstance(data, UnregisterMeterCommandVendorOptions): - return data - return UnregisterMeterCommandVendorOptions(_data=data) - - -def _unstructure_unregistermetercommandvendoroptions(instance: UnregisterMeterCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle UnregisterMeterCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(UnregisterMeterCommandVendorOptions, _structure_unregistermetercommandvendoroptions) -converter.register_unstructure_hook(UnregisterMeterCommandVendorOptions, _unstructure_unregistermetercommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/unregister_meter_params.py b/sparkmeter/metering/_generated/models/unregister_meter_params.py deleted file mode 100644 index c757027..0000000 --- a/sparkmeter/metering/_generated/models/unregister_meter_params.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["UnregisterMeterParams"] - -@dataclass -class UnregisterMeterParams: - """ - UnregisterMeterParams dataclass - - Args: - meter_id (str) : - """ - meter_id: str - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - } - key_transform_with_dump = { - "meter_id": "meter_id", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/validation_error.py b/sparkmeter/metering/_generated/models/validation_error.py deleted file mode 100644 index 3124f4e..0000000 --- a/sparkmeter/metering/_generated/models/validation_error.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .input_ import Input_ -from .validation_error_ctx import ValidationErrorCtx -from .validation_error_loc import ValidationErrorLoc - -__all__ = ["ValidationError"] - -@dataclass -class ValidationError: - """ - ValidationError dataclass - - Args: - loc (ValidationErrorLoc) : - msg (str) : - type_ (str) : Maps from 'type' - ctx (ValidationErrorCtx | None) - : - input_ (Input_ | None) : Maps from 'input' - """ - loc: ValidationErrorLoc - msg: str - type_: str # Maps from 'type' - ctx: ValidationErrorCtx | None = None - input_: Input_ | None = None # Maps from 'input' - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "ctx": "ctx", - "input": "input_", - "loc": "loc", - "msg": "msg", - "type": "type_", - } - key_transform_with_dump = { - "ctx": "ctx", - "input_": "input", - "loc": "loc", - "msg": "msg", - "type_": "type", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/validation_error_ctx.py b/sparkmeter/metering/_generated/models/validation_error_ctx.py deleted file mode 100644 index bac1cf7..0000000 --- a/sparkmeter/metering/_generated/models/validation_error_ctx.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["ValidationErrorCtx"] - -@dataclass -class ValidationErrorCtx: - """ - ValidationErrorCtx dataclass - """ - # No properties defined in schema - pass \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/validation_error_loc.py b/sparkmeter/metering/_generated/models/validation_error_loc.py deleted file mode 100644 index beb9923..0000000 --- a/sparkmeter/metering/_generated/models/validation_error_loc.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from typing import List, TypeAlias - -from .validation_error_loc_item import ValidationErrorLocItem - -from .validation_error_loc_item import ValidationErrorLocItem - -__all__ = ['ValidationErrorLoc'] - -ValidationErrorLoc: TypeAlias = List[ValidationErrorLocItem] \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/validation_error_loc_item.py b/sparkmeter/metering/_generated/models/validation_error_loc_item.py deleted file mode 100644 index 471fd26..0000000 --- a/sparkmeter/metering/_generated/models/validation_error_loc_item.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import TypeAlias, Union - -__all__ = ['ValidationErrorLocItem'] - -ValidationErrorLocItem: TypeAlias = Union[str, int] \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/write_meter_register_command.py b/sparkmeter/metering/_generated/models/write_meter_register_command.py deleted file mode 100644 index 664d109..0000000 --- a/sparkmeter/metering/_generated/models/write_meter_register_command.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from .submit_command_v_1_commands_post_request_body_command_type_enum import SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum -from .write_meter_register_command_vendor_options import WriteMeterRegisterCommandVendorOptions -from .write_meter_register_params import WriteMeterRegisterParams - -__all__ = ["WriteMeterRegisterCommand"] - -@dataclass -class WriteMeterRegisterCommand: - """ - Write a single meter register (debug primitive). Reply contract: **two-stage**. - - Args: - command_type (SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum) - : - correlation_id (str) : Client-supplied id used to correlate every event the - command produces. Must be unique within the client's - outstanding work. - params (WriteMeterRegisterParams) - : - vendor_options (WriteMeterRegisterCommandVendorOptions | None) - : Vendor-specific extension data. The generic wire ignores - this; the vendor's translator may consume it. See the - vendor's capability documentation for accepted keys. - """ - command_type: SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum - correlation_id: str # Client-supplied id used to correlate every event the command produces. Must be unique within the client's outstanding work. - params: WriteMeterRegisterParams - vendor_options: WriteMeterRegisterCommandVendorOptions | None = None # Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } - key_transform_with_dump = { - "command_type": "command_type", - "correlation_id": "correlation_id", - "params": "params", - "vendor_options": "vendor_options", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/models/write_meter_register_command_vendor_options.py b/sparkmeter/metering/_generated/models/write_meter_register_command_vendor_options.py deleted file mode 100644 index c2a1aae..0000000 --- a/sparkmeter/metering/_generated/models/write_meter_register_command_vendor_options.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -__all__ = ["WriteMeterRegisterCommandVendorOptions"] - -@dataclass -class WriteMeterRegisterCommandVendorOptions: - """ - Vendor-specific extension data. The generic wire ignores this; the vendor's translator may consume it. See the vendor's capability documentation for accepted keys. - - This class wraps arbitrary JSON objects with no defined schema, - preserving all data during serialization/deserialization. - - Example: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict, unstructure_to_dict - - # Deserialize from API response - obj = structure_from_dict({"key": "value"}, WriteMeterRegisterCommandVendorOptions) - - # Access data - print(obj["key"]) # "value" - obj["new_key"] = "new_value" - - # Serialize for API request - data = unstructure_to_dict(obj) # {"key": "value", "new_key": "new_value"} - """ - - _data: dict[str, Any] = field(default_factory=dict, repr=False) - - def get(self, key: str, default: Any = None) -> Any: - """Get value for key, returning default if key not present.""" - return self._data.get(key, default) - - def __getitem__(self, key: str) -> Any: - """Get value for key.""" - return self._data[key] - - def __setitem__(self, key: str, value: Any) -> None: - """Set value for key.""" - self._data[key] = value - - def __contains__(self, key: str) -> bool: - """Check if key exists.""" - return key in self._data - - def __bool__(self) -> bool: - """Return True if wrapper contains any data.""" - return bool(self._data) - - def keys(self) -> Any: - """Return dictionary keys.""" - return self._data.keys() - - def values(self) -> Any: - """Return dictionary values.""" - return self._data.values() - - def items(self) -> Any: - """Return dictionary items.""" - return self._data.items() - - def __iter__(self) -> Any: - """Iterate over keys.""" - return iter(self._data) - - def __len__(self) -> int: - """Return number of items.""" - return len(self._data) - - -# Register cattrs hooks for WriteMeterRegisterCommandVendorOptions -def _structure_writemeterregistercommandvendoroptions(data: dict[str, Any], _: type[WriteMeterRegisterCommandVendorOptions]) -> WriteMeterRegisterCommandVendorOptions: - """Structure hook for cattrs to handle WriteMeterRegisterCommandVendorOptions deserialization.""" - if data is None: - return WriteMeterRegisterCommandVendorOptions() - if isinstance(data, WriteMeterRegisterCommandVendorOptions): - return data - return WriteMeterRegisterCommandVendorOptions(_data=data) - - -def _unstructure_writemeterregistercommandvendoroptions(instance: WriteMeterRegisterCommandVendorOptions) -> dict[str, Any]: - """Unstructure hook for cattrs to handle WriteMeterRegisterCommandVendorOptions serialization.""" - return instance._data.copy() - - -# Register hooks with cattrs converter at module import time -from sparkmeter.metering._generated.core.cattrs_converter import converter -converter.register_structure_hook(WriteMeterRegisterCommandVendorOptions, _structure_writemeterregistercommandvendoroptions) -converter.register_unstructure_hook(WriteMeterRegisterCommandVendorOptions, _unstructure_writemeterregistercommandvendoroptions) diff --git a/sparkmeter/metering/_generated/models/write_meter_register_params.py b/sparkmeter/metering/_generated/models/write_meter_register_params.py deleted file mode 100644 index 02c4d25..0000000 --- a/sparkmeter/metering/_generated/models/write_meter_register_params.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -__all__ = ["WriteMeterRegisterParams"] - -@dataclass -class WriteMeterRegisterParams: - """ - WriteMeterRegisterParams dataclass - - Args: - meter_id (str) : - register_address (int) : - value (int) : - """ - meter_id: str - register_address: int - value: int - - class Meta: - """Configure field name mapping for JSON conversion.""" - key_transform_with_load = { - "meter_id": "meter_id", - "register_address": "register_address", - "value": "value", - } - key_transform_with_dump = { - "meter_id": "meter_id", - "register_address": "register_address", - "value": "value", - } \ No newline at end of file diff --git a/sparkmeter/metering/_generated/py.typed b/sparkmeter/metering/_generated/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/sparkmeter/metering/cli.py b/sparkmeter/metering/cli.py index aa2fbb9..13261db 100644 --- a/sparkmeter/metering/cli.py +++ b/sparkmeter/metering/cli.py @@ -1,462 +1,286 @@ -""" -Dynamic CLI generated from the metering provider's OpenAPI spec. - -Walks the discriminated `Command` union from `_generated/`, introspects -each command's `params` dataclass, and builds a click command per -operation. New commands added to the spec auto-appear in the CLI on -the next `./scripts/regen-metering-wire.sh`. - -Usage: - flask metering register-meter --meter-id 42 --meter-type SM5R - flask metering ping-meter --meter-id 42 - flask metering set-balance --meter-id 42 --balance 12.50 - flask metering configure-provider --heartbeat-seconds 900 \\ - --vendor-option channel=25 --vendor-option aes-key=00112233... - -For commands with nested dataclass params (e.g. `configure-meter`'s -`configuration` and its inner `throttle`), fields are flattened with -dotted prefixes: - - flask metering configure-meter --meter-id 42 --behavior enable \\ - --configuration.power-limit-watts 1500 \\ - --configuration.current-limit-amps 10 \\ - --configuration.throttle.on-seconds 5 \\ - --configuration.throttle.off-seconds 10 \\ - --configuration.throttle.count-limit 5 - -The CLI submits the command via the generated `APIClient`, then tails -the SSE stream for any reply matching the correlation id (up to -~10 seconds), and prints each event one per line. Exit code is 0 on -`command_accepted` / `command_applied` / typed query reply, 1 on -`command_failed` / `command_rejected` / `command_timed_out`, 2 on -HTTP error. +"""Dynamic metering CLI, built from the target driver's live /openapi.json. + + flask metering --driver [--field ...] + +The command list and each command's options are discovered from the selected +driver's OpenAPI document at invocation time: every POST/DELETE operation +becomes a subcommand, and its options come from that operation's requestBody +schema (nested objects flatten to dotted options, e.g. +`--configuration.power-limit`). A command exists only if the chosen driver +publishes it, so optional operations (ping, query-neighbors, ...) appear only +for drivers that declare them. Path parameters (e.g. node_id) become required +options and are substituted into the request path. + +`--driver` is used directly if it looks like a URL, otherwise it is treated as +a registered driver id and resolved to its base_url via provider settings. """ -import asyncio -import dataclasses -import inspect -import json import logging -import os -import types -import typing +import re import uuid -from enum import Enum -from typing import Any, AsyncIterator, Union, get_args, get_origin +from typing import Any import click import httpx -from flask.cli import with_appcontext - -from sparkmeter.metering._generated import APIClient, ClientConfig, HttpxTransport -from sparkmeter.metering._generated.models.submit_command_v_1_commands_post_request_body import ( - SubmitCommandV1CommandsPostRequestBodyDiscriminator, -) -from sparkmeter.metering._generated.models.submit_command_v_1_commands_post_request_body_command_type_enum import ( - SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum as CommandTypeEnum, -) logger = logging.getLogger(__name__) - -metering = click.Group( - "metering", - help="Metering-provider commands. Built dynamically from the OpenAPI spec.", -) +_DEFAULT_TIMEOUT = 15.0 # --------------------------------------------------------------------------- -# Type → click option mapping +# Driver resolution + OpenAPI discovery # --------------------------------------------------------------------------- -_NoneType = type(None) - - -def _unwrap_optional(tp: Any) -> tuple[Any, bool]: - """Return `(inner_type, is_optional)`. `Optional[T]` ↔ `T | None`.""" - origin = get_origin(tp) - if origin is Union or origin is types.UnionType: - args = [a for a in get_args(tp) if a is not _NoneType] - if len(args) == 1: - return args[0], True - # Non-Optional union (e.g. `float | str`); fall through to STRING input. - return args[0], _NoneType in get_args(tp) - return tp, False - - -def _resolve_param_class(command_class: type) -> type: - """The `params` field's type is the command's params dataclass. - - For commands where `params` has a default (e.g. `params: FooParams | None = None`), - `get_type_hints` returns the Union; unwrap to the dataclass. - """ - field = next(f for f in dataclasses.fields(command_class) if f.name == "params") - type_hints = typing.get_type_hints(command_class) - raw_type = type_hints[field.name] - inner, _ = _unwrap_optional(raw_type) - return inner - - -def _options_for_dataclass( - cls: type, prefix: tuple[str, ...] = () -) -> list[tuple[tuple[str, ...], dataclasses.Field, Any]]: - """Yield `(path_segments, field, resolved_type)` for every leaf field - on `cls`. Recurses into REQUIRED nested dataclasses; optional - nested dataclasses are skipped (operators can populate them via - `--vendor-option` or use a hand-tuned CLI for that operation). - """ - out: list[tuple[tuple[str, ...], dataclasses.Field, Any]] = [] - type_hints = typing.get_type_hints(cls) - for f in dataclasses.fields(cls): - raw_type = type_hints.get(f.name, f.type) - inner, was_optional = _unwrap_optional(raw_type) - if dataclasses.is_dataclass(inner): - field_has_default = ( - f.default is not dataclasses.MISSING or f.default_factory is not dataclasses.MISSING # type: ignore[misc] +def _flask_app_from_ctx(ctx: click.Context): + """Find the Flask app via the ScriptInfo the `flask` CLI stashes on ctx.obj.""" + try: + from flask.cli import ScriptInfo + except ImportError: + return None + node = ctx + while node is not None: + if isinstance(node.obj, ScriptInfo): + return node.obj.load_app() + node = node.parent + return None + + +def _resolve_driver_url(ctx: click.Context, driver: str) -> str: + """Resolve `--driver` (a URL, or a registered driver id) to a base URL.""" + if driver.startswith(("http://", "https://")): + return driver.rstrip("/") + + from flask import has_app_context + + from sparkmeter.config.provider_settings import get_provider + + if has_app_context(): + provider = get_provider(driver) + else: + app = _flask_app_from_ctx(ctx) + if app is None: + raise click.BadParameter( + "cannot resolve driver id {!r} without an app context; pass a URL".format(driver) ) - if was_optional or field_has_default: - # Skip optional nested dataclasses to keep the CLI - # surface manageable. - continue - out.extend(_options_for_dataclass(inner, prefix + (f.name,))) + with app.app_context(): + provider = get_provider(driver) + + if not provider: + raise click.BadParameter("no registered driver with id {!r}".format(driver)) + base_url = str(provider.get("base_url") or "").strip() + if not base_url: + raise click.BadParameter("driver {!r} has no base_url".format(driver)) + return base_url.rstrip("/") + + +def _fetch_openapi(base_url: str) -> dict[str, Any]: + with httpx.Client(base_url=base_url, timeout=_DEFAULT_TIMEOUT) as client: + response = client.get("/openapi.json") + response.raise_for_status() + return response.json() + + +def _spec_and_url(ctx: click.Context) -> tuple[dict[str, Any] | None, str | None]: + """Fetch (and memoize on the click ctx) the selected driver's OpenAPI doc.""" + driver = (ctx.params or {}).get("driver") + if not driver: + return None, None + cache = ctx.meta.setdefault("_metering_spec", {}) + if "spec" not in cache: + base_url = _resolve_driver_url(ctx, driver) + cache["url"] = base_url + try: + cache["spec"] = _fetch_openapi(base_url) + except Exception as exc: # noqa: BLE001 + raise click.ClickException("could not fetch {}/openapi.json: {}".format(base_url, exc)) from exc + return cache.get("spec"), cache.get("url") + + +# --------------------------------------------------------------------------- +# OpenAPI schema -> click options +# --------------------------------------------------------------------------- + + +def _resolve_ref(spec: dict, ref: str) -> dict: + node: Any = spec + for part in ref.lstrip("#/").split("/"): + node = node[part] + return node + + +def _resolve_schema(spec: dict, schema: Any) -> dict: + if not isinstance(schema, dict): + return {} + if "$ref" in schema: + return _resolve_schema(spec, _resolve_ref(spec, schema["$ref"])) + if "allOf" in schema: + merged: dict[str, Any] = {"type": "object", "properties": {}, "required": []} + for sub in schema["allOf"]: + sub = _resolve_schema(spec, sub) + merged["properties"].update(sub.get("properties", {})) + merged["required"].extend(sub.get("required", [])) + merged["properties"].update(schema.get("properties", {})) + merged["required"].extend(schema.get("required", [])) + return merged + return schema + + +def _leaf_options(spec: dict, schema: Any, prefix: tuple[str, ...] = ()): + """Yield (path_segments, prop_schema, required) for each leaf field.""" + schema = _resolve_schema(spec, schema) + out: list[tuple[tuple[str, ...], dict, bool]] = [] + required = set(schema.get("required", [])) + for name, prop in (schema.get("properties") or {}).items(): + resolved = _resolve_schema(spec, prop) + if resolved.get("type") == "object" and resolved.get("properties"): + out.extend(_leaf_options(spec, resolved, prefix + (name,))) else: - out.append((prefix + (f.name,), f, raw_type)) + out.append((prefix + (name,), resolved, name in required)) return out def _option_name(path: tuple[str, ...]) -> str: - """`('configuration', 'throttle', 'on_seconds')` → '--configuration.throttle.on-seconds'.""" return "--" + ".".join(seg.replace("_", "-") for seg in path) def _option_dest(path: tuple[str, ...]) -> str: - """`('configuration', 'throttle', 'on_seconds')` → 'configuration__throttle__on_seconds'.""" return "__".join(path) -def _click_kwargs_for(field: dataclasses.Field, ftype: Any) -> dict[str, Any]: - """Build click.option kwargs for a field of the given type.""" - is_optional = ( - field.default is not dataclasses.MISSING or field.default_factory is not dataclasses.MISSING # type: ignore[misc] - ) - # Detect a union of multiple non-None types (e.g. `float | str`). - # Operators pass the value as a string; the dataclass accepts it. - origin = get_origin(ftype) - non_none_args = [a for a in get_args(ftype) if a is not _NoneType] if origin else [] - is_real_union = (origin is Union or origin is types.UnionType) and len(non_none_args) > 1 - - inner, was_optional = _unwrap_optional(ftype) - if was_optional: - is_optional = True - ftype = inner - - kwargs: dict[str, Any] = {"required": not is_optional} - if field.default is not dataclasses.MISSING: - kwargs["default"] = field.default - elif field.default_factory is not dataclasses.MISSING: # type: ignore[misc] - kwargs["default"] = None - else: - kwargs["default"] = None - - if is_real_union: - kwargs["type"] = click.STRING - elif isinstance(ftype, type) and issubclass(ftype, Enum): - kwargs["type"] = click.Choice([e.value for e in ftype]) - elif ftype is bool: - # A pair of flag options: --foo / --no-foo. - kwargs["is_flag"] = True - elif ftype is int: +def _click_kwargs(prop: dict, required: bool) -> dict[str, Any]: + enum = prop.get("enum") + ptype = prop.get("type") + kwargs: dict[str, Any] = {"required": required, "default": None} + if enum: + kwargs["type"] = click.Choice([str(e) for e in enum]) + elif ptype == "integer": kwargs["type"] = click.INT - elif ftype is float: + elif ptype == "number": kwargs["type"] = click.FLOAT - elif ftype is bytes: + elif ptype == "boolean": + kwargs.pop("default", None) + kwargs["is_flag"] = True + kwargs["default"] = False + elif ptype == "array": + kwargs.pop("default", None) + kwargs["multiple"] = True kwargs["type"] = click.STRING - kwargs["metavar"] = "HEX" else: - # str, lists, etc. — fall back to STRING. - # list[str] gets multiple=True so operators can pass --target-meters - # several times. - if get_origin(ftype) is list: - kwargs["multiple"] = True - kwargs.pop("default", None) - if not is_optional: - kwargs["required"] = True kwargs["type"] = click.STRING - return kwargs -def _coerce_value(field: dataclasses.Field, ftype: Any, raw: Any) -> Any: - """Convert click's parsed value into the dataclass's expected type.""" - if raw is None: - return None - inner, _ = _unwrap_optional(ftype) - if isinstance(inner, type) and issubclass(inner, Enum): - return inner(raw) - if inner is bytes: - try: - return bytes.fromhex(raw) - except ValueError as exc: - raise click.BadParameter(f"--{field.name} must be valid hex") from exc - if get_origin(inner) is list: - return list(raw) - return raw +def _assemble_body(leaf_options, kwargs: dict[str, Any]) -> dict[str, Any]: + body: dict[str, Any] = {} + for path, _prop, _required in leaf_options: + value = kwargs.get(_option_dest(path)) + if value is None or (isinstance(value, tuple) and not value): + continue + node = body + for seg in path[:-1]: + node = node.setdefault(seg, {}) + node[path[-1]] = list(value) if isinstance(value, tuple) else value + return body # --------------------------------------------------------------------------- -# Vendor-options: free-form `KEY=VALUE` repeatable +# Operation -> click command # --------------------------------------------------------------------------- -def _parse_vendor_options(values: tuple[str, ...]) -> dict[str, Any]: - out: dict[str, Any] = {} - for v in values: - if "=" not in v: - raise click.BadParameter(f"--vendor-option must be KEY=VALUE; got {v!r}") - key, raw = v.split("=", 1) - # Coerce simple types: digits → int, true/false → bool, else string. - if raw.lstrip("-").isdigit(): - out[key] = int(raw) - elif raw.lower() in ("true", "false"): - out[key] = raw.lower() == "true" - else: - out[key] = raw - return out +def _kebab(operation_id: str) -> str: + return re.sub(r"(? str: - return os.environ.get("METERING_PROVIDER_URL", "http://localhost:8000") - +def _iter_command_operations(spec: dict): + """Yield (name, method, path, operation) for each POST/DELETE operation.""" + for path, methods in (spec.get("paths") or {}).items(): + if not isinstance(methods, dict): + continue + for method, operation in methods.items(): + if method.lower() not in ("post", "delete") or not isinstance(operation, dict): + continue + operation_id = operation.get("operationId") or "{}{}".format(method, path) + yield (_kebab(operation_id), method.lower(), path, operation) -def _make_client() -> tuple[APIClient, str]: - client_id = "cli-" + uuid.uuid4().hex[:8] - transport = HttpxTransport( - base_url=_provider_url(), - timeout=30.0, - default_headers={"X-Client-Id": client_id}, - ) - return APIClient(ClientConfig(base_url=_provider_url()), transport=transport), client_id - - -_TERMINAL_EVENT_TYPES = frozenset( - { - "command_accepted", - "command_applied", - "command_rejected", - "command_failed", - "command_timed_out", - "command_cached", - "provider_status", - "network_health", - "capabilities", - "meter_neighbors", - "meter_config", - "meter_version", - "meter_instant_reading", - "meter_errors", - "firmware_update_status", - "rf_test_result", - "meter_memory", - "meter_register", - } -) -_FAILURE_EVENT_TYPES = frozenset({"command_failed", "command_rejected", "command_timed_out"}) - - -async def _stream_events_raw(base_url: str, client_id: str) -> AsyncIterator[dict]: - """Stream typed events as raw dicts. - - Bypasses the generated `stream_events_v1_events_get`: that method - calls the transport's buffered `request()`, which never returns for - an infinite SSE stream. Here we use `httpx.AsyncClient.stream()` - explicitly so the response body is consumed line-by-line. - """ - headers = {"X-Client-Id": client_id, "Accept": "text/event-stream"} - async with httpx.AsyncClient(base_url=base_url, timeout=None) as client: - async with client.stream( - "GET", - "/v1/events", - params={"client_id": client_id}, - headers=headers, - ) as response: - response.raise_for_status() - data_lines: list[str] = [] - async for line in response.aiter_lines(): - if line == "": - if data_lines: - yield json.loads("\n".join(data_lines)) - data_lines = [] - elif line.startswith("data:"): - data_lines.append(line[5:].lstrip()) - - -async def _submit_and_tail(body: Any, correlation_id: str, timeout: float = 10.0) -> int: - client, client_id = _make_client() - try: - async with client: - try: - await client.default.submit_command_v1_commands_post(body) - except Exception as exc: # noqa: BLE001 - click.echo(f"submit failed: {exc!r}", err=True) - return 2 - - exit_code = 0 - saw_terminal = False - - async def _consume() -> None: - nonlocal exit_code, saw_terminal - async for raw_event in _stream_events_raw(_provider_url(), client_id): - if raw_event.get("correlation_id") != correlation_id: - continue - event_type = raw_event.get("event_type", "?") - click.echo(json.dumps(raw_event, sort_keys=True)) - if event_type in _TERMINAL_EVENT_TYPES: - saw_terminal = True - if event_type in _FAILURE_EVENT_TYPES: - exit_code = 1 - return - - try: - await asyncio.wait_for(_consume(), timeout=timeout) - except asyncio.TimeoutError: - if not saw_terminal: - click.echo("(no reply within timeout)", err=True) - exit_code = 1 - - return exit_code - finally: - await client.close() +def _path_param_dest(name: str) -> str: + return "path__" + name -# --------------------------------------------------------------------------- -# Build a click command for one Command class -# --------------------------------------------------------------------------- +def _submit(base_url: str, method: str, path: str, body: dict[str, Any]) -> None: + headers = {"X-Client-Id": "cli-" + uuid.uuid4().hex[:8]} + with httpx.Client(base_url=base_url, timeout=_DEFAULT_TIMEOUT, headers=headers) as client: + response = client.request(method.upper(), path, json=body or None) + click.echo("{} {} -> {}".format(method.upper(), path, response.status_code)) + text = response.text.strip() + if text: + click.echo(text) + if response.is_error: + raise SystemExit(1) -def _command_name(command_type_value: str) -> str: - return command_type_value.replace("_", "-") - - -def _build_click_command(command_class: type, command_type_enum_value: str) -> click.Command: - params_class = _resolve_param_class(command_class) - leaf_options = _options_for_dataclass(params_class) - type_hints_cache: dict[type, dict[str, Any]] = {} - - def _hints(cls: type) -> dict[str, Any]: - if cls not in type_hints_cache: - type_hints_cache[cls] = typing.get_type_hints(cls) - return type_hints_cache[cls] - - def _build_params(kwargs: dict[str, Any]) -> Any: - """Reconstruct the nested dataclass tree from flat kwargs. - - Mirrors `_options_for_dataclass`: only required nested - dataclasses are constructed; optional ones are left at their - default (typically None). - """ - - def build(cls: type, prefix: tuple[str, ...]) -> Any: - init_kwargs: dict[str, Any] = {} - for f in dataclasses.fields(cls): - ftype = _hints(cls).get(f.name, f.type) - inner, was_optional = _unwrap_optional(ftype) - field_has_default = ( - f.default is not dataclasses.MISSING or f.default_factory is not dataclasses.MISSING # type: ignore[misc] - ) - if dataclasses.is_dataclass(inner): - if was_optional or field_has_default: - # Skip — the optional nested dataclass stays at - # its default (None or factory result). - continue - init_kwargs[f.name] = build(inner, prefix + (f.name,)) - else: - dest = _option_dest(prefix + (f.name,)) - raw = kwargs.get(dest) - if raw is None and field_has_default: - continue - init_kwargs[f.name] = _coerce_value(f, ftype, raw) - return cls(**init_kwargs) - - return build(params_class, ()) - - @click.pass_context - def callback(ctx: click.Context, **kwargs: Any) -> None: - correlation_id = kwargs.pop("correlation_id", None) or "cli-" + uuid.uuid4().hex[:12] - vendor_option_values = kwargs.pop("vendor_option", ()) - vendor_options_dict = _parse_vendor_options(vendor_option_values) - - params = _build_params(kwargs) - - cmd_kwargs: dict[str, Any] = { - "command_type": CommandTypeEnum(command_type_enum_value), - "correlation_id": correlation_id, - "params": params, - } - # vendor_options is the same wrapper class per command type; - # construct via the cattrs structure-from-dict helper so we - # don't have to import ~20 vendor-options modules here. - if vendor_options_dict: - from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict - - vendor_options_class = next( - f for f in dataclasses.fields(command_class) if f.name == "vendor_options" - ) - inner_cls, _ = _unwrap_optional(_hints(command_class)[vendor_options_class.name]) - cmd_kwargs["vendor_options"] = structure_from_dict(vendor_options_dict, inner_cls) - - body = command_class(**cmd_kwargs) - ctx.exit(asyncio.run(_submit_and_tail(body, correlation_id))) - - callback = with_appcontext(callback) - - cmd = click.command(_command_name(command_type_enum_value), help=_command_help(command_class))(callback) - - # Apply options last-to-first so they appear in declaration order. - for path, field, ftype in reversed(leaf_options): - kwargs = _click_kwargs_for(field, ftype) - cmd = click.option(_option_name(path), _option_dest(path), **kwargs)(cmd) - cmd = click.option( - "--vendor-option", - "vendor_option", - multiple=True, - metavar="KEY=VALUE", - help="Vendor-specific option, repeatable.", - )(cmd) - cmd = click.option( - "--correlation-id", - default=None, - help="Correlation id; auto-generated if omitted.", - )(cmd) +def _build_command(spec: dict, name: str, method: str, path: str, operation: dict, base_url: str): + path_params = [p for p in operation.get("parameters", []) if p.get("in") == "path"] + body_schema = ( + operation.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema") + ) + leaf_options = _leaf_options(spec, body_schema) if body_schema else [] + + def callback(**kwargs: Any) -> None: + actual_path = path + for param in path_params: + pname = param["name"] + actual_path = actual_path.replace("{%s}" % pname, str(kwargs.pop(_path_param_dest(pname)))) + _submit(base_url, method, actual_path, _assemble_body(leaf_options, kwargs)) + + cmd = click.command(name, help=operation.get("summary") or "")(callback) + for path_seg, prop, required in reversed(leaf_options): + cmd = click.option(_option_name(path_seg), _option_dest(path_seg), **_click_kwargs(prop, required))( + cmd + ) + for param in reversed(path_params): + pname = param["name"] + cmd = click.option( + "--" + pname.replace("_", "-"), + _path_param_dest(pname), + required=True, + help=param.get("description") or "", + )(cmd) return cmd -def _command_help(command_class: type) -> str: - doc = inspect.getdoc(command_class) or "" - return doc.split("\n\n")[0] # first paragraph +class _MeteringGroup(click.Group): + """A click group whose subcommands are the selected driver's operations.""" + def list_commands(self, ctx: click.Context): + spec, _ = _spec_and_url(ctx) + if not spec: + return [] + return sorted(name for name, *_ in _iter_command_operations(spec)) -# --------------------------------------------------------------------------- -# Register one command per discriminated-union variant -# --------------------------------------------------------------------------- - - -def _register_all() -> None: - discriminator = SubmitCommandV1CommandsPostRequestBodyDiscriminator() - mapping = discriminator.get_mapping() - for command_type_value, command_class in mapping.items(): - try: - cmd = _build_click_command(command_class, command_type_value) - except Exception: # noqa: BLE001 - logger.exception("failed to build CLI for %s; skipping", command_class.__name__) - continue - metering.add_command(cmd) + def get_command(self, ctx: click.Context, name: str): + spec, base_url = _spec_and_url(ctx) + if not spec: + return None + for op_name, method, path, operation in _iter_command_operations(spec): + if op_name == name: + return _build_command(spec, name, method, path, operation, base_url) + return None -_register_all() +@click.group( + cls=_MeteringGroup, + name="metering", + help="Metering-provider commands, discovered from the target driver's /openapi.json.", +) +@click.option( + "--driver", + is_eager=True, # parse before --help so the discovered commands show in help + metavar="URL|ID", + help="Target driver base URL or registered driver id.", +) +def metering(driver: str | None) -> None: + """Root of the dynamic metering CLI; `--driver` selects the target.""" diff --git a/sparkmeter/metering/dispatch.py b/sparkmeter/metering/dispatch.py index ad42ecb..307e0ab 100644 --- a/sparkmeter/metering/dispatch.py +++ b/sparkmeter/metering/dispatch.py @@ -19,27 +19,16 @@ import asyncio import logging -import uuid from typing import Any -from sparkmeter.metering._generated import APIClient -from sparkmeter.metering._generated.models.configure_meter_command import ConfigureMeterCommand -from sparkmeter.metering._generated.models.configure_meter_params import ConfigureMeterParams -from sparkmeter.metering._generated.models.meter_behavior_command import MeterBehaviorCommand -from sparkmeter.metering._generated.models.meter_configuration import MeterConfiguration -from sparkmeter.metering._generated.models.register_meter_command import RegisterMeterCommand -from sparkmeter.metering._generated.models.register_meter_command_vendor_options import ( - RegisterMeterCommandVendorOptions, +from meter_driver_spec.http.models import ( + ConfigureElectricalMeterCompatRequest, + ElectricalMeterConfiguration, + RegisterNodeRequest, + SetBalanceAndFlagsRequest, ) -from sparkmeter.metering._generated.models.register_meter_params import RegisterMeterParams -from sparkmeter.metering._generated.models.set_balance_command import SetBalanceCommand -from sparkmeter.metering._generated.models.set_balance_params import SetBalanceParams -from sparkmeter.metering._generated.models.submit_command_v_1_commands_post_request_body_command_type_enum import ( - SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum as CommandTypeEnum, -) -from sparkmeter.metering._generated.models.throttle_config import ThrottleConfig -from sparkmeter.metering._generated.models.unregister_meter_command import UnregisterMeterCommand -from sparkmeter.metering._generated.models.unregister_meter_params import UnregisterMeterParams + +from sparkmeter.metering.runtime_client import behavior_to_command, to_spec_decimal logger = logging.getLogger(__name__) @@ -80,10 +69,16 @@ def enqueue_command(cmd: dict[str, Any]) -> bool: return False -async def command_dispatcher(client: APIClient, queue: asyncio.Queue) -> None: +async def command_dispatcher( + client, + queue: asyncio.Queue, + commands_allowed: asyncio.Event | None = None, +) -> None: """Drain the command queue, submitting each entry to the provider.""" while True: cmd = await queue.get() + if commands_allowed is not None: + await commands_allowed.wait() op = cmd.get("op") handler = _HANDLERS.get(op) if handler is None: @@ -91,105 +86,70 @@ async def command_dispatcher(client: APIClient, queue: asyncio.Queue) -> None: continue try: - body = handler(cmd) - await client.default.submit_command_v1_commands_post(body) + await handler(client, cmd) except Exception: # noqa: BLE001 logger.exception("metering dispatch: handler op=%r failed", op) -def _correlation_id(cmd: dict[str, Any]) -> str: - return cmd.get("correlation_id") or "dispatch-" + uuid.uuid4().hex[:12] - - # ---------------------------------------------------------------------- -# Op handlers — build a typed Command body from the legacy dict shape. +# Op handlers — build a spec command model and submit it (see runtime_client). # ---------------------------------------------------------------------- -_BEHAVIOR_FROM_STR = { - "none": MeterBehaviorCommand.NONE, - "enable": MeterBehaviorCommand.ENABLE, - "disable": MeterBehaviorCommand.DISABLE, - "reboot": MeterBehaviorCommand.REBOOT, - "calibrate_start": MeterBehaviorCommand.CALIBRATE_START, - "calibrate_finish": MeterBehaviorCommand.CALIBRATE_FINISH, - "enter_unprovisioned": MeterBehaviorCommand.ENTER_UNPROVISIONED, -} - - -def _build_configure_meter(cmd: dict) -> ConfigureMeterCommand: - behavior_str = (cmd.get("command") or "none").lower().strip() - behavior = _BEHAVIOR_FROM_STR.get(behavior_str, MeterBehaviorCommand.NONE) - return ConfigureMeterCommand( - command_type=CommandTypeEnum.CONFIGURE_METER, - correlation_id=_correlation_id(cmd), - params=ConfigureMeterParams( - meter_id=str(cmd["node_id"]), - behavior=behavior, - configuration=MeterConfiguration( - power_limit_watts=float(cmd.get("power_limit", 65535)), - current_limit_amps=float(cmd.get("current_limit", 65535)), - startup_delay_seconds=int(cmd.get("startup_delay", 0)), - throttle=ThrottleConfig( - on_seconds=int(cmd.get("throttle_on_time", 5)), - off_seconds=int(cmd.get("throttle_off_time", 10)), - count_limit=int(cmd.get("throttle_count_limit", 5)), - ), - ), - ), +async def _handle_register(client, cmd: dict) -> None: + await client.register_node( + RegisterNodeRequest( + node_id=int(cmd["node_id"]), + node_type=str(cmd.get("node_type", "SM5R")), + mac=int(cmd["mac"]) if cmd.get("mac") is not None else None, + ) ) -def _build_set_balance(cmd: dict) -> SetBalanceCommand: - # Balance is `float | str`; pass as string to preserve precision. - return SetBalanceCommand( - command_type=CommandTypeEnum.SET_BALANCE, - correlation_id=_correlation_id(cmd), - params=SetBalanceParams( - balance=str(cmd.get("balance", 0)), - meter_id=str(cmd["node_id"]), - low_balance=bool(cmd.get("low_balance_flag", False)), - ), +async def _handle_configure(client, cmd: dict) -> None: + command = behavior_to_command(cmd.get("command")) + if command is None: + logger.warning( + "metering dispatch: no spec command for behavior=%r on meter %s; configure dropped", + cmd.get("command"), + cmd.get("node_id"), + ) + return + await client.configure_meter( + ConfigureElectricalMeterCompatRequest( + node_id=int(cmd["node_id"]), + command=command, + configuration=ElectricalMeterConfiguration( + power_limit=float(cmd.get("power_limit", 65535)), + current_limit=float(cmd.get("current_limit", 65535)), + startup_delay=int(cmd.get("startup_delay", 0)), + throttle_on_time=int(cmd.get("throttle_on_time", 5)), + throttle_off_time=int(cmd.get("throttle_off_time", 10)), + throttle_count_limit=int(cmd.get("throttle_count_limit", 5)), + ), + ) ) -def _build_register_meter(cmd: dict) -> RegisterMeterCommand: - vendor_options = RegisterMeterCommandVendorOptions() - if cmd.get("mac") is not None: - vendor_options["mac"] = int(cmd["mac"]) - return RegisterMeterCommand( - command_type=CommandTypeEnum.REGISTER_METER, - correlation_id=_correlation_id(cmd), - vendor_options=vendor_options if vendor_options else None, - params=RegisterMeterParams( - meter_id=str(cmd["node_id"]), - meter_type=str(cmd.get("node_type", "SM5R")), +async def _handle_set_balance(client, cmd: dict) -> None: + await client.set_balance( + int(cmd["node_id"]), + SetBalanceAndFlagsRequest( + balance=to_spec_decimal(cmd.get("balance", 0)), + low_balance_flag=bool(cmd.get("low_balance_flag", False)), ), ) -def _build_unregister_meter(cmd: dict) -> UnregisterMeterCommand: - return UnregisterMeterCommand( - command_type=CommandTypeEnum.UNREGISTER_METER, - correlation_id=_correlation_id(cmd), - params=UnregisterMeterParams(meter_id=str(cmd["node_id"])), - ) - - -def _build_disable_all(cmd: dict) -> ConfigureMeterCommand: - """Fan-out broadcast as per-node configure_meter is handled at the - queue level by enqueuing individual commands. This handler isn't - used directly; `enqueue_command({"op": "disable_all", ...})` is - expanded by `dispatch.enqueue_disable_all` into per-node enqueues. - """ - raise RuntimeError("disable_all should be expanded before reaching the handler") +async def _handle_unregister(client, cmd: dict) -> None: + await client.unregister_node(int(cmd["node_id"])) _HANDLERS = { - "configure_meter": _build_configure_meter, - "set_balance": _build_set_balance, - "register_meter": _build_register_meter, - "unregister_meter": _build_unregister_meter, + "configure_meter": _handle_configure, + "set_balance": _handle_set_balance, + "register_meter": _handle_register, + "unregister_meter": _handle_unregister, } diff --git a/sparkmeter/metering/events.py b/sparkmeter/metering/events.py index 9062045..5f12b52 100644 --- a/sparkmeter/metering/events.py +++ b/sparkmeter/metering/events.py @@ -1,22 +1,22 @@ """ Event handlers consuming the metering provider's SSE stream. -The generated `stream_events_v1_events_get` yields untyped dicts. -`dispatch_dict_event` routes each into a typed dataclass via the -discriminator metadata, then invokes every registered handler. +`http_sse.stream_json_events` yields each SSE frame's `data:` payload as a +JSON dict. Per the Meter Driver Specification every frame is an envelope +`{"type": , "data": }`. `dispatch_dict_event` parses +the payload into the matching generated model +(`meter_driver_spec.http.models`) and invokes every registered handler. -Three handlers wired up by `lifespan.py`: +Two handlers wired up by `lifespan.py`: -- `build_reading_consumer(app)`: buffers `MeterReadingEvent` / - `MeterReadingPhasedEvent` and flushes them to the reading table in - batches. +- `build_reading_consumer(app)`: buffers `ElectricalMeterReading` / + `ElectricalMeterReadingPhased` and flushes them to the reading table in + batches of 50 or on a flush boundary (a `heartbeat_read_hops` frame or a + `heartbeat_statistics` frame). -- `build_log_consumer(app)`: forwards `LogEvent` into the Python - logging system. - -- `build_watchdog(app)`: observes `HeartbeatSummaryEvent`, logs - persistent network dropouts. The webapp no longer owns the - underlying provider process so the watchdog only logs. +- `build_watchdog(app)`: observes `HeartbeatStatistics`, logs persistent + network dropouts, and can request a full provider reconnect when the + active roster unexpectedly drops to zero. Each handler is `async def handler(event: Any) -> None`. """ @@ -25,16 +25,14 @@ import logging from typing import TYPE_CHECKING, Any -from sparkmeter.metering._generated.core.cattrs_converter import structure_from_dict -from sparkmeter.metering._generated.models.heartbeat_summary_event import HeartbeatSummaryEvent -from sparkmeter.metering._generated.models.log_event import LogEvent -from sparkmeter.metering._generated.models.log_level import LogLevel -from sparkmeter.metering._generated.models.meter_reading_event import MeterReadingEvent -from sparkmeter.metering._generated.models.meter_reading_phased_event import MeterReadingPhasedEvent -from sparkmeter.metering._generated.models.stream_events_v_1_events_get_200_response import ( - StreamEventsV1EventsGet200ResponseDiscriminator, +from meter_driver_spec.http.models import ( + ElectricalMeterReading, + ElectricalMeterReadingPhased, + HeartbeatStatistics, ) +from sparkmeter.meter.meterstate import MeterState + if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -47,59 +45,171 @@ # Same batch-size rationale as before: 50 amortizes per-event DB # transaction overhead at fleet scale. READING_BATCH_SIZE = 50 - -_EVENT_DISCRIMINATOR = StreamEventsV1EventsGet200ResponseDiscriminator() +READING_FLUSH_INTERVAL_SECONDS = 2.0 + +# Envelope "type" values that carry a meter reading, mapped to the model +# their "data" payload structures into. +_READING_EVENT_TYPES: dict[str, type] = { + "electrical_meter_reading": ElectricalMeterReading, + "electrical_meter_reading_phased": ElectricalMeterReadingPhased, +} + +# Envelope "type" values that are observed but not acted on beyond logging. +_SIDE_CHANNEL_TYPES = { + "gateway_status", + "node_registered", + "node_already_registered", + "node_unregistered", + "node_to_unregister_unknown", + "node_firmware_version_changed", + "invalid_electrical_meter_configuration", + "electrical_meter_configuration_accepted", + "electrical_meter_configuration_applied", + "electrical_meter_balance_and_flags_accepted", + # "sparknet_configuration_applied" is what SparkNet-Http-New's live HTTP + # SSE stream actually emits for this event (its own server-side naming, + # not something Thundercloud controls) -- kept for as long as that + # specific driver is in use. "driver_configuration_applied" is the + # vendor-neutral name from the meter-driver-spec. + "sparknet_configuration_applied", + "driver_configuration_applied", +} + +# Side-channel types worth an INFO line rather than DEBUG. +_SIDE_CHANNEL_INFO_TYPES = { + "electrical_meter_configuration_accepted", + "electrical_meter_configuration_applied", + "invalid_electrical_meter_configuration", +} + +# A `heartbeat_read_hops` frame carries no reading but marks a flush boundary. +_READING_FLUSH_MARKER = object() def build_handlers(app: "FastAPI") -> list["EventHandler"]: """Construct the handlers wired up by the SSE consumer task.""" return [ build_reading_consumer(app), - build_log_consumer(app), build_watchdog(app), ] async def dispatch_dict_event(raw: dict[str, Any], handlers: list["EventHandler"]) -> None: - """Structure a raw SSE dict into the right typed event and dispatch it.""" - event_type = raw.get("event_type") + """Parse a raw SSE envelope into the matching event and dispatch it.""" + event_type = raw.get("type") if not event_type: - logger.warning("metering SSE event missing event_type: %r", raw) + logger.warning("metering SSE event missing type: %r", raw) return - target_class = _EVENT_DISCRIMINATOR.get_mapping().get(event_type) - if target_class is None: - logger.warning("metering SSE event unknown event_type=%r", event_type) + data = raw.get("data") or {} + + reading_cls = _READING_EVENT_TYPES.get(event_type) + if reading_cls is not None: + try: + event = reading_cls.model_validate(data) + except Exception: # noqa: BLE001 + logger.exception("metering SSE reading failed to parse (type=%s)", event_type) + return + await _dispatch(event, handlers, event_type) return - try: - event = structure_from_dict(raw, target_class) - except Exception: # noqa: BLE001 - logger.exception("metering SSE event failed to structure (event_type=%s)", event_type) + + if event_type == "heartbeat_statistics": + try: + event = HeartbeatStatistics.model_validate(data) + except Exception: # noqa: BLE001 + logger.exception("metering SSE heartbeat failed to parse (type=%s)", event_type) + return + await _dispatch(event, handlers, event_type) + return + + if event_type == "heartbeat_read_hops": + await _dispatch(_READING_FLUSH_MARKER, handlers, event_type) + return + + if event_type in _SIDE_CHANNEL_TYPES: + if event_type in _SIDE_CHANNEL_INFO_TYPES: + logger.info("metering provider side-channel event observed: %s", event_type) + else: + logger.debug("metering provider side-channel event observed: %s", event_type) return + + logger.warning("metering SSE event unknown type=%r", event_type) + + +async def _dispatch(event: Any, handlers: list["EventHandler"], event_type: str) -> None: + """Invoke every handler with the event, isolating handler failures.""" for handler in handlers: try: await handler(event) except Exception: # noqa: BLE001 - logger.exception("metering event handler failed (event_type=%s)", event_type) + logger.exception("metering event handler failed (type=%s)", event_type) def build_reading_consumer(app: "FastAPI") -> "EventHandler": - """Buffer reading events and flush them to the DB in batches.""" - pending: list[MeterReadingEvent | MeterReadingPhasedEvent] = [] + """Buffer reading events and flush them at batch size or heartbeat boundaries.""" + pending: list[ElectricalMeterReading | ElectricalMeterReadingPhased] = [] + flush_lock = asyncio.Lock() + flask_app = getattr(app.state, "flask_app", None) if app is not None else None + flush_task: asyncio.Task | None = None + + async def flush_pending() -> None: + nonlocal flush_task + async with flush_lock: + if not pending: + flush_task = None + return + batch = list(pending) + pending.clear() + flush_task = None + await _flush_readings(batch, flask_app) + + async def cancel_flush_task() -> None: + nonlocal flush_task + task = flush_task + if task is None or task.done(): + return + flush_task = None + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + async def delayed_flush() -> None: + try: + await asyncio.sleep(READING_FLUSH_INTERVAL_SECONDS) + await flush_pending() + except asyncio.CancelledError: + raise async def consumer(event: Any) -> None: - if not isinstance(event, (MeterReadingEvent, MeterReadingPhasedEvent)): + nonlocal flush_task + if event is _READING_FLUSH_MARKER or isinstance(event, HeartbeatStatistics): + await cancel_flush_task() + await flush_pending() return - pending.append(event) - if len(pending) >= READING_BATCH_SIZE: - batch = list(pending) - pending.clear() - await _flush_readings(batch) + if not isinstance(event, (ElectricalMeterReading, ElectricalMeterReadingPhased)): + return + should_flush = False + should_start_timer = False + async with flush_lock: + pending.append(event) + should_flush = len(pending) >= READING_BATCH_SIZE + should_start_timer = not should_flush and (flush_task is None or flush_task.done()) + if should_start_timer: + flush_task = asyncio.create_task( + delayed_flush(), + name="metering-reading-flush", + ) + if should_flush: + await cancel_flush_task() + await flush_pending() return consumer async def _flush_readings( - batch: list[MeterReadingEvent | MeterReadingPhasedEvent], + batch: list[ElectricalMeterReading | ElectricalMeterReadingPhased], + flask_app, ) -> None: """Persist a batch of reading events to the DB. @@ -107,120 +217,90 @@ async def _flush_readings( so the event loop stays responsive. """ try: - await asyncio.to_thread(_write_readings_sync, batch) + await asyncio.to_thread(_write_readings_sync, batch, flask_app) except Exception: # noqa: BLE001 logger.exception("metering reading flush failed (batch size=%d)", len(batch)) def _write_readings_sync( - batch: list[MeterReadingEvent | MeterReadingPhasedEvent], + batch: list[ElectricalMeterReading | ElectricalMeterReadingPhased], + flask_app, ) -> None: - from flask import current_app - from sparkmeter.controller import add_reading from sparkmeter.exceptions import DatabaseLockTimeoutException, DuplicateReadingException from sparkmeter.misc.datetimeutils import datetime_from_timestamp_string - with current_app.app_context(): + if flask_app is None: + raise RuntimeError( + "metering reading flush requires a Flask app context; app.state.flask_app is not set" + ) + + with flask_app.app_context(): for event in batch: try: if not (event.period_start and event.period_end): logger.warning( - "discarding reading from meter %s: missing heartbeat timestamps", - event.meter_id, + "discarding reading from meter %s: incomplete heartbeat window " + "(period_start=%r, period_end=%r)", + event.node_id, + event.period_start, + event.period_end, ) continue heartbeat_start = datetime_from_timestamp_string(event.period_start) heartbeat_end = datetime_from_timestamp_string(event.period_end) - if isinstance(event, MeterReadingPhasedEvent): - agg = event.aggregate - reading_data = dict( - meter=int(event.meter_id), - state=event.state.value if event.state else "unknown", - uptime=int(event.uptime_seconds), - heartbeat_start=heartbeat_start, - heartbeat_end=heartbeat_end, - frequency=int(agg.frequency_hz), - voltage_min=int(agg.voltage_min), - voltage_max=int(agg.voltage_max), - voltage_avg=int(agg.voltage_avg), - current_min=int(agg.current_min_amps), - current_max=int(agg.current_max_amps), - current_avg=int(agg.current_avg_amps), - energy=int(event.energy_wh), - true_power_inst=int(agg.true_power_inst_watts), - true_power_avg=int(agg.true_power_avg_watts), - apparent_power_avg=int(agg.apparent_power_avg_va), - power_factor_avg=int(agg.power_factor_avg), - user_power_limit=int(event.user_power_limit_watts), - ) - else: - reading_data = dict( - meter=int(event.meter_id), - state=event.state.value if event.state else "unknown", - uptime=int(event.uptime_seconds), - heartbeat_start=heartbeat_start, - heartbeat_end=heartbeat_end, - frequency=int(event.frequency_hz), - voltage_min=int(event.voltage_min), - voltage_max=int(event.voltage_max), - voltage_avg=int(event.voltage_avg), - current_min=int(event.current_min_amps), - current_max=int(event.current_max_amps), - current_avg=int(event.current_avg_amps), - energy=int(event.energy_wh), - true_power_inst=int(event.true_power_inst_watts), - true_power_avg=int(event.true_power_avg_watts), - apparent_power_avg=int(event.apparent_power_avg_va), - power_factor_avg=int(event.power_factor_avg), - user_power_limit=int(event.user_power_limit_watts), - ) + # The phased variant carries the same aggregate figures at the + # top level as the non-phased one (plus per-phase fields the + # reading table does not store), so both write identically. + # The spec's ElectricalMeterState ids are sparkmeter's MeterState + # ids (both -1..13); add_reading takes the state *name*. + reading_data = dict( + meter=int(event.node_id), + state=MeterState.get_state_name_from_id(int(event.state.value)), + uptime=int(event.uptime_secs), + heartbeat_start=heartbeat_start, + heartbeat_end=heartbeat_end, + frequency=float(event.frequency), + voltage_min=float(event.voltage_min), + voltage_max=float(event.voltage_max), + voltage_avg=float(event.voltage_avg), + current_min=float(event.current_min), + current_max=float(event.current_max), + current_avg=float(event.current_avg), + energy=float(event.energy), + true_power_inst=float(event.true_power_inst), + true_power_avg=float(event.true_power_avg), + apparent_power_avg=float(event.apparent_power_avg), + power_factor_avg=float(event.power_factor_avg), + user_power_limit=float(event.user_power_limit), + ) try: - add_reading(reading_data) + add_reading(reading_data, update_meter_state=False) except DatabaseLockTimeoutException: logger.error( "discarding reading from meter %s: db lock timeout", - event.meter_id, + event.node_id, ) - current_app.sentry.captureException( - message=f"Meter {event.meter_id} reading lock timeout", + flask_app.sentry.captureException( + message=f"Meter {event.node_id} reading lock timeout", tags={"action": "reading"}, ) except DuplicateReadingException: - logger.warning("discarding duplicate reading from meter %s", event.meter_id) + logger.warning("discarding duplicate reading from meter %s", event.node_id) except Exception: # noqa: BLE001 logger.exception( - "failed to write reading for meter_id=%s", - getattr(event, "meter_id", "?"), + "failed to write reading for node_id=%s", + getattr(event, "node_id", "?"), ) -def build_log_consumer(app: "FastAPI") -> "EventHandler": - """Forwards `LogEvent` into the Python logging system.""" - - level_map = { - LogLevel.TRACE: logging.DEBUG, - LogLevel.DEBUG: logging.DEBUG, - LogLevel.INFO: logging.INFO, - LogLevel.WARN: logging.WARNING, - LogLevel.ERROR: logging.ERROR, - } - provider_logger = logging.getLogger("sparkmeter.metering.provider") - - async def consumer(event: Any) -> None: - if not isinstance(event, LogEvent): - return - provider_logger.log(level_map.get(event.level, logging.INFO), event.message) - - return consumer - - def build_watchdog(app: "FastAPI") -> "EventHandler": """Observe heartbeat summaries; log persistent dropouts. - The webapp no longer owns the provider process; the watchdog only - logs. Tunables: + The webapp no longer owns the provider process, but it can still + request a reconnect when the provider unexpectedly reports an empty + roster after previously seeing active meters. Tunables: METERING_WATCHDOG_MIN_NODES minimum registered-node count before the dropout check is meaningful (default 10) @@ -233,18 +313,44 @@ def build_watchdog(app: "FastAPI") -> "EventHandler": min_nodes_for_check = int(os.environ.get("METERING_WATCHDOG_MIN_NODES", "10")) max_consecutive_dropouts = int(os.environ.get("METERING_WATCHDOG_MAX_DROPOUTS", "3")) - state = {"consecutive_dropouts": 0, "warned": False} + state = { + "consecutive_dropouts": 0, + "warned": False, + "saw_registered_meters": False, + "restart_requested": False, + } async def watchdog(event: Any) -> None: - if not isinstance(event, HeartbeatSummaryEvent): + if not isinstance(event, HeartbeatStatistics): return - if event.total_registered_meters < min_nodes_for_check: + registered = event.total_registered_nodes + attempted = event.nodes_reached_out_to_in_current_heartbeat + responded = event.nodes_heard_from_in_current_heartbeat + + logger.info( + "metering heartbeat summary: registered=%d attempted=%d responded=%d", + registered, + attempted, + responded, + ) + + if registered > 0: + state["saw_registered_meters"] = True + state["restart_requested"] = False + elif state["saw_registered_meters"] and not state["restart_requested"]: + logger.warning("metering watchdog: provider roster dropped to zero; scheduling reconcile") + gateway_state = getattr(getattr(app, "state", None), "metering_gateway_state", None) + if gateway_state is not None: + gateway_state["needs_full_restart"] = True + state["restart_requested"] = True + + if registered < min_nodes_for_check: state["consecutive_dropouts"] = 0 state["warned"] = False return - all_dropped = event.meters_attempted > 0 and event.meters_responded == 0 + all_dropped = attempted > 0 and responded == 0 if all_dropped: state["consecutive_dropouts"] += 1 else: diff --git a/sparkmeter/metering/http_sse.py b/sparkmeter/metering/http_sse.py new file mode 100644 index 0000000..883d032 --- /dev/null +++ b/sparkmeter/metering/http_sse.py @@ -0,0 +1,58 @@ +"""Shared helpers for consuming a driver's HTTP SSE event stream. + +Plain SSE parsing (RFC 8895 §9.2 "event stream" framing) — no vendor +package involved. Works against any driver implementing the Thunder-Cloud +2.0 Open Source Meter Driver Specification's required GET /v1/events +endpoint. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from typing import Any + +import httpx + + +async def _iter_sse_data_payloads(response: httpx.Response) -> AsyncIterator[str]: + """Yield each SSE event's joined `data:` payload as one string. + + A minimal, dependency-free SSE parser: accumulates consecutive + `data:` lines for one event, and yields the joined payload when a + blank line (the event terminator) is seen. `event:`/`id:`/`retry:` + lines and comment lines (starting with `:`) are ignored — this + codebase only cares about the JSON payload in `data:`. + """ + data_lines: list[str] = [] + async for line in response.aiter_lines(): + if line == "": + if data_lines: + yield "\n".join(data_lines) + data_lines = [] + continue + if line.startswith(":"): + continue + if line.startswith("data:"): + data_lines.append(line[len("data:") :].lstrip(" ")) + # Other SSE fields (event:, id:, retry:) aren't needed here. + if data_lines: + yield "\n".join(data_lines) + + +async def stream_json_events( + base_url: str, + client_id: str, +) -> AsyncIterator[dict[str, Any]]: + """Yield JSON event payloads from the driver's HTTP SSE endpoint.""" + headers = {"X-Client-Id": client_id, "Accept": "text/event-stream"} + async with httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=None) as client: + async with client.stream( + "GET", + "/v1/events", + params={"client_id": client_id}, + headers=headers, + ) as response: + response.raise_for_status() + async for payload in _iter_sse_data_payloads(response): + yield json.loads(payload) diff --git a/sparkmeter/metering/lifespan.py b/sparkmeter/metering/lifespan.py index 45e6cdd..2de590d 100644 --- a/sparkmeter/metering/lifespan.py +++ b/sparkmeter/metering/lifespan.py @@ -1,10 +1,11 @@ """ FastAPI lifespan piece for the metering-provider integration. -On ground deployments the lifespan opens an httpx-backed APIClient -against the metering provider, runs the startup reconcile, starts the -SSE consumer task, and hosts the dispatcher. On cloud deployments the -radio code paths are dormant and this lifespan is a no-op. +On ground deployments the lifespan opens the selected command/event +transports against the metering provider, runs the startup reconcile, +starts the event consumer task, and hosts the dispatcher. On cloud +deployments the radio code paths are dormant and this lifespan is a +no-op. Other code pulls the client from `app.state.metering` (which is `None` in cloud mode). @@ -13,92 +14,349 @@ import asyncio import logging import os +import sys import uuid from collections.abc import AsyncIterator +from concurrent.futures import TimeoutError as FutureTimeoutError from contextlib import asynccontextmanager +import httpx from fastapi import FastAPI -from sparkmeter.metering._generated import APIClient, ClientConfig, HttpxTransport +from sparkmeter.metering.provider_config import configured_provider_url +from sparkmeter.metering.runtime_client import build_command_client, build_event_client logger = logging.getLogger(__name__) +_GATEWAY_RECOVERY_POLL_SECONDS = 5.0 -@asynccontextmanager -async def metering_lifespan(app: FastAPI) -> AsyncIterator[None]: - """Owns the metering-provider connection on ground; no-op on cloud.""" - from sparkmeter.config.configdict import config +class _ProviderRemovedDuringRecovery(RuntimeError): + """Raised when the configured driver disappears during gateway recovery.""" + + +def _is_ground() -> bool: + """Whether this deployment should drive a meter network.""" + try: + from sparkmeter.config.configdict import config + + return config.is_ground() + except Exception: # noqa: BLE001 + return os.environ.get("SPARKMETER_MODE", "ground") == "ground" + + +def _metering_enabled() -> bool: + """Whether metering-provider startup should run for this process.""" + try: + from sparkmeter.config.configdict import config + + return not bool(config.get("OFFLINE", False)) + except Exception: # noqa: BLE001 + return os.environ.get("SM_OFFLINE", "").lower() not in {"1", "true", "yes", "on"} + + +def _initialize_configured_providers_with_app_context(flask_app, timeout): + """Run the startup init pass inside an explicit Flask app context.""" + from sparkmeter.config.provider_settings import initialize_configured_providers_on_startup + + if flask_app is None: + raise RuntimeError( + "metering lifespan: app.state.flask_app is not set; the ASGI " + "entrypoint must stash the Flask app there before startup init runs" + ) + + with flask_app.app_context(): + return initialize_configured_providers_on_startup(timeout) + + +def _enabled_provider_signature(flask_app): + """Return a stable signature for the currently enabled provider.""" + if flask_app is None: + return None + + try: + from sparkmeter.config.provider_settings import get_enabled_provider + + with flask_app.app_context(): + provider = get_enabled_provider() + except Exception: # noqa: BLE001 + logger.exception("failed to load enabled provider signature") + return None + + if provider is None: + return None + + return _provider_signature(provider) + + +def _provider_signature(provider): + """Return a stable signature tuple for a provider record.""" + if provider is None: + return None + + return ( + str(provider.get("id") or ""), + str(provider.get("base_url") or ""), + str(provider.get("selected_interface") or ""), + bool(provider.get("enabled")), + ) + + +async def shutdown_metering_runtime(app: FastAPI) -> None: + """Stop the live metering runtime owned by this FastAPI app.""" + from sparkmeter.metering import dispatch + + dispatch.unregister_loop() + + dispatcher_task = getattr(app.state, "metering_dispatcher_task", None) + sse_task = getattr(app.state, "metering_sse_task", None) + for task in (dispatcher_task, sse_task): + if task is not None: + task.cancel() + for task in (dispatcher_task, sse_task): + if task is None: + continue + try: + await task + except asyncio.CancelledError: + pass + + event_client = getattr(app.state, "metering_event_client", None) + if event_client is not None: + await event_client.close() + + client = getattr(app.state, "metering", None) + if client is not None: + await client.close() - if config.is_cloud(): + app.state.metering = None + app.state.metering_client_id = None + app.state.metering_command_queue = None + app.state.metering_gateway_state = None + app.state.metering_dispatcher_task = None + app.state.metering_sse_task = None + app.state.metering_event_client = None + app.state.metering_provider_signature = None + + +async def ensure_metering_runtime( + app: FastAPI, + *, + skip_provider_init: bool = False, +) -> bool: + """Ensure live metering is active for the currently configured provider.""" + if not _is_ground(): app.state.metering = None - yield - return + return False - base_url = os.environ.get("METERING_PROVIDER_URL", "http://localhost:8000") - client_id = "webapp-" + uuid.uuid4().hex[:8] + if not _metering_enabled(): + logger.info("metering provider disabled in offline mode; skipping startup") + app.state.metering = None + return False + + flask_app = getattr(app.state, "flask_app", None) + desired_signature = _enabled_provider_signature(flask_app) + existing_client = getattr(app.state, "metering", None) + running_signature = getattr(app.state, "metering_provider_signature", None) + + if ( + existing_client is not None + and running_signature == desired_signature + and desired_signature is not None + ): + logger.info("metering runtime already active; re-running init and reconcile") + await _run_provider_reconcile(app, skip_provider_init=skip_provider_init) + return True + + if existing_client is not None: + logger.info("metering runtime configuration changed; restarting live metering") + await shutdown_metering_runtime(app) + runtime_provider_initialized = False + if not skip_provider_init: + try: + init_results = await asyncio.to_thread( + _initialize_configured_providers_with_app_context, + flask_app, + 10.0, + ) + for result in init_results: + provider = result.get("provider") or {} + provider_name = provider.get("name") or provider.get("base_url") or "meter driver" + if _provider_signature(provider) == desired_signature and result.get("success"): + runtime_provider_initialized = True + if result.get("success"): + logger.info("metering startup init succeeded for %s", provider_name) + elif result.get("attempted"): + logger.warning( + "metering startup init failed for %s: %s", + provider_name, + result.get("reason") or "unknown error", + ) + else: + logger.info( + "metering startup init skipped for %s: %s", + provider_name, + result.get("reason") or "not configured", + ) + except Exception: + logger.exception("metering startup init pass failed") + + base_url = configured_provider_url(default="", flask_app=flask_app) + if not base_url: + logger.info("metering provider is not configured; skipping startup") + app.state.metering = None + return False + + client_id = "webapp-" + uuid.uuid4().hex[:8] logger.info("connecting to metering provider: %s (client_id=%s)", base_url, client_id) - transport = HttpxTransport( - base_url=base_url, - timeout=30.0, - default_headers={"X-Client-Id": client_id}, + provider = None + provider_details = None + try: + from sparkmeter.config.provider_settings import get_enabled_provider, get_live_interface_details + + with flask_app.app_context(): + provider = get_enabled_provider() + if provider is not None: + provider_details = get_live_interface_details( + provider["base_url"], + selected_interface=provider["selected_interface"], + ) + except Exception: # noqa: BLE001 + logger.exception("failed to resolve selected provider interface; falling back to HTTP") + + if provider is None: + provider = { + "base_url": base_url, + "selected_interface": "http", + } + + client = build_command_client(provider, client_id, provider_details=provider_details) + event_client = build_event_client(provider, client_id, provider_details=provider_details) + logger.info( + "metering provider command transport=%s event transport=%s", + getattr(client, "transport_name", type(client).__name__), + getattr(event_client, "transport_name", type(event_client).__name__), ) - client = APIClient(ClientConfig(base_url=base_url), transport=transport) - app.state.metering = client - app.state.metering_client_id = client_id from sparkmeter.metering import dispatch command_queue: asyncio.Queue = asyncio.Queue() + commands_allowed = asyncio.Event() + commands_allowed.set() + + app.state.metering = client + app.state.metering_client_id = client_id app.state.metering_command_queue = command_queue + app.state.metering_gateway_state = { + "gateway_connected": None, + "gateway_paused": False, + "gateway_recovery_task": None, + "needs_full_restart": False, + "commands_allowed": commands_allowed, + } + app.state.metering_event_client = event_client + app.state.metering_provider_signature = desired_signature + dispatch.register_loop(asyncio.get_running_loop(), command_queue) dispatcher_task = asyncio.create_task( - dispatch.command_dispatcher(client, command_queue), + dispatch.command_dispatcher(client, command_queue, commands_allowed=commands_allowed), name="metering-command-dispatcher", ) - - sse_task = asyncio.create_task(_run_sse_consumer(app, client, client_id), name="metering-sse-consumer") + sse_task = asyncio.create_task( + _run_sse_consumer(app, event_client, client_id), name="metering-sse-consumer" + ) + app.state.metering_dispatcher_task = dispatcher_task + app.state.metering_sse_task = sse_task try: - from sparkmeter.metering.reconcile import reconcile_all - - # The Flask app is stashed on FastAPI state in asgi.create_public_app - # so that reconcile's worker-thread DB queries can push an explicit - # app context. Without this, `current_app` would fail in the thread. - flask_app = getattr(app.state, "flask_app", None) - if flask_app is None: - raise RuntimeError( - "metering lifespan: app.state.flask_app is not set; the ASGI " - "entrypoint must stash the Flask app there before the lifespan " - "runs" - ) - - await reconcile_all(client, flask_app) + await _run_provider_reconcile( + app, + skip_provider_init=skip_provider_init or runtime_provider_initialized, + ) except Exception: logger.exception("metering reconcile failed; aborting startup") - sse_task.cancel() - dispatcher_task.cancel() - await client.close() + await shutdown_metering_runtime(app) raise + return True + + +def activate_metering_runtime_in_process( + timeout: float = 15.0, + *, + skip_provider_init: bool = False, +) -> tuple[bool, str | None]: + """Try to activate live metering inside the already-running ASGI process.""" try: + public_app = _get_running_public_app() + if public_app is None: + return False, "public app is not available" + + loop = getattr(public_app.state, "main_loop", None) + if loop is None: + return False, "main event loop is not available" + + future = asyncio.run_coroutine_threadsafe( + ensure_metering_runtime(public_app, skip_provider_init=skip_provider_init), + loop, + ) + return bool(future.result(timeout=timeout)), None + except FutureTimeoutError: + logger.exception("timed out activating live metering runtime") + return False, "timed out activating live metering runtime" + except Exception as exc: # noqa: BLE001 + logger.exception("failed to activate live metering runtime") + return False, str(exc) + + +def _get_running_public_app(): + """Return the already-running public ASGI app without importing a fresh module.""" + candidates = [] + + asgi_module = sys.modules.get("sparkmeter.asgi") + if asgi_module is not None: + candidates.append(asgi_module) + + main_module = sys.modules.get("__main__") + main_spec_name = getattr(getattr(main_module, "__spec__", None), "name", None) + if main_module is not None and main_spec_name == "sparkmeter.asgi": + candidates.append(main_module) + + for module in candidates: + public_app = getattr(module, "__dict__", {}).get("public_app") + if public_app is not None: + return public_app + + return None + + +@asynccontextmanager +async def metering_lifespan(app: FastAPI) -> AsyncIterator[None]: + """Owns the metering-provider connection on ground; no-op on cloud.""" + if not _is_ground(): + app.state.metering = None yield - finally: - dispatch.unregister_loop() - dispatcher_task.cancel() - sse_task.cancel() - for task in (dispatcher_task, sse_task): - try: - await task - except asyncio.CancelledError: - pass - await client.close() + return + + if not _metering_enabled(): + logger.info("metering provider disabled in offline mode; skipping startup") app.state.metering = None - app.state.metering_command_queue = None + yield + return + started = await ensure_metering_runtime(app) + if not started: + yield + return -async def _run_sse_consumer(app: FastAPI, client: APIClient, client_id: str) -> None: + try: + yield + finally: + await shutdown_metering_runtime(app) + + +async def _run_sse_consumer(app: FastAPI, event_client, client_id: str) -> None: """Long-lived task: read events off SSE and dispatch to handlers. Reconnects with exponential backoff on disconnect. The generated @@ -110,16 +368,186 @@ async def _run_sse_consumer(app: FastAPI, client: APIClient, client_id: str) -> handlers = build_handlers(app) backoff = 1.0 + has_connected_once = False while True: + await _run_pending_provider_restart(app) + gateway_state = getattr(app.state, "metering_gateway_state", {}) or {} try: - async for raw_event in client.default.stream_events_v1_events_get( + saw_event_this_connection = False + async for raw_event in event_client.stream_events( client_id=client_id, ): backoff = 1.0 + saw_event_this_connection = True + has_connected_once = True + await _observe_gateway_status(app, raw_event) await dispatch_dict_event(raw_event, handlers) + await _run_pending_provider_restart(app) + if has_connected_once or saw_event_this_connection: + logger.warning("metering SSE stream ended; treating provider as restarted") + gateway_state["needs_full_restart"] = True except asyncio.CancelledError: raise except Exception: # noqa: BLE001 logger.exception("metering SSE stream broke; reconnecting in %.1fs", backoff) + if has_connected_once: + gateway_state["needs_full_restart"] = True await asyncio.sleep(backoff) backoff = min(backoff * 2, 30.0) + + +async def _run_pending_provider_restart(app: FastAPI) -> None: + """Run a deferred init/reconcile pass when provider state demands it.""" + gateway_state = getattr(app.state, "metering_gateway_state", {}) or {} + if not gateway_state.get("needs_full_restart"): + return + if gateway_state.get("gateway_paused"): + logger.info("metering provider is paused for gateway recovery; deferring restart reconcile") + return + logger.warning("metering provider state changed; re-running init and reconcile") + await _run_provider_reconcile(app) + gateway_state["needs_full_restart"] = False + + +async def _run_provider_reconcile( + app: FastAPI, + *, + skip_provider_init: bool = False, +) -> None: + """Re-run full provider init and meter reconcile.""" + from sparkmeter.metering.reconcile import reconcile_all + + client = getattr(app.state, "metering", None) + flask_app = getattr(app.state, "flask_app", None) + if client is None or flask_app is None: + raise RuntimeError("metering provider reconcile requires active client and flask app") + await reconcile_all(client, flask_app, skip_provider_init=skip_provider_init) + + +async def _observe_gateway_status(app: FastAPI, raw_event) -> None: + """Pause command flow when the gateway disconnects and trigger recovery.""" + if raw_event.get("type") != "gateway_status": + return + + gateway_state = getattr(app.state, "metering_gateway_state", None) + if gateway_state is None: + return + + gateway_data = raw_event.get("data") or {} + connected = bool(gateway_data.get("connected")) + previous_connected = gateway_state.get("gateway_connected") + gateway_state["gateway_connected"] = connected + + if connected: + if previous_connected is not True: + logger.info("metering provider gateway reported connected") + if gateway_state.get("gateway_paused"): + logger.info("metering provider gateway reconnected via event stream; stopping recovery polling") + await _cancel_gateway_recovery_poll(gateway_state) + gateway_state["gateway_paused"] = False + gateway_state["needs_full_restart"] = True + gateway_state["commands_allowed"].set() + return + + if gateway_state.get("gateway_paused"): + return + + if previous_connected is not False: + logger.warning("metering provider gateway reported disconnected; pausing commands until recovery") + gateway_state["gateway_paused"] = True + gateway_state["commands_allowed"].clear() + + recovery_task = gateway_state.get("gateway_recovery_task") + if recovery_task is None or recovery_task.done(): + gateway_state["gateway_recovery_task"] = asyncio.create_task( + _recover_provider_after_gateway_loss(app), + name="metering-gateway-recovery", + ) + + +async def _cancel_gateway_recovery_poll(gateway_state) -> None: + """Stop the disconnect polling task once live gateway events say it recovered.""" + recovery_task = gateway_state.get("gateway_recovery_task") + if recovery_task is None or recovery_task.done(): + gateway_state["gateway_recovery_task"] = None + return + + recovery_task.cancel() + try: + await recovery_task + except asyncio.CancelledError: + pass + gateway_state["gateway_recovery_task"] = None + + +async def _recover_provider_after_gateway_loss(app: FastAPI) -> None: + """Poll for gateway recovery, then re-run init/reconcile before resuming commands.""" + gateway_state = getattr(app.state, "metering_gateway_state", None) + if gateway_state is None: + return + + try: + while True: + try: + await _wait_for_gateway_online(app) + except _ProviderRemovedDuringRecovery: + logger.info( + "metering provider configuration was removed during gateway recovery; " + "shutting down live metering" + ) + await shutdown_metering_runtime(app) + return + logger.info("metering provider gateway is reachable again; re-running init and reconcile") + try: + await _run_provider_reconcile(app) + except asyncio.CancelledError: + raise + except Exception: + logger.exception( + "metering provider recovery reconcile failed; retrying in %.1fs", + _GATEWAY_RECOVERY_POLL_SECONDS, + ) + await asyncio.sleep(_GATEWAY_RECOVERY_POLL_SECONDS) + continue + + gateway_state["gateway_connected"] = True + gateway_state["gateway_paused"] = False + gateway_state["needs_full_restart"] = False + gateway_state["commands_allowed"].set() + logger.info("metering provider resumed after gateway recovery") + return + except asyncio.CancelledError: + raise + finally: + latest_state = getattr(app.state, "metering_gateway_state", None) + if latest_state is not None: + latest_state["gateway_recovery_task"] = None + + +async def _wait_for_gateway_online(app: FastAPI) -> None: + """Poll the provider status endpoint until the gateway reports connected.""" + flask_app = getattr(app.state, "flask_app", None) + while True: + base_url = configured_provider_url(default="", flask_app=flask_app).rstrip("/") + if not base_url: + raise _ProviderRemovedDuringRecovery + status_url = base_url + "/v1/status" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(status_url) + response.raise_for_status() + payload = response.json() + if bool(payload.get("connected")): + return + logger.info( + "metering provider gateway still disconnected; waiting %.1fs before retry", + _GATEWAY_RECOVERY_POLL_SECONDS, + ) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 + logger.info( + "metering provider status unavailable during gateway recovery; retrying in %.1fs", + _GATEWAY_RECOVERY_POLL_SECONDS, + ) + await asyncio.sleep(_GATEWAY_RECOVERY_POLL_SECONDS) diff --git a/sparkmeter/metering/provider_config.py b/sparkmeter/metering/provider_config.py new file mode 100644 index 0000000..a4c2353 --- /dev/null +++ b/sparkmeter/metering/provider_config.py @@ -0,0 +1,21 @@ +"""Runtime access to the configured metering provider.""" + + +def configured_provider_url(default="", flask_app=None): + """Return provider URL from the saved meter driver config.""" + try: + from sparkmeter.config.provider_settings import get_enabled_provider + + if flask_app is not None: + with flask_app.app_context(): + enabled_provider = get_enabled_provider() + saved_url = ((enabled_provider or {}).get("base_url") or "").strip() + else: + enabled_provider = get_enabled_provider() + saved_url = ((enabled_provider or {}).get("base_url") or "").strip() + if saved_url: + return saved_url + except Exception: # noqa: BLE001 + pass + + return default diff --git a/sparkmeter/metering/reconcile.py b/sparkmeter/metering/reconcile.py index e831ad4..077067f 100644 --- a/sparkmeter/metering/reconcile.py +++ b/sparkmeter/metering/reconcile.py @@ -18,37 +18,26 @@ import asyncio import logging -import uuid -from decimal import Decimal +import re from typing import TYPE_CHECKING, Any, Optional -from sparkmeter.metering._generated.models.configure_meter_command import ConfigureMeterCommand -from sparkmeter.metering._generated.models.configure_meter_params import ConfigureMeterParams -from sparkmeter.metering._generated.models.configure_provider_command import ConfigureProviderCommand -from sparkmeter.metering._generated.models.configure_provider_command_vendor_options import ( - ConfigureProviderCommandVendorOptions, +from meter_driver_spec.http.models import ( + ConfigureElectricalMeterCompatRequest, + ElectricalMeterConfiguration, + RegisterNodeRequest, + SetBalanceAndFlagsRequest, ) -from sparkmeter.metering._generated.models.configure_provider_params import ConfigureProviderParams -from sparkmeter.metering._generated.models.meter_behavior_command import MeterBehaviorCommand -from sparkmeter.metering._generated.models.meter_configuration import MeterConfiguration -from sparkmeter.metering._generated.models.register_meter_command import RegisterMeterCommand -from sparkmeter.metering._generated.models.register_meter_command_vendor_options import ( - RegisterMeterCommandVendorOptions, -) -from sparkmeter.metering._generated.models.register_meter_params import RegisterMeterParams -from sparkmeter.metering._generated.models.set_balance_command import SetBalanceCommand -from sparkmeter.metering._generated.models.set_balance_params import SetBalanceParams -from sparkmeter.metering._generated.models.submit_command_v_1_commands_post_request_body_command_type_enum import ( - SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum as CommandTypeEnum, -) -from sparkmeter.metering._generated.models.throttle_config import ThrottleConfig +from past.utils import old_div + +from sparkmeter.metering.runtime_client import behavior_to_command, to_spec_decimal if TYPE_CHECKING: from flask import Flask - from sparkmeter.metering._generated import APIClient + from sparkmeter.metering.runtime_client import MeteringCommandClient logger = logging.getLogger(__name__) +_AES_KEY_HEX_RE = re.compile(r"^[0-9a-fA-F]{32}$") _KNOWN_METER_TYPES = { @@ -72,7 +61,12 @@ } -async def reconcile_all(client: "APIClient", flask_app: "Flask") -> None: +async def reconcile_all( + client: "MeteringCommandClient", + flask_app: "Flask", + *, + skip_provider_init: bool = False, +) -> None: """Read all meters from DB and register them with the provider. `flask_app` is passed in explicitly because the DB loaders below run @@ -80,27 +74,54 @@ async def reconcile_all(client: "APIClient", flask_app: "Flask") -> None: proxy is bound to a per-request thread-local that those worker threads never enter. The loaders push an explicit app context using this Flask instance. + + `skip_provider_init` is used when the caller has already issued the + vendor-specific provider init for this runtime transition and only + needs the per-meter reconcile sequence. """ logger.info("metering reconcile: starting") - provider_cmd = await asyncio.to_thread(_load_provider_command, flask_app) - if provider_cmd is not None: - await client.default.submit_command_v1_commands_post(provider_cmd) + if not skip_provider_init: + driver_init_payload = await asyncio.to_thread(_load_driver_init_payload, flask_app) + if driver_init_payload is not None: + await client.init_driver(driver_init_payload) meters_data = await asyncio.to_thread(_load_meters, flask_app) logger.info("metering reconcile: registering %d meters", len(meters_data)) for m in meters_data: try: - await client.default.submit_command_v1_commands_post(_build_register(m)) + logger.info( + "metering reconcile: registering meter_id=%s type=%s", + m.get("meter_id"), + m.get("meter_type"), + ) + await client.register_node(_build_register(m)) cfg = _build_configure(m) if cfg is not None: - await client.default.submit_command_v1_commands_post(cfg) + logger.info( + "metering reconcile: configuring meter_id=%s behavior=%s power_limit=%s current_limit=%s", + m.get("meter_id"), + cfg.command.value, + cfg.configuration.power_limit, + cfg.configuration.current_limit, + ) + await client.configure_meter(cfg) + else: + logger.warning( + "metering reconcile: skipping configure for meter_id=%s because no config payload was derived", + m.get("meter_id"), + ) balance = _build_balance(m) if balance is not None: - await client.default.submit_command_v1_commands_post(balance) + logger.info( + "metering reconcile: setting balance for meter_id=%s low_balance=%s", + m.get("meter_id"), + balance.low_balance_flag, + ) + await client.set_balance(int(m["meter_id"]), balance) except Exception: # noqa: BLE001 logger.exception("metering reconcile: failed for meter_id=%s", m.get("meter_id", "?")) @@ -112,10 +133,12 @@ async def reconcile_all(client: "APIClient", flask_app: "Flask") -> None: # ---------------------------------------------------------------------- -def _load_provider_command(flask_app: "Flask") -> Optional[ConfigureProviderCommand]: +def _load_driver_init_payload(flask_app: "Flask") -> dict[str, Any] | None: try: - from sparkmeter.config.configdict import config - from sparkmeter.config.configparameter import parameters + from sparkmeter.config.provider_settings import ( + get_enabled_provider, + load_provider_runtime_settings, + ) except ImportError: return None @@ -124,32 +147,39 @@ def _load_provider_command(flask_app: "Flask") -> Optional[ConfigureProviderComm # runs inside `asyncio.to_thread`, which spawns a fresh OS thread # with no Flask context. with flask_app.app_context(): - heartbeat = config.get("HEARTBEAT_PERIOD") - if heartbeat is None: + enabled_provider = get_enabled_provider() + driver_config = load_provider_runtime_settings(enabled_provider) + driver_field_values = ((driver_config or {}).get("field_values")) or {} + aes_key_hex = driver_field_values.get("aes_key") + if not aes_key_hex: return None - vendor_options = ConfigureProviderCommandVendorOptions() - channel = getattr(parameters, "RADIO_CHANNEL", None) - aes_key_hex = getattr(parameters, "RADIO_AES_KEY", None) + heartbeat = driver_field_values.get("heartbeat_period_duration") + if heartbeat in (None, ""): + return None + + payload: dict[str, Any] = { + "heartbeat_period_duration": int(heartbeat), + "aes_key": str(aes_key_hex).strip(), + } + channel = driver_field_values.get("channel") if channel is not None: try: - vendor_options["channel"] = int(channel) - except (TypeError, ValueError): - pass - if aes_key_hex: - try: - vendor_options["aes_key"] = str(aes_key_hex) + payload["channel"] = int(channel) except (TypeError, ValueError): - logger.warning("metering reconcile: invalid RADIO_AES_KEY, skipping") - - return ConfigureProviderCommand( - command_type=CommandTypeEnum.CONFIGURE_PROVIDER, - correlation_id="reconcile-" + uuid.uuid4().hex[:12], - vendor_options=vendor_options if vendor_options else None, - # HEARTBEAT_PERIOD is in minutes in the existing config; the - # provider takes seconds. - params=ConfigureProviderParams(heartbeat_seconds=int(heartbeat) * 60), - ) + logger.warning( + "metering reconcile: skipping invalid channel %r; expected integer", + channel, + ) + aes_key = payload["aes_key"] + if not _AES_KEY_HEX_RE.fullmatch(aes_key): + logger.warning( + "metering reconcile: skipping invalid driver AES key %r; expected 32 hex characters", + aes_key_hex, + ) + return None + + return payload def _load_meters(flask_app: "Flask") -> list[dict[str, Any]]: @@ -185,58 +215,46 @@ def _load_meters(flask_app: "Flask") -> list[dict[str, Any]]: # ---------------------------------------------------------------------- -def _build_register(m: dict) -> RegisterMeterCommand: - vendor_options = RegisterMeterCommandVendorOptions() - if m.get("mac") is not None: - vendor_options["mac"] = int(m["mac"]) - return RegisterMeterCommand( - command_type=CommandTypeEnum.REGISTER_METER, - correlation_id="reconcile-register-" + uuid.uuid4().hex[:12], - vendor_options=vendor_options if vendor_options else None, - params=RegisterMeterParams( - meter_id=str(m["meter_id"]), - meter_type=str(m.get("meter_type") or "SM5R"), - ), +def _build_register(m: dict) -> RegisterNodeRequest: + return RegisterNodeRequest( + node_id=int(m["meter_id"]), + node_type=str(m.get("meter_type") or "SM5R"), + mac=int(m["mac"]) if m.get("mac") is not None else None, ) -def _build_configure(m: dict) -> Optional[ConfigureMeterCommand]: +def _build_configure(m: dict) -> Optional[ConfigureElectricalMeterCompatRequest]: config = m.get("config") if config is None: return None - behavior = MeterBehaviorCommand.ENABLE if m.get("is_active") else MeterBehaviorCommand.DISABLE - return ConfigureMeterCommand( - command_type=CommandTypeEnum.CONFIGURE_METER, - correlation_id="reconcile-configure-" + uuid.uuid4().hex[:12], - params=ConfigureMeterParams( - meter_id=str(m["meter_id"]), - behavior=behavior, - configuration=MeterConfiguration( - power_limit_watts=float(config.get("power_limit") or 65535), - current_limit_amps=float(config.get("current_limit") or 65535), - startup_delay_seconds=int(config.get("startup_delay") or 0), - throttle=ThrottleConfig( - on_seconds=int(config.get("throttle_on_time") or 5), - off_seconds=int(config.get("throttle_off_time") or 10), - count_limit=int(config.get("throttle_count_limit") or 5), - ), - ), + behavior_name = str(config.get("behavior") or "").strip().lower() + if not behavior_name: + behavior_name = "enable" if m.get("is_active") else "disable" + behavior = "enable" if behavior_name == "enable" else "disable" + command = behavior_to_command(behavior) + if command is None: + return None + return ConfigureElectricalMeterCompatRequest( + node_id=int(m["meter_id"]), + command=command, + configuration=ElectricalMeterConfiguration( + power_limit=float(config.get("power_limit") or 65535), + current_limit=float(config.get("current_limit") or 65535), + startup_delay=int(config.get("startup_delay") or 0), + throttle_on_time=int(config.get("throttle_on_time") or 5), + throttle_off_time=int(config.get("throttle_off_time") or 10), + throttle_count_limit=int(config.get("throttle_count_limit") or 5), ), ) -def _build_balance(m: dict) -> Optional[SetBalanceCommand]: +def _build_balance(m: dict) -> Optional[SetBalanceAndFlagsRequest]: balance = m.get("balance") if balance is None: return None - return SetBalanceCommand( - command_type=CommandTypeEnum.SET_BALANCE, - correlation_id="reconcile-balance-" + uuid.uuid4().hex[:12], - params=SetBalanceParams( - balance=str(Decimal(str(balance))), - meter_id=str(m["meter_id"]), - low_balance=bool(m.get("low_balance")), - ), + return SetBalanceAndFlagsRequest( + balance=to_spec_decimal(balance), + low_balance_flag=bool(m.get("low_balance")), ) @@ -273,13 +291,43 @@ def _config_of(meter) -> dict | None: cfg = getattr(meter, "config", None) if cfg is None: return None + if not meter.is_customer_meter(): + return None + + try: + from sparkmeter.config.configparameter import parameters + from sparkmeter.meter.meterdomain import MeterConfig + except ImportError: + return None + + try: + override_meter_state = meter.ground.private.override_meter_state + nominal_voltage = parameters.NOMINAL_VOLTAGE + tariff_load_limit = meter.tariff.get_current_load_limit() + continuous_power = meter.continuous_current_limit * nominal_voltage + provider_uses_engineering_units = bool(getattr(meter, "provider_id", None)) + power_limit = min(continuous_power, tariff_load_limit) + if not provider_uses_engineering_units: + power_limit = old_div(power_limit, meter.scalars.power_scalar) + current_limit = meter.model.inrush_limit + if not provider_uses_engineering_units: + current_limit = old_div(current_limit, meter.scalars.current_scalar) + current_limit = min(current_limit, 65535) + behavior = ( + "enable" if meter.state_value == MeterConfig.STATE_ON and not override_meter_state else "disable" + ) + except Exception: # noqa: BLE001 + logger.exception("metering reconcile: failed to derive config for meter %s", meter.code) + return None + return { - "power_limit": getattr(cfg, "power_limit", None), - "current_limit": getattr(cfg, "current_limit", None), - "startup_delay": getattr(cfg, "startup_delay", None), - "throttle_on_time": getattr(cfg, "throttle_on_time", None), - "throttle_off_time": getattr(cfg, "throttle_off_time", None), - "throttle_count_limit": getattr(cfg, "throttle_count_limit", None), + "behavior": behavior, + "power_limit": float(power_limit), + "current_limit": float(current_limit), + "startup_delay": int(getattr(cfg, "startup_delay", None) or 0), + "throttle_on_time": int(getattr(cfg, "throttle_on_time", None) or 5), + "throttle_off_time": int(getattr(cfg, "throttle_off_time", None) or 10), + "throttle_count_limit": int(getattr(cfg, "throttle_count_limit", None) or 5), } diff --git a/sparkmeter/metering/runtime_client.py b/sparkmeter/metering/runtime_client.py new file mode 100644 index 0000000..477488c --- /dev/null +++ b/sparkmeter/metering/runtime_client.py @@ -0,0 +1,567 @@ +"""Transport adapters for metering command and event traffic. + +Generic HTTP+SSE and gRPC clients built directly against the Thunder-Cloud +2.0 Open Source Meter Driver Specification — not against any vendor's +package. Any driver that implements the spec's required HTTP+SSE contract +works here with zero vendor-specific code. A driver that additionally +implements the optional gRPC profile (see sparkmeter/metering/proto/) +works over gRPC too, using the client Thundercloud compiles from its own +spec-owned .proto files. + +SparkNet-Http gets no special treatment here: it's one compliant driver +instance among however many a deployment configures. Nothing in this +module imports a vendor-published client package. +""" + +from __future__ import annotations + +import asyncio +import binascii +import logging +import uuid +from decimal import Decimal, InvalidOperation +from typing import Any +from urllib.parse import urlparse + +import grpc +import httpx +from google.protobuf.json_format import MessageToDict +from google.protobuf.wrappers_pb2 import BoolValue, UInt32Value +from meter_driver_spec.grpc import meter_driver_pb2 as pb2 +from meter_driver_spec.grpc import meter_driver_pb2_grpc as pb2_grpc +from meter_driver_spec.http.models import ( + ConfigureElectricalMeterCompatRequest, + ElectricalMeterCommandName, + RegisterNodeRequest, + SetBalanceAndFlagsRequest, +) +from meter_driver_spec.http.models import ( + Decimal as SpecDecimal, +) + +from sparkmeter.metering.http_sse import stream_json_events + +logger = logging.getLogger(__name__) + +# Lowercase behavior verb -> the spec's ElectricalMeterCommandName. Verbs with +# no spec command ("none", "enter_unprovisioned") return None; callers skip +# building a configure command for those rather than guess a wire value. +_BEHAVIOR_TO_COMMAND = { + "enable": ElectricalMeterCommandName.ELECTRICALMETERCOMMANDENABLE, + "disable": ElectricalMeterCommandName.ELECTRICALMETERCOMMANDDISABLE, + "reboot": ElectricalMeterCommandName.ELECTRICALMETERCOMMANDREBOOT, + "calibrate_start": ElectricalMeterCommandName.ELECTRICALMETERCOMMANDCALIBRATESTART, + "calibrate_finish": ElectricalMeterCommandName.ELECTRICALMETERCOMMANDCALIBRATEFINISH, +} + + +def behavior_to_command(behavior: str | None) -> ElectricalMeterCommandName | None: + """Map a lowercase behavior verb to a spec ElectricalMeterCommandName (or None).""" + return _BEHAVIOR_TO_COMMAND.get((behavior or "").lower().strip()) + + +def to_spec_decimal(value: Any) -> SpecDecimal: + """Decompose a numeric/str value into the spec's {sign, coef, exp} Decimal.""" + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + decimal_value = Decimal("0") + sign, digits, exponent = decimal_value.as_tuple() + coef = 0 + for digit in digits: + coef = (coef * 10) + digit + return SpecDecimal(sign=-1 if sign else 1, coef=int(coef), exp=int(exponent)) + + +class MeteringCommandClient: + """Command-only transport abstraction.""" + + transport_name = "unknown" + + async def init_driver(self, payload: dict[str, Any]) -> None: + raise NotImplementedError + + async def register_node(self, req: RegisterNodeRequest) -> None: + raise NotImplementedError + + async def configure_meter(self, req: ConfigureElectricalMeterCompatRequest) -> None: + raise NotImplementedError + + async def set_balance(self, node_id: int, req: SetBalanceAndFlagsRequest) -> None: + raise NotImplementedError + + async def unregister_node(self, node_id: int) -> None: + raise NotImplementedError + + async def close(self) -> None: + raise NotImplementedError + + +class HttpCommandClient(MeteringCommandClient): + """Generic HTTP command client, built directly against the TC 2.0 + Open Source Meter Driver Specification's required endpoints. + + Every request uses plain dicts with the spec's documented field names — + no vendor-generated model classes. Any driver implementing the + required HTTP contract works here unmodified. + """ + + transport_name = "http" + + def __init__(self, base_url: str, client_id: str): + self._client = httpx.AsyncClient( + base_url=base_url.rstrip("/"), + timeout=30.0, + headers={"X-Client-Id": client_id}, + ) + + async def init_driver(self, payload: dict[str, Any]) -> None: + # The spec's own worked examples show POST /v1/init; the reference + # driver (SparkNet-Http-New) actually serves this at + # /v1/sparknet/init. Targeting the endpoint that's actually live. + response = await self._client.post("/v1/sparknet/init", json=payload) + response.raise_for_status() + + async def register_node(self, req: RegisterNodeRequest) -> None: + response = await self._client.post( + "/v1/nodes/register", json=req.model_dump(mode="json", exclude_none=True) + ) + response.raise_for_status() + + async def configure_meter(self, req: ConfigureElectricalMeterCompatRequest) -> None: + # Body-node_id compatibility form (POST /v1/meters/configure): node_id + # travels in the body, so this single model carries the whole request. + response = await self._client.post("/v1/meters/configure", json=req.model_dump(mode="json")) + response.raise_for_status() + + async def set_balance(self, node_id: int, req: SetBalanceAndFlagsRequest) -> None: + response = await self._client.post( + "/v1/nodes/{}/balance-and-flags".format(int(node_id)), json=req.model_dump(mode="json") + ) + response.raise_for_status() + + async def unregister_node(self, node_id: int) -> None: + response = await self._client.delete("/v1/nodes/{}".format(int(node_id))) + response.raise_for_status() + + async def close(self) -> None: + await self._client.aclose() + + +class GrpcCommandClient(MeteringCommandClient): + """Command client backed by the standard TC 2.0 meter driver gRPC + profile, compiled from Thundercloud's own proto (sparkmeter/metering/proto/) + — not imported from any driver vendor's package. + """ + + transport_name = "grpc" + + def __init__(self, target: str): + self._channel = grpc.aio.insecure_channel(target) + self._stub = pb2_grpc.MeterDriverControlStub(self._channel) + + async def init_driver(self, payload: dict[str, Any]) -> None: + request = pb2.ConfigureDriver( + heartbeat_period_duration=int(payload["heartbeat_period_duration"]), + aes_key=_aes_key_bytes(payload["aes_key"]), + ) + channel = payload.get("channel") + if channel is not None: + request.channel.CopyFrom(UInt32Value(value=int(channel))) + await self._stub.InitDriver(request) + + async def register_node(self, req: RegisterNodeRequest) -> None: + await self._stub.RegisterNode(_register_request(req)) + + async def configure_meter(self, req: ConfigureElectricalMeterCompatRequest) -> None: + await self._stub.ConfigureElectricalMeter(_configure_request(req)) + + async def set_balance(self, node_id: int, req: SetBalanceAndFlagsRequest) -> None: + await self._stub.SetElectricalMeterBalanceAndFlags(_set_balance_request(node_id, req)) + + async def unregister_node(self, node_id: int) -> None: + await self._stub.UnregisterNode(pb2.UnregisterNode(node_id=int(node_id))) + + async def close(self) -> None: + await self._channel.close() + + +class HttpEventClient: + """Event streaming client backed by the required HTTP SSE interface.""" + + transport_name = "http-sse" + + def __init__(self, base_url: str, client_id: str): + self._base_url = base_url.rstrip("/") + self._client_id = client_id + + async def stream_events(self, client_id: str): + async for event in stream_json_events(self._base_url, client_id or self._client_id): + yield event + + async def close(self) -> None: + return None + + +class GrpcEventClient: + """Event streaming client backed by the standard TC 2.0 gRPC profile's + SubscribeEvents stream, compiled from Thundercloud's own proto. + """ + + transport_name = "grpc-stream" + + def __init__(self, target: str): + self._channel = grpc.aio.insecure_channel(target) + self._stub = pb2_grpc.MeterDriverControlStub(self._channel) + self._event_id = 0 + + async def stream_events(self, client_id: str): + del client_id + request = pb2.SubscribeEventsRequest() + async for event in self._stub.SubscribeEvents(request): + self._event_id += 1 + raw = _grpc_event_to_raw_dict(event, self._event_id) + if raw is None: + continue + yield raw + + async def close(self) -> None: + await self._channel.close() + + +def _selected_interface_details(provider, provider_details): + if provider_details and provider_details.get("selected_interface_details"): + return provider_details["selected_interface_details"] + return {} + + +def _grpc_target(provider, provider_details) -> str | None: + selected_details = _selected_interface_details(provider, provider_details) + target = selected_details.get("target") or selected_details.get("address") + target = str(target or "").strip() + if not target: + target = str((provider or {}).get("selected_interface_target") or "").strip() + if not target: + base_url = str((provider or {}).get("base_url") or "").strip() + parsed = urlparse(base_url) + hostname = parsed.hostname or "" + if hostname: + target = "{}:50051".format(hostname) + logger.warning( + "provider %s selected gRPC but no target metadata is available; " + "falling back to derived target %s", + base_url, + target, + ) + return target or None + + +def build_command_client(provider, client_id: str, provider_details=None) -> MeteringCommandClient: + """Create the command transport for the selected provider interface.""" + selected_interface = str((provider or {}).get("selected_interface") or "http").strip().lower() + if selected_interface == "grpc": + target = _grpc_target(provider, provider_details) + if target: + return GrpcCommandClient(target) + logger.warning( + "provider %s selected gRPC but no grpc target is available; falling back to HTTP commands", + (provider or {}).get("base_url"), + ) + return HttpCommandClient(str((provider or {}).get("base_url") or ""), client_id) + + +def build_event_client(provider, client_id: str, provider_details=None): + """Create the event transport for the selected provider interface.""" + selected_interface = str((provider or {}).get("selected_interface") or "http").strip().lower() + if selected_interface == "grpc": + target = _grpc_target(provider, provider_details) + if target: + return GrpcEventClient(target) + logger.warning( + "provider %s selected gRPC but no grpc target is available; falling back to HTTP SSE events", + (provider or {}).get("base_url"), + ) + return HttpEventClient(str((provider or {}).get("base_url") or ""), client_id) + + +async def initialize_provider(provider, payload: dict[str, Any], provider_details=None) -> None: + """Initialize a driver over its selected interface.""" + client_id = "init-" + uuid.uuid4().hex[:8] + client = build_command_client(provider, client_id, provider_details=provider_details) + try: + await client.init_driver(payload) + finally: + await client.close() + + +def initialize_provider_sync(provider, payload: dict[str, Any], provider_details=None) -> None: + """Sync wrapper for initializing a driver over its selected interface.""" + asyncio.run(initialize_provider(provider, payload, provider_details=provider_details)) + + +def _aes_key_bytes(value: Any) -> bytes: + if isinstance(value, str): + return binascii.unhexlify(value) + if isinstance(value, (bytes, bytearray)): + return bytes(value) + return bytes(int(item) for item in value) + + +def _register_request(req: RegisterNodeRequest) -> pb2.RegisterNode: + request = pb2.RegisterNode( + node_id=int(req.node_id), + node_type=getattr(pb2, str(req.node_type)), + ) + if req.mac is not None: + request.mac.CopyFrom(UInt32Value(value=int(req.mac))) + if req.request_phased_readings: + request.request_phased_readings.CopyFrom(BoolValue(value=bool(req.request_phased_readings))) + return request + + +def _configure_request(req: ConfigureElectricalMeterCompatRequest) -> pb2.ConfigureElectricalMeter: + c = req.configuration + configuration = pb2.ElectricalMeterConfiguration( + node_id=int(req.node_id), + power_limit=float(c.power_limit), + current_limit=float(c.current_limit), + startup_delay=int(c.startup_delay), + throttle_on_time=int(c.throttle_on_time), + throttle_off_time=int(c.throttle_off_time), + throttle_count_limit=int(c.throttle_count_limit), + ) + # The spec ElectricalMeterCommandName values match the pb2 enum member + # names one-to-one (e.g. "ElectricalMeterCommandEnable"). + return pb2.ConfigureElectricalMeter( + node_id=int(req.node_id), + command=getattr(pb2.ElectricalMeterCommand, req.command.value), + configuration=configuration, + ) + + +def _set_balance_request( + node_id: int, req: SetBalanceAndFlagsRequest +) -> pb2.SetElectricalMeterBalanceAndFlags: + return pb2.SetElectricalMeterBalanceAndFlags( + node_id=int(node_id), + balance=_decimal_proto(req.balance), + low_balance_flag=bool(req.low_balance_flag), + ) + + +def _decimal_proto(value: SpecDecimal) -> pb2.Decimal: + return pb2.Decimal(sign=int(value.sign), coef=int(value.coef), exp=int(value.exp)) + + +def _grpc_event_to_raw_dict(event: "pb2.MeterDriverEvent", event_id: int): + """Translate a protobuf stream event into the raw event dict shape.""" + event_name = event.WhichOneof("event") + if not event_name: + return None + + message = getattr(event, event_name) + message_dict = MessageToDict(message, preserving_proto_field_name=True) + + if event_name == "electrical_meter_reading": + data = { + "node_id": int(message.node_id), + "period_start": int(message.period_start), + "period_end": int(message.period_end), + "state": int(message.state), + "frequency": float(message.frequency), + "current_avg": float(message.current_avg), + "current_min": float(message.current_min), + "current_max": float(message.current_max), + "voltage_avg": float(message.voltage_avg), + "voltage_min": float(message.voltage_min), + "voltage_max": float(message.voltage_max), + "true_power_avg": float(message.true_power_avg), + "true_power_inst": float(message.true_power_inst), + "apparent_power_avg": float(message.apparent_power_avg), + "power_factor_avg": float(message.power_factor_avg), + "energy": float(message.energy), + "uptime_secs": int(message.uptime_secs), + "user_power_limit": float(message.user_power_limit), + } + return { + "type": "electrical_meter_reading", + "event_id": event_id, + "event_type": "meter_reading", + "meter_id": str(message.node_id), + "period_start": int(message.period_start), + "period_end": int(message.period_end), + "state": _meter_state_name(message.state), + "frequency_hz": float(message.frequency), + "current_avg_amps": float(message.current_avg), + "current_max_amps": float(message.current_max), + "current_min_amps": float(message.current_min), + "voltage_avg": float(message.voltage_avg), + "voltage_max": float(message.voltage_max), + "voltage_min": float(message.voltage_min), + "true_power_avg_watts": float(message.true_power_avg), + "true_power_inst_watts": float(message.true_power_inst), + "apparent_power_avg_va": float(message.apparent_power_avg), + "power_factor_avg": float(message.power_factor_avg), + "energy_wh": float(message.energy), + "uptime_seconds": int(message.uptime_secs), + "user_power_limit_watts": float(message.user_power_limit), + "data": data, + } + + if event_name == "electrical_meter_reading_phased": + data = { + "node_id": int(message.node_id), + "period_start": int(message.period_start), + "period_end": int(message.period_end), + "state": int(message.state), + "energy": float(message.energy), + "uptime_secs": int(message.uptime_secs), + "user_power_limit": float(message.user_power_limit), + } + return { + "type": "electrical_meter_reading_phased", + "event_id": event_id, + "event_type": "meter_reading_phased", + "meter_id": str(message.node_id), + "period_start": int(message.period_start), + "period_end": int(message.period_end), + "state": _meter_state_name(message.state), + "energy_wh": float(message.energy), + "uptime_seconds": int(message.uptime_secs), + "user_power_limit_watts": float(message.user_power_limit), + "aggregate": _phase_reading_dict(message), + "per_phase": _phased_per_phase_dict(message), + "phases": _phases_list(message), + "computed_fields_version": int(message.computed_fields_version), + "data": data, + } + + if event_name == "heartbeat_statistics": + return { + "type": "heartbeat_statistics", + "event_id": event_id, + "event_type": "heartbeat_summary", + "timestamp": int(message.timestamp), + "total_registered_meters": int(message.total_registered_nodes), + "meters_attempted": int(message.nodes_reached_out_to_in_current_heartbeat), + "meters_responded": int(message.nodes_heard_from_in_current_heartbeat), + "packets_sent": int(message.packets_sent_in_current_heartbeat), + "packets_received": int(message.packets_received_in_current_heartbeat), + "read_reply_latency_ms": _stats_dict(message_dict.get("millisecond_read_reply_stats")), + "set_config_reply_latency_ms": _stats_dict( + message_dict.get("millisecond_set_config_reply_stats") + ), + "data": message_dict, + } + + if event_name == "node_firmware_version_changed": + return { + "type": "node_firmware_version_changed", + "event_id": event_id, + "event_type": "meter_firmware_changed", + "meter_id": str(message.node_id), + "firmware_version": _version_dict(message.firmware_version), + "data": { + "node_id": int(message.node_id), + "firmware_version": _version_dict(message.firmware_version), + }, + } + + if event_name in { + "gateway_status", + "heartbeat_read_hops", + "driver_configuration_applied", + "node_registered", + "node_already_registered", + "node_unregistered", + "node_to_unregister_unknown", + "invalid_electrical_meter_configuration", + "electrical_meter_configuration_accepted", + "electrical_meter_configuration_applied", + "electrical_meter_balance_and_flags_accepted", + }: + return { + "type": event_name, + "event_id": event_id, + "data": message_dict, + } + + logger.debug("ignoring unsupported gRPC provider event %s", event_name) + return None + + +def _meter_state_name(state_value: int) -> str: + mapping = { + getattr(pb2.ElectricalMeterState, "ElectricalMeterStateOff", 0): "off", + getattr(pb2.ElectricalMeterState, "ElectricalMeterStateOn", 1): "on", + getattr(pb2.ElectricalMeterState, "ElectricalMeterStateUnknown", -1): "unknown", + } + return mapping.get(state_value, "unknown") + + +def _version_dict(version_message: Any) -> dict[str, int]: + """Return a complete semantic version dict from a protobuf Version message.""" + return { + "major": int(getattr(version_message, "major", 0) or 0), + "minor": int(getattr(version_message, "minor", 0) or 0), + "patch": int(getattr(version_message, "patch", 0) or 0), + } + + +def _phase_reading_dict(message) -> dict[str, float]: + return { + "apparent_power_avg_va": float(message.apparent_power_avg), + "current_avg_amps": float(message.current_avg), + "current_max_amps": float(message.current_max), + "current_min_amps": float(message.current_min), + "frequency_hz": float(message.frequency), + "power_factor_avg": float(message.power_factor_avg), + "true_power_avg_watts": float(message.true_power_avg), + "true_power_inst_watts": float(message.true_power_inst), + "voltage_avg": float(message.voltage_avg), + "voltage_max": float(message.voltage_max), + "voltage_min": float(message.voltage_min), + } + + +def _phased_per_phase_dict(message) -> dict[str, dict[str, float]]: + per_phase = {} + for phase in _phases_list(message): + suffix = phase + per_phase[phase] = { + "apparent_power_avg_va": float(getattr(message, f"apparent_power_avg_{suffix}")), + "current_avg_amps": float(getattr(message, f"current_avg_{suffix}")), + "current_max_amps": float(getattr(message, f"current_max_{suffix}")), + "current_min_amps": float(getattr(message, f"current_min_{suffix}")), + "frequency_hz": float(getattr(message, f"frequency_{suffix}")), + "power_factor_avg": float(getattr(message, f"power_factor_avg_{suffix}")), + "true_power_avg_watts": float(getattr(message, f"true_power_avg_{suffix}")), + "true_power_inst_watts": float(getattr(message, f"true_power_inst_{suffix}")), + "voltage_avg": float(getattr(message, f"voltage_avg_{suffix}")), + "voltage_max": float(getattr(message, f"voltage_max_{suffix}")), + "voltage_min": float(getattr(message, f"voltage_min_{suffix}")), + } + return per_phase + + +def _phases_list(message) -> list[str]: + phases = [] + if getattr(message.phases, "a", False): + phases.append("a") + if getattr(message.phases, "b", False): + phases.append("b") + if getattr(message.phases, "c", False): + phases.append("c") + return phases + + +def _stats_dict(data: dict[str, Any] | None): + if not data: + return None + return { + "count": int(data.get("count", 0)), + "last_value": float(data.get("last_value", 0.0)), + "max": float(data.get("max", 0.0)), + "min": float(data.get("min", 0.0)), + "avg": float(data.get("avg", 0.0)), + } diff --git a/sparkmeter/metering/tests/test_cli.py b/sparkmeter/metering/tests/test_cli.py index 1d46e8c..e2d530f 100644 --- a/sparkmeter/metering/tests/test_cli.py +++ b/sparkmeter/metering/tests/test_cli.py @@ -1,212 +1,436 @@ -"""Tests for the dynamic OpenAPI-driven CLI. +"""Tests for the discovery-driven metering CLI. -The CLI is built at module import time from the discriminated `Command` -union in `_generated/`. These tests verify: - -- All command_type values appear as CLI subcommands. -- Top-level scalar params translate to flat options. -- Nested-dataclass params translate to dotted options. -- Submission goes through `submit_command_v1_commands_post` with the - correct typed body. -- `--vendor-option KEY=VALUE` populates the vendor_options wrapper. - -Submission and SSE-tailing are mocked so tests don't touch the network. +The CLI builds its command tree from the selected driver's live +`/openapi.json` at invocation time. These tests feed a fake OpenAPI +document and capture the HTTP submission, so no network or driver is +involved. """ +from types import SimpleNamespace + +import click import pytest +from click.testing import CliRunner from sparkmeter.metering import cli as metering_cli -from sparkmeter.metering._generated.models.configure_meter_command import ConfigureMeterCommand -from sparkmeter.metering._generated.models.configure_provider_command import ConfigureProviderCommand -from sparkmeter.metering._generated.models.meter_behavior_command import MeterBehaviorCommand -from sparkmeter.metering._generated.models.ping_meter_command import PingMeterCommand -from sparkmeter.metering._generated.models.register_meter_command import RegisterMeterCommand -from sparkmeter.metering._generated.models.set_balance_command import SetBalanceCommand + +# A minimal driver OpenAPI doc: two POST commands (one with a path param), +# one DELETE command, and a GET stream that must NOT become a command. +_FAKE_SPEC = { + "paths": { + "/v1/nodes/register": { + "post": { + "operationId": "registerNode", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["node_id", "node_type"], + "properties": { + "node_id": {"type": "integer"}, + "node_type": {"type": "string"}, + "mac": {"type": "integer"}, + }, + } + } + } + }, + } + }, + "/v1/nodes/{node_id}/balance-and-flags": { + "post": { + "operationId": "setBalanceAndFlags", + "parameters": [{"name": "node_id", "in": "path", "required": True}], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["balance", "low_balance_flag"], + "properties": { + "balance": {"type": "number"}, + "low_balance_flag": {"type": "boolean"}, + }, + } + } + } + }, + } + }, + "/v1/nodes/{node_id}": { + "delete": { + "operationId": "unregisterNode", + "parameters": [{"name": "node_id", "in": "path", "required": True}], + } + }, + "/v1/events": {"get": {"operationId": "subscribeEvents"}}, + } +} @pytest.fixture -def captured(mocker): - """Capture every (body, exit_code) submitted via the dynamic CLI.""" +def submits(monkeypatch): captured: list = [] - async def fake_submit_and_tail(body, correlation_id, timeout=10.0): - captured.append(body) - return 0 + def fake_submit(base_url, method, path, body): + captured.append({"base_url": base_url, "method": method, "path": path, "body": body}) - mocker.patch.object(metering_cli, "_submit_and_tail", fake_submit_and_tail) + monkeypatch.setattr(metering_cli, "_fetch_openapi", lambda base_url: _FAKE_SPEC) + monkeypatch.setattr(metering_cli, "_submit", fake_submit) return captured -def _expected_command_names() -> set[str]: - from sparkmeter.metering._generated.models.submit_command_v_1_commands_post_request_body import ( - SubmitCommandV1CommandsPostRequestBodyDiscriminator, - ) +def _run(*args): + return CliRunner().invoke(metering_cli.metering, list(args)) - discriminator = SubmitCommandV1CommandsPostRequestBodyDiscriminator() - return {ct.replace("_", "-") for ct in discriminator.get_mapping()} +class TestDiscovery: + def test_post_operation_is_a_command(self, submits): + result = _run("--driver", "http://drv", "register-node", "--node-id", "1", "--node-type", "SM5R") + assert result.exit_code == 0, result.output -class TestRegistration: - def test_every_spec_command_appears_as_subcommand(self): - registered = set(metering_cli.metering.commands.keys()) - assert _expected_command_names().issubset(registered) + def test_get_operations_are_not_commands(self, submits): + # GET /v1/events must not become a command. + result = _run("--driver", "http://drv", "subscribe-events") + assert result.exit_code != 0 + def test_command_absent_from_driver_is_unknown(self, submits): + result = _run("--driver", "http://drv", "ping-meter", "--meter-id", "1") + assert result.exit_code != 0 -class TestRegisterMeter: - def test_required_params_only(self, cli, captured): - result = cli( - "metering", - "register-meter", - "--meter-id", - "42", - "--meter-type", - "SM5R", - ) - assert result.exit_code == 0 - assert len(captured) == 1 - body = captured[0] - assert isinstance(body, RegisterMeterCommand) - assert body.params.meter_id == "42" - assert body.params.meter_type == "SM5R" - - def test_with_vendor_option(self, cli, captured): - result = cli( - "metering", - "register-meter", - "--meter-id", - "42", - "--meter-type", - "SM5R", - "--vendor-option", - "mac=43981", - ) - assert result.exit_code == 0 - body = captured[0] - assert body.vendor_options is not None - assert body.vendor_options["mac"] == 43981 - - def test_correlation_id_passed_through(self, cli, captured): - result = cli( - "metering", - "register-meter", - "--meter-id", - "42", - "--meter-type", + def test_driver_help_lists_discovered_commands(self, submits): + # With a driver selected, --help enumerates the discovered subcommands. + result = _run("--driver", "http://drv", "--help") + assert result.exit_code == 0, result.output + assert "register-node" in result.output + assert "unregister-node" in result.output + + +class TestRegisterNode: + def test_body_assembled_from_options(self, submits): + result = _run( + "--driver", + "http://drv", + "register-node", + "--node-id", + "100", + "--node-type", "SM5R", - "--correlation-id", - "fixed-id", + "--mac", + "43981", ) - assert result.exit_code == 0 - assert captured[0].correlation_id == "fixed-id" + assert result.exit_code == 0, result.output + assert len(submits) == 1 + call = submits[0] + assert call["method"] == "post" + assert call["path"] == "/v1/nodes/register" + assert call["body"] == {"node_id": 100, "node_type": "SM5R", "mac": 43981} + def test_required_option_missing_errors(self, submits): + result = _run("--driver", "http://drv", "register-node", "--node-id", "100") + assert result.exit_code != 0 + assert submits == [] -class TestSetBalance: - def test_balance_passes_through_as_string(self, cli, captured): - result = cli( - "metering", - "set-balance", - "--meter-id", + +class TestSetBalanceAndFlags: + def test_path_param_substituted_and_body_built(self, submits): + result = _run( + "--driver", + "http://drv", + "set-balance-and-flags", + "--node-id", "9", "--balance", "12.5", + "--low-balance-flag", ) - assert result.exit_code == 0 - body = captured[0] - assert isinstance(body, SetBalanceCommand) - assert body.params.meter_id == "9" - assert body.params.balance == "12.5" + assert result.exit_code == 0, result.output + call = submits[0] + assert call["method"] == "post" + assert call["path"] == "/v1/nodes/9/balance-and-flags" + assert call["body"] == {"balance": 12.5, "low_balance_flag": True} -class TestPingMeter: - def test_basic(self, cli, captured): - result = cli("metering", "ping-meter", "--meter-id", "42") +class TestUnregisterNode: + def test_delete_with_path_param_no_body(self, submits): + result = _run("--driver", "http://drv", "unregister-node", "--node-id", "55") + assert result.exit_code == 0, result.output + call = submits[0] + assert call["method"] == "delete" + assert call["path"] == "/v1/nodes/55" + assert call["body"] == {} + + +class TestNoDriver: + def test_no_driver_lists_no_commands(self, submits): + # Without --driver there is nothing to discover. + result = _run("--help") assert result.exit_code == 0 - body = captured[0] - assert isinstance(body, PingMeterCommand) - assert body.params.meter_id == "42" - - -class TestConfigureMeter: - def test_with_nested_throttle_options(self, cli, captured): - result = cli( - "metering", - "configure-meter", - "--meter-id", - "42", - "--behavior", - "enable", - "--configuration.power-limit-watts", - "1500", - "--configuration.current-limit-amps", - "10", - "--configuration.startup-delay-seconds", - "2", - "--configuration.throttle.on-seconds", - "5", - "--configuration.throttle.off-seconds", - "10", - "--configuration.throttle.count-limit", - "5", + assert "register-node" not in result.output + + def test_subcommand_without_driver_is_unknown(self, submits): + # get_command returns None when no spec is available. + result = _run("register-node") + assert result.exit_code != 0 + + def test_fetch_failure_is_reported(self, monkeypatch): + def boom(base_url): + raise RuntimeError("connection refused") + + monkeypatch.setattr(metering_cli, "_fetch_openapi", boom) + result = _run("--driver", "http://drv", "register-node") + assert result.exit_code != 0 + assert "could not fetch" in result.output + + +# --------------------------------------------------------------------------- +# OpenAPI schema resolution helpers +# --------------------------------------------------------------------------- + + +class TestSchemaHelpers: + def test_resolve_ref_walks_pointer(self): + spec = {"components": {"schemas": {"Foo": {"type": "object"}}}} + assert metering_cli._resolve_ref(spec, "#/components/schemas/Foo") == {"type": "object"} + + def test_resolve_schema_non_dict_returns_empty(self): + assert metering_cli._resolve_schema({}, "not-a-dict") == {} + + def test_resolve_schema_follows_ref(self): + spec = {"components": {"schemas": {"Foo": {"type": "string"}}}} + assert metering_cli._resolve_schema(spec, {"$ref": "#/components/schemas/Foo"}) == {"type": "string"} + + def test_resolve_schema_merges_all_of(self): + schema = { + "allOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]}, + {"properties": {"b": {"type": "integer"}}}, + ], + "properties": {"c": {"type": "string"}}, + "required": ["c"], + } + merged = metering_cli._resolve_schema({}, schema) + assert set(merged["properties"]) == {"a", "b", "c"} + assert set(merged["required"]) == {"a", "c"} + + def test_leaf_options_flattens_nested_objects(self): + schema = { + "type": "object", + "required": ["configuration"], + "properties": { + "configuration": { + "type": "object", + "properties": {"power_limit": {"type": "number"}}, + } + }, + } + leaves = metering_cli._leaf_options({}, schema) + assert [(path, required) for path, _prop, required in leaves] == [ + (("configuration", "power_limit"), False) + ] + + def test_iter_command_operations_skips_non_dict_and_get(self): + spec = { + "paths": { + "/x": "not-a-dict", + "/y": {"get": {"operationId": "readY"}}, + "/z": {"post": {"operationId": "makeZ"}}, + } + } + names = [name for name, *_ in metering_cli._iter_command_operations(spec)] + assert names == ["make-z"] + + def test_assemble_body_nests_dotted_paths(self): + leaf_options = [(("configuration", "power_limit"), {}, False)] + kwargs = {metering_cli._option_dest(("configuration", "power_limit")): 1500} + assert metering_cli._assemble_body(leaf_options, kwargs) == {"configuration": {"power_limit": 1500}} + + +class TestClickKwargs: + def test_enum_becomes_choice(self): + kwargs = metering_cli._click_kwargs({"enum": ["on", "off"]}, required=True) + assert isinstance(kwargs["type"], click.Choice) + + def test_integer_and_number_and_string(self): + assert metering_cli._click_kwargs({"type": "integer"}, False)["type"] is click.INT + assert metering_cli._click_kwargs({"type": "number"}, False)["type"] is click.FLOAT + assert metering_cli._click_kwargs({"type": "string"}, False)["type"] is click.STRING + + def test_boolean_becomes_flag(self): + kwargs = metering_cli._click_kwargs({"type": "boolean"}, False) + assert kwargs["is_flag"] is True + assert kwargs["default"] is False + + def test_array_is_multiple(self): + kwargs = metering_cli._click_kwargs({"type": "array"}, False) + assert kwargs["multiple"] is True + assert kwargs["type"] is click.STRING + + +# --------------------------------------------------------------------------- +# Driver resolution + HTTP transport +# --------------------------------------------------------------------------- + + +class TestResolveDriverUrl: + def test_url_passes_through(self): + assert metering_cli._resolve_driver_url(None, "http://drv:18080/") == "http://drv:18080" + + def test_id_resolved_in_app_context(self, monkeypatch): + monkeypatch.setattr("flask.has_app_context", lambda: True) + monkeypatch.setattr( + "sparkmeter.config.provider_settings.get_provider", + lambda driver: {"base_url": "http://resolved:18080/"}, ) - assert result.exit_code == 0 - body = captured[0] - assert isinstance(body, ConfigureMeterCommand) - assert body.params.meter_id == "42" - assert body.params.behavior is MeterBehaviorCommand.ENABLE - assert body.params.configuration.power_limit_watts == pytest.approx(1500.0) - assert body.params.configuration.current_limit_amps == pytest.approx(10.0) - assert body.params.configuration.startup_delay_seconds == 2 - assert body.params.configuration.throttle.on_seconds == 5 - assert body.params.configuration.throttle.off_seconds == 10 - assert body.params.configuration.throttle.count_limit == 5 - - -class TestConfigureProvider: - def test_minimal(self, cli, captured): - result = cli( - "metering", - "configure-provider", - "--heartbeat-seconds", - "900", + assert metering_cli._resolve_driver_url(None, "abc") == "http://resolved:18080" + + def test_unknown_id_raises(self, monkeypatch): + monkeypatch.setattr("flask.has_app_context", lambda: True) + monkeypatch.setattr("sparkmeter.config.provider_settings.get_provider", lambda driver: None) + with pytest.raises(click.BadParameter): + metering_cli._resolve_driver_url(None, "abc") + + def test_missing_base_url_raises(self, monkeypatch): + monkeypatch.setattr("flask.has_app_context", lambda: True) + monkeypatch.setattr( + "sparkmeter.config.provider_settings.get_provider", lambda driver: {"base_url": ""} ) - assert result.exit_code == 0 - body = captured[0] - assert isinstance(body, ConfigureProviderCommand) - assert body.params.heartbeat_seconds == 900 - - def test_with_vendor_options(self, cli, captured): - result = cli( - "metering", - "configure-provider", - "--heartbeat-seconds", - "900", - "--vendor-option", - "channel=25", - "--vendor-option", - "aes_key=00112233445566778899aabbccddeeff", + with pytest.raises(click.BadParameter): + metering_cli._resolve_driver_url(None, "abc") + + def test_no_context_and_no_app_raises(self, monkeypatch): + monkeypatch.setattr("flask.has_app_context", lambda: False) + ctx = SimpleNamespace(obj=None, parent=None) + with pytest.raises(click.BadParameter): + metering_cli._resolve_driver_url(ctx, "abc") + + def test_id_resolved_via_ctx_app_when_no_app_context(self, monkeypatch): + import contextlib + + from flask.cli import ScriptInfo + + # No ambient app context: the driver id is resolved inside the app + # loaded from the ScriptInfo on ctx.obj. + monkeypatch.setattr("flask.has_app_context", lambda: False) + monkeypatch.setattr( + "sparkmeter.config.provider_settings.get_provider", + lambda driver: {"base_url": "http://resolved:18080/"}, ) - assert result.exit_code == 0 - body = captured[0] - assert body.vendor_options["channel"] == 25 - assert body.vendor_options["aes_key"] == "00112233445566778899aabbccddeeff" - - -class TestVendorOptionParsing: - @pytest.mark.parametrize( - "raw,key,expected", - [ - ("channel=25", "channel", 25), - ("flag=true", "flag", True), - ("name=hello", "name", "hello"), - ("negative=-5", "negative", -5), - ], - ) - def test_parses_typed_values(self, raw, key, expected): - out = metering_cli._parse_vendor_options((raw,)) - assert out[key] == expected - - def test_invalid_format_raises(self): - from click import BadParameter - - with pytest.raises(BadParameter): - metering_cli._parse_vendor_options(("no-equals-sign",)) + + entered = [] + + @contextlib.contextmanager + def fake_app_context(): + entered.append(True) + yield + + fake_app = SimpleNamespace(app_context=fake_app_context) + script_info = ScriptInfo(create_app=lambda: fake_app, set_debug_flag=False) + ctx = SimpleNamespace(obj=script_info, parent=None) + + assert metering_cli._resolve_driver_url(ctx, "abc") == "http://resolved:18080" + assert entered == [True] # the app context was actually entered + + +class TestFlaskAppFromCtx: + def test_finds_scriptinfo_and_loads_app(self): + from flask.cli import ScriptInfo + + fake_app = SimpleNamespace() + script_info = ScriptInfo(create_app=lambda: fake_app, set_debug_flag=False) + ctx = SimpleNamespace(obj=script_info, parent=None) + assert metering_cli._flask_app_from_ctx(ctx) is fake_app + + def test_returns_none_when_no_scriptinfo(self): + ctx = SimpleNamespace(obj="not-script-info", parent=SimpleNamespace(obj=None, parent=None)) + assert metering_cli._flask_app_from_ctx(ctx) is None + + def test_returns_none_when_flask_cli_unimportable(self, monkeypatch): + import sys + + # A None entry in sys.modules makes `from flask.cli import ScriptInfo` + # raise ImportError, exercising the import-guard fallback. + monkeypatch.setitem(sys.modules, "flask.cli", None) + ctx = SimpleNamespace(obj="anything", parent=None) + assert metering_cli._flask_app_from_ctx(ctx) is None + + +class _FakeHttpxResponse: + def __init__(self, status_code=200, text="", payload=None, is_error=False): + self.status_code = status_code + self.text = text + self.is_error = is_error + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +class _FakeHttpxClient: + def __init__(self, response, **kwargs): + self._response = response + self.kwargs = kwargs + self.request_args = None + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def get(self, path): + self.request_args = ("GET", path) + return self._response + + def request(self, method, path, json=None): + self.request_args = (method, path, json) + return self._response + + +class TestTransport: + def test_fetch_openapi_reads_document(self, monkeypatch): + response = _FakeHttpxResponse(payload={"paths": {}}) + holder = {} + + def factory(**kwargs): + holder["client"] = _FakeHttpxClient(response, **kwargs) + return holder["client"] + + monkeypatch.setattr(metering_cli.httpx, "Client", factory) + assert metering_cli._fetch_openapi("http://drv") == {"paths": {}} + # It fetches the OpenAPI document from the spec endpoint, not some other path. + assert holder["client"].request_args == ("GET", "/openapi.json") + + def test_submit_echoes_status_and_body(self, monkeypatch, capsys): + response = _FakeHttpxResponse(status_code=200, text="created", is_error=False) + holder = {} + + def factory(**kwargs): + holder["client"] = _FakeHttpxClient(response, **kwargs) + return holder["client"] + + monkeypatch.setattr(metering_cli.httpx, "Client", factory) + metering_cli._submit("http://drv", "post", "/v1/x", {"a": 1}) + out = capsys.readouterr().out + assert "POST /v1/x -> 200" in out + assert "created" in out + # The method is upper-cased and the body is sent as JSON. + assert holder["client"].request_args == ("POST", "/v1/x", {"a": 1}) + + def test_submit_raises_on_error_status(self, monkeypatch): + response = _FakeHttpxResponse(status_code=500, text="", is_error=True) + holder = {} + + def factory(**kwargs): + holder["client"] = _FakeHttpxClient(response, **kwargs) + return holder["client"] + + monkeypatch.setattr(metering_cli.httpx, "Client", factory) + with pytest.raises(SystemExit): + metering_cli._submit("http://drv", "post", "/v1/x", {}) + # An empty body is sent as json=None, and the request still went out. + assert holder["client"].request_args == ("POST", "/v1/x", None) diff --git a/sparkmeter/metering/tests/test_dispatch.py b/sparkmeter/metering/tests/test_dispatch.py index 92674d8..8ad9f3c 100644 --- a/sparkmeter/metering/tests/test_dispatch.py +++ b/sparkmeter/metering/tests/test_dispatch.py @@ -1,23 +1,43 @@ -"""Tests for the sync→async dispatch bridge and command builders.""" +"""Tests for the sync→async dispatch bridge and command handlers.""" import asyncio import pytest +from meter_driver_spec.http.models import ( + ConfigureElectricalMeterCompatRequest, + ElectricalMeterCommandName, + RegisterNodeRequest, + SetBalanceAndFlagsRequest, +) from sparkmeter.metering import dispatch -from sparkmeter.metering._generated.models.configure_meter_command import ConfigureMeterCommand -from sparkmeter.metering._generated.models.meter_behavior_command import MeterBehaviorCommand -from sparkmeter.metering._generated.models.register_meter_command import RegisterMeterCommand -from sparkmeter.metering._generated.models.set_balance_command import SetBalanceCommand -from sparkmeter.metering._generated.models.submit_command_v_1_commands_post_request_body_command_type_enum import ( - SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum as CommandTypeEnum, -) -from sparkmeter.metering._generated.models.unregister_meter_command import UnregisterMeterCommand -class TestBuildConfigureMeter: - def test_enable_sets_full_configuration(self): - body = dispatch._build_configure_meter( +class _FakeClient: + """Records the typed command calls the handlers make.""" + + def __init__(self): + self.calls: list = [] + + async def register_node(self, req): + self.calls.append(("register_node", req)) + + async def configure_meter(self, req): + self.calls.append(("configure_meter", req)) + + async def set_balance(self, node_id, req): + self.calls.append(("set_balance", node_id, req)) + + async def unregister_node(self, node_id): + self.calls.append(("unregister_node", node_id)) + + +class TestHandleConfigure: + @pytest.mark.asyncio + async def test_enable_sets_full_configuration(self): + client = _FakeClient() + await dispatch._handle_configure( + client, { "op": "configure_meter", "node_id": 42, @@ -28,87 +48,174 @@ def test_enable_sets_full_configuration(self): "throttle_on_time": 3, "throttle_off_time": 11, "throttle_count_limit": 4, - } - ) - assert isinstance(body, ConfigureMeterCommand) - assert body.command_type is CommandTypeEnum.CONFIGURE_METER - assert body.params.meter_id == "42" - assert body.params.behavior is MeterBehaviorCommand.ENABLE - assert body.params.configuration.power_limit_watts == pytest.approx(1500.0) - assert body.params.configuration.current_limit_amps == pytest.approx(10.0) - assert body.params.configuration.startup_delay_seconds == 2 - assert body.params.configuration.throttle.on_seconds == 3 - assert body.params.configuration.throttle.off_seconds == 11 - assert body.params.configuration.throttle.count_limit == 4 - - def test_disable(self): - body = dispatch._build_configure_meter({"op": "configure_meter", "node_id": 7, "command": "disable"}) - assert body.params.behavior is MeterBehaviorCommand.DISABLE - - def test_unknown_command_falls_back_to_none(self): - body = dispatch._build_configure_meter({"op": "configure_meter", "node_id": 7, "command": "wibble"}) - assert body.params.behavior is MeterBehaviorCommand.NONE - - def test_correlation_id_default_starts_with_dispatch(self): - body = dispatch._build_configure_meter({"op": "configure_meter", "node_id": 1, "command": "enable"}) - assert body.correlation_id.startswith("dispatch-") - - def test_correlation_id_passed_through(self): - body = dispatch._build_configure_meter( - { - "op": "configure_meter", - "node_id": 1, - "command": "enable", - "correlation_id": "fixed-id", - } + }, ) - assert body.correlation_id == "fixed-id" + assert len(client.calls) == 1 + _, req = client.calls[0] + assert isinstance(req, ConfigureElectricalMeterCompatRequest) + assert req.node_id == 42 + assert req.command is ElectricalMeterCommandName.ELECTRICALMETERCOMMANDENABLE + assert req.configuration.power_limit == pytest.approx(1500.0) + assert req.configuration.current_limit == pytest.approx(10.0) + assert req.configuration.startup_delay == 2 + assert req.configuration.throttle_on_time == 3 + assert req.configuration.throttle_off_time == 11 + assert req.configuration.throttle_count_limit == 4 + + @pytest.mark.asyncio + async def test_disable(self): + client = _FakeClient() + await dispatch._handle_configure(client, {"node_id": 7, "command": "disable"}) + _, req = client.calls[0] + assert req.command is ElectricalMeterCommandName.ELECTRICALMETERCOMMANDDISABLE + + @pytest.mark.asyncio + async def test_unknown_command_dropped(self): + client = _FakeClient() + await dispatch._handle_configure(client, {"node_id": 7, "command": "wibble"}) + # No spec command name maps to "wibble", so nothing is submitted. + assert client.calls == [] -class TestBuildSetBalance: - def test_balance_serialized_as_string(self): - body = dispatch._build_set_balance( - {"op": "set_balance", "node_id": 9, "balance": "12.5", "low_balance_flag": True} +class TestHandleSetBalance: + @pytest.mark.asyncio + async def test_balance_becomes_spec_decimal(self): + client = _FakeClient() + await dispatch._handle_set_balance( + client, {"node_id": 9, "balance": "12.5", "low_balance_flag": True} ) - assert isinstance(body, SetBalanceCommand) - assert body.command_type is CommandTypeEnum.SET_BALANCE - assert body.params.meter_id == "9" - assert body.params.balance == "12.5" - assert body.params.low_balance is True + kind, node_id, req = client.calls[0] + assert kind == "set_balance" + assert node_id == 9 + assert isinstance(req, SetBalanceAndFlagsRequest) + assert req.balance.model_dump() == {"sign": 1, "coef": 125, "exp": -1} + assert req.low_balance_flag is True - def test_zero_balance(self): - body = dispatch._build_set_balance({"op": "set_balance", "node_id": 9}) - assert body.params.balance == "0" + @pytest.mark.asyncio + async def test_zero_balance(self): + client = _FakeClient() + await dispatch._handle_set_balance(client, {"node_id": 9}) + _, _, req = client.calls[0] + assert req.balance.model_dump() == {"sign": 1, "coef": 0, "exp": 0} -class TestBuildRegisterMeter: - def test_with_mac_via_vendor_options(self): - body = dispatch._build_register_meter( - { - "op": "register_meter", - "node_id": 100, - "node_type": "SM5R", - "mac": 0xABCD, - } - ) - assert isinstance(body, RegisterMeterCommand) - assert body.command_type is CommandTypeEnum.REGISTER_METER - assert body.params.meter_id == "100" - assert body.params.meter_type == "SM5R" - assert body.vendor_options is not None - assert body.vendor_options["mac"] == 0xABCD +class TestHandleRegister: + @pytest.mark.asyncio + async def test_with_mac(self): + client = _FakeClient() + await dispatch._handle_register(client, {"node_id": 100, "node_type": "SM5R", "mac": 0xABCD}) + _, req = client.calls[0] + assert isinstance(req, RegisterNodeRequest) + assert req.node_id == 100 + assert req.node_type == "SM5R" + assert req.mac == 0xABCD + + @pytest.mark.asyncio + async def test_without_mac(self): + client = _FakeClient() + await dispatch._handle_register(client, {"node_id": 100, "node_type": "SM5R"}) + _, req = client.calls[0] + assert req.mac is None + + +class TestHandleUnregister: + @pytest.mark.asyncio + async def test_basic(self): + client = _FakeClient() + await dispatch._handle_unregister(client, {"node_id": 55}) + assert client.calls == [("unregister_node", 55)] - def test_without_mac_omits_vendor_options(self): - body = dispatch._build_register_meter({"op": "register_meter", "node_id": 100, "node_type": "SM5R"}) - assert body.vendor_options is None +async def _drain_with_dispatcher(client, queue, commands_allowed, done, timeout=1.0): + """Run command_dispatcher until `done()` is truthy, then cancel it.""" + task = asyncio.create_task(dispatch.command_dispatcher(client, queue, commands_allowed)) + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + try: + while loop.time() < deadline: + if done(): + return + await asyncio.sleep(0.005) + raise AssertionError("dispatcher did not reach the expected state in time") + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +class TestCommandDispatcher: + @pytest.mark.asyncio + async def test_dispatches_queued_command_when_allowed(self): + client = _FakeClient() + queue: asyncio.Queue = asyncio.Queue() + commands_allowed = asyncio.Event() + commands_allowed.set() + await queue.put({"op": "register_meter", "node_id": 7, "node_type": "SM5R"}) + + await _drain_with_dispatcher(client, queue, commands_allowed, lambda: client.calls) + + kind, req = client.calls[0] + assert kind == "register_node" + # The queued dict was translated into a typed request carrying node_id. + assert req.node_id == 7 + assert req.node_type == "SM5R" + + @pytest.mark.asyncio + async def test_unknown_op_is_dropped_and_loop_continues(self): + client = _FakeClient() + queue: asyncio.Queue = asyncio.Queue() + await queue.put({"op": "does_not_exist"}) + await queue.put({"op": "unregister_meter", "node_id": 3}) + + await _drain_with_dispatcher(client, queue, None, lambda: ("unregister_node", 3) in client.calls) + + # The unknown op was dropped; the following known op still ran. + assert client.calls == [("unregister_node", 3)] + + @pytest.mark.asyncio + async def test_handler_failure_is_isolated(self): + class _BoomClient(_FakeClient): + async def register_node(self, req): + raise RuntimeError("kaboom") -class TestBuildUnregisterMeter: - def test_basic(self): - body = dispatch._build_unregister_meter({"op": "unregister_meter", "node_id": 55}) - assert isinstance(body, UnregisterMeterCommand) - assert body.command_type is CommandTypeEnum.UNREGISTER_METER - assert body.params.meter_id == "55" + client = _BoomClient() + queue: asyncio.Queue = asyncio.Queue() + await queue.put({"op": "register_meter", "node_id": 1}) + await queue.put({"op": "unregister_meter", "node_id": 2}) + + await _drain_with_dispatcher(client, queue, None, lambda: ("unregister_node", 2) in client.calls) + + # The failing register handler did not stop the loop. + assert ("unregister_node", 2) in client.calls + + @pytest.mark.asyncio + async def test_commands_allowed_gate_blocks_until_set(self): + client = _FakeClient() + queue: asyncio.Queue = asyncio.Queue() + commands_allowed = asyncio.Event() # cleared: the gate is shut + await queue.put({"op": "unregister_meter", "node_id": 9}) + + task = asyncio.create_task(dispatch.command_dispatcher(client, queue, commands_allowed)) + try: + # The dispatcher dequeues the command but blocks on the closed gate. + await asyncio.sleep(0.02) + assert client.calls == [] + + # Opening the gate lets the already-dequeued command through. + commands_allowed.set() + loop = asyncio.get_running_loop() + deadline = loop.time() + 1.0 + while loop.time() < deadline and not client.calls: + await asyncio.sleep(0.005) + assert client.calls == [("unregister_node", 9)] + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass class TestEnqueueCommand: @@ -117,6 +224,15 @@ def test_enqueue_drops_silently_without_loop(self): dispatch.unregister_loop() assert dispatch.enqueue_command({"op": "register_meter", "node_id": 1}) is False + def test_enqueue_returns_false_on_scheduling_error(self): + # A registered loop/queue whose put() blows up: enqueue swallows it and + # returns False rather than propagating into the sync caller. + dispatch.register_loop(object(), object()) # object() has no .put() + try: + assert dispatch.enqueue_command({"op": "register_meter", "node_id": 1}) is False + finally: + dispatch.unregister_loop() + @pytest.mark.asyncio async def test_enqueue_pushes_to_queue_when_loop_registered(self): loop = asyncio.get_running_loop() diff --git a/sparkmeter/metering/tests/test_events.py b/sparkmeter/metering/tests/test_events.py index 8ef12d0..17eb46c 100644 --- a/sparkmeter/metering/tests/test_events.py +++ b/sparkmeter/metering/tests/test_events.py @@ -1,18 +1,79 @@ -"""Tests for the SSE event handler / typed-event dispatcher.""" +"""Tests for the SSE envelope dispatcher and reading handlers.""" +import asyncio +import contextlib import logging +from types import SimpleNamespace import pytest +from meter_driver_spec.http.models import ( + ElectricalMeterReading, + HeartbeatStatistics, +) +from sparkmeter.exceptions import DatabaseLockTimeoutException, DuplicateReadingException from sparkmeter.metering import events -from sparkmeter.metering._generated.models.heartbeat_summary_event import HeartbeatSummaryEvent -from sparkmeter.metering._generated.models.log_event import LogEvent -from sparkmeter.metering._generated.models.log_level import LogLevel -from sparkmeter.metering._generated.models.meter_reading_event import MeterReadingEvent -from sparkmeter.metering._generated.models.meter_state import MeterState # --------------------------------------------------------------------------- -# dispatch_dict_event — structures raw dicts into typed events +# Helpers building valid generated-model payloads +# --------------------------------------------------------------------------- + + +def _reading_data(**overrides): + """A full `electrical_meter_reading` "data" payload (spec field names).""" + data = dict( + node_id=100, + period_start=1700000000, + period_end=1700000900, + state=1, # ElectricalMeterState id for "on" + frequency=50.0, + current_avg=5.0, + current_min=1.0, + current_max=10.0, + voltage_avg=230.5, + voltage_min=220.0, + voltage_max=235.0, + true_power_avg=1000.0, + true_power_inst=1100.0, + apparent_power_avg=1200.0, + power_factor_avg=0.95, + energy=1234.5, + uptime_secs=12345, + user_power_limit=1500.0, + ) + data.update(overrides) + return data + + +def _reading(**overrides): + return ElectricalMeterReading.model_validate(_reading_data(**overrides)) + + +def _stats(): + return {"count": 1, "last_value": 10.0, "max": 10.0, "min": 10.0, "avg": 10.0} + + +def _heartbeat_data(total, attempted, responded): + return dict( + timestamp=1700000000, + total_registered_nodes=total, + total_packets_sent=attempted, + total_packets_received=responded, + nodes_reached_out_to_in_current_heartbeat=attempted, + nodes_heard_from_in_current_heartbeat=responded, + packets_sent_in_current_heartbeat=attempted, + packets_received_in_current_heartbeat=responded, + millisecond_read_reply_stats=_stats(), + millisecond_set_config_reply_stats=_stats(), + ) + + +def _heartbeat(total, attempted, responded): + return HeartbeatStatistics.model_validate(_heartbeat_data(total, attempted, responded)) + + +# --------------------------------------------------------------------------- +# dispatch_dict_event — parses SSE envelopes into generated models # --------------------------------------------------------------------------- @@ -24,61 +85,91 @@ async def test_meter_reading_routes_to_handler(self): async def capture(event): captured.append(event) - raw = { - "event_type": "meter_reading", - "event_id": 42, - "meter_id": "100", - "period_start": 1700000000, - "period_end": 1700000900, - "state": "on", - "uptime_seconds": 12345, - "frequency_hz": 50.0, - "voltage_min": 220.0, - "voltage_avg": 230.5, - "voltage_max": 235.0, - "current_min_amps": 1.0, - "current_avg_amps": 5.0, - "current_max_amps": 10.0, - "true_power_avg_watts": 1000.0, - "true_power_inst_watts": 1100.0, - "apparent_power_avg_va": 1200.0, - "power_factor_avg": 0.95, - "energy_wh": 1234.5, - "user_power_limit_watts": 1500.0, - } + raw = {"type": "electrical_meter_reading", "data": _reading_data()} await events.dispatch_dict_event(raw, [capture]) assert len(captured) == 1 event = captured[0] - assert isinstance(event, MeterReadingEvent) - assert event.meter_id == "100" - assert event.state is MeterState.ON - assert event.energy_wh == pytest.approx(1234.5) + assert isinstance(event, ElectricalMeterReading) + assert event.node_id == 100 + assert event.state.value == 1 + assert event.energy == pytest.approx(1234.5) @pytest.mark.asyncio - async def test_unknown_event_type_logged_and_skipped(self, caplog): + async def test_unknown_type_logged_and_skipped(self, caplog): captured: list = [] async def capture(event): captured.append(event) with caplog.at_level(logging.WARNING): - await events.dispatch_dict_event({"event_type": "wat", "event_id": 1}, [capture]) + await events.dispatch_dict_event({"type": "wat", "data": {}}, [capture]) assert captured == [] - assert any("unknown event_type" in rec.message for rec in caplog.records) + assert any("unknown type" in rec.message for rec in caplog.records) @pytest.mark.asyncio - async def test_missing_event_type_skipped(self, caplog): + async def test_missing_type_skipped(self, caplog): captured: list = [] async def capture(event): captured.append(event) with caplog.at_level(logging.WARNING): - await events.dispatch_dict_event({"event_id": 1}, [capture]) + await events.dispatch_dict_event({"data": {}}, [capture]) assert captured == [] + assert any("missing type" in rec.message for rec in caplog.records) + + @pytest.mark.asyncio + async def test_side_channel_logged_and_skipped(self, caplog): + captured: list = [] + + async def capture(event): + captured.append(event) + + with caplog.at_level(logging.DEBUG): + await events.dispatch_dict_event({"type": "node_registered", "data": {"node_id": 1}}, [capture]) + + assert captured == [] + assert any("side-channel" in rec.message for rec in caplog.records) + + @pytest.mark.asyncio + async def test_heartbeat_statistics_parsed(self): + captured: list = [] + + async def capture(event): + captured.append(event) + + raw = {"type": "heartbeat_statistics", "data": _heartbeat_data(1, 1, 1)} + await events.dispatch_dict_event(raw, [capture]) + + assert len(captured) == 1 + event = captured[0] + assert isinstance(event, HeartbeatStatistics) + assert event.total_registered_nodes == 1 + assert event.nodes_reached_out_to_in_current_heartbeat == 1 + assert event.nodes_heard_from_in_current_heartbeat == 1 + + @pytest.mark.asyncio + async def test_heartbeat_read_hops_flushes_pending_readings(self, monkeypatch): + flushed: list = [] + + async def fake_flush(batch, flask_app): + del flask_app + flushed.append(list(batch)) + + monkeypatch.setattr(events, "_flush_readings", fake_flush) + consumer = events.build_reading_consumer(app=None) + + await consumer(_reading(period_start=1, period_end=2)) + await events.dispatch_dict_event( + {"type": "heartbeat_read_hops", "data": {}}, + [consumer], + ) + + assert len(flushed) == 1 + assert len(flushed[0]) == 1 @pytest.mark.asyncio async def test_handler_exception_is_isolated(self, caplog): @@ -90,18 +181,13 @@ async def failing(event): async def capture(event): captured.append(event) - raw = { - "event_type": "log", - "event_id": 1, - "level": "info", - "message": "hi", - } + raw = {"type": "electrical_meter_reading", "data": _reading_data()} with caplog.at_level(logging.ERROR): await events.dispatch_dict_event(raw, [failing, capture]) # Second handler still runs. assert len(captured) == 1 - assert isinstance(captured[0], LogEvent) + assert isinstance(captured[0], ElectricalMeterReading) # --------------------------------------------------------------------------- @@ -115,74 +201,30 @@ async def test_buffers_until_batch_full(self, monkeypatch): """Below the batch size, no flush happens.""" flushed: list = [] - async def fake_flush(batch): + async def fake_flush(batch, flask_app): + del flask_app flushed.append(list(batch)) monkeypatch.setattr(events, "_flush_readings", fake_flush) consumer = events.build_reading_consumer(app=None) for i in range(events.READING_BATCH_SIZE - 1): - await consumer( - MeterReadingEvent( - event_id=i, - event_type="meter_reading", - meter_id=str(i), - period_start=1, - period_end=2, - state=MeterState.ON, - uptime_seconds=0, - frequency_hz=50.0, - voltage_min=0.0, - voltage_avg=0.0, - voltage_max=0.0, - current_min_amps=0.0, - current_avg_amps=0.0, - current_max_amps=0.0, - true_power_avg_watts=0.0, - true_power_inst_watts=0.0, - apparent_power_avg_va=0.0, - power_factor_avg=0.0, - energy_wh=0.0, - user_power_limit_watts=0.0, - ) - ) + await consumer(_reading(node_id=i)) assert flushed == [] @pytest.mark.asyncio async def test_flushes_on_batch_full(self, monkeypatch): flushed: list = [] - async def fake_flush(batch): + async def fake_flush(batch, flask_app): + del flask_app flushed.append(list(batch)) monkeypatch.setattr(events, "_flush_readings", fake_flush) consumer = events.build_reading_consumer(app=None) for i in range(events.READING_BATCH_SIZE): - await consumer( - MeterReadingEvent( - event_id=i, - event_type="meter_reading", - meter_id=str(i), - period_start=1, - period_end=2, - state=MeterState.ON, - uptime_seconds=0, - frequency_hz=50.0, - voltage_min=0.0, - voltage_avg=0.0, - voltage_max=0.0, - current_min_amps=0.0, - current_avg_amps=0.0, - current_max_amps=0.0, - true_power_avg_watts=0.0, - true_power_inst_watts=0.0, - apparent_power_avg_va=0.0, - power_factor_avg=0.0, - energy_wh=0.0, - user_power_limit_watts=0.0, - ) - ) + await consumer(_reading(node_id=i)) assert len(flushed) == 1 assert len(flushed[0]) == events.READING_BATCH_SIZE @@ -190,60 +232,52 @@ async def fake_flush(batch): async def test_non_reading_events_ignored(self, monkeypatch): flushed: list = [] - async def fake_flush(batch): + async def fake_flush(batch, flask_app): + del flask_app flushed.append(list(batch)) monkeypatch.setattr(events, "_flush_readings", fake_flush) consumer = events.build_reading_consumer(app=None) - await consumer(LogEvent(event_id=1, event_type="log", level=LogLevel.INFO, message="hi")) + await consumer(object()) assert flushed == [] + @pytest.mark.asyncio + async def test_flushes_partial_batch_on_heartbeat(self, monkeypatch): + flushed: list = [] -# --------------------------------------------------------------------------- -# Log consumer -# --------------------------------------------------------------------------- + async def fake_flush(batch, flask_app): + del flask_app + flushed.append(list(batch)) + monkeypatch.setattr(events, "_flush_readings", fake_flush) + consumer = events.build_reading_consumer(app=None) -class TestLogConsumer: - @pytest.mark.asyncio - async def test_log_event_forwards_to_logger(self, caplog): - consumer = events.build_log_consumer(app=None) - with caplog.at_level(logging.WARNING, logger="sparkmeter.metering.provider"): - await consumer(LogEvent(event_id=1, event_type="log", level=LogLevel.WARN, message="hello")) - assert any(rec.message == "hello" for rec in caplog.records) + await consumer(_reading(node_id=1)) + await consumer(_heartbeat(1, 1, 1)) + + assert len(flushed) == 1 + assert len(flushed[0]) == 1 @pytest.mark.asyncio - async def test_non_log_event_ignored(self, caplog): - consumer = events.build_log_consumer(app=None) - with caplog.at_level(logging.DEBUG, logger="sparkmeter.metering.provider"): - await consumer( - MeterReadingEvent( - event_id=1, - event_type="meter_reading", - meter_id="x", - period_start=1, - period_end=2, - state=MeterState.ON, - uptime_seconds=0, - frequency_hz=50.0, - voltage_min=0.0, - voltage_avg=0.0, - voltage_max=0.0, - current_min_amps=0.0, - current_avg_amps=0.0, - current_max_amps=0.0, - true_power_avg_watts=0.0, - true_power_inst_watts=0.0, - apparent_power_avg_va=0.0, - power_factor_avg=0.0, - energy_wh=0.0, - user_power_limit_watts=0.0, - ) - ) - # No record from the metering.provider logger. - provider_records = [r for r in caplog.records if r.name == "sparkmeter.metering.provider"] - assert provider_records == [] + async def test_flushes_partial_batch_on_timer(self, monkeypatch): + flushed: list = [] + + async def fake_flush(batch, flask_app): + del flask_app + flushed.append(list(batch)) + + monkeypatch.setattr(events, "_flush_readings", fake_flush) + monkeypatch.setattr(events, "READING_FLUSH_INTERVAL_SECONDS", 0) + consumer = events.build_reading_consumer(app=None) + + await consumer(_reading(node_id=1)) + + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert len(flushed) == 1 + assert len(flushed[0]) == 1 # --------------------------------------------------------------------------- @@ -251,19 +285,6 @@ async def test_non_log_event_ignored(self, caplog): # --------------------------------------------------------------------------- -def _heartbeat(total: int, attempted: int, responded: int) -> HeartbeatSummaryEvent: - return HeartbeatSummaryEvent( - event_id=1, - event_type="heartbeat_summary", - timestamp=1700000000, - total_registered_meters=total, - meters_attempted=attempted, - meters_responded=responded, - packets_sent=attempted, - packets_received=responded, - ) - - class TestWatchdog: @pytest.mark.asyncio async def test_below_threshold_does_not_warn(self, monkeypatch, caplog): @@ -299,3 +320,190 @@ async def test_resets_on_recovery(self, monkeypatch, caplog): await watchdog(_heartbeat(total=20, attempted=20, responded=0)) # Two dropouts before reset, then two more — still below threshold. assert not any("dropout heartbeats" in r.message for r in caplog.records) + + @pytest.mark.asyncio + async def test_zero_roster_after_registered_meter_requests_restart(self, monkeypatch, caplog): + monkeypatch.setenv("METERING_WATCHDOG_MIN_NODES", "10") + gateway_state = {"needs_full_restart": False} + app = SimpleNamespace(state=SimpleNamespace(metering_gateway_state=gateway_state)) + watchdog = events.build_watchdog(app=app) + + with caplog.at_level(logging.WARNING): + await watchdog(_heartbeat(total=1, attempted=1, responded=1)) + await watchdog(_heartbeat(total=0, attempted=0, responded=0)) + + assert gateway_state["needs_full_restart"] is True + assert any("provider roster dropped to zero" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# dispatch_dict_event — parse failures and info-level side channels +# --------------------------------------------------------------------------- + + +class TestDispatchParseFailures: + @pytest.mark.asyncio + async def test_unparseable_reading_is_logged_and_dropped(self, caplog): + captured: list = [] + + async def capture(event): + captured.append(event) + + with caplog.at_level(logging.ERROR): + await events.dispatch_dict_event( + {"type": "electrical_meter_reading", "data": {"unexpected": "shape"}}, + [capture], + ) + + assert captured == [] + assert any("failed to parse" in rec.message for rec in caplog.records) + + @pytest.mark.asyncio + async def test_unparseable_heartbeat_is_logged_and_dropped(self, caplog): + captured: list = [] + + async def capture(event): + captured.append(event) + + with caplog.at_level(logging.ERROR): + await events.dispatch_dict_event( + {"type": "heartbeat_statistics", "data": {"unexpected": "shape"}}, + [capture], + ) + + assert captured == [] + assert any("heartbeat failed to parse" in rec.message for rec in caplog.records) + + @pytest.mark.asyncio + async def test_info_side_channel_logged_at_info(self, caplog): + with caplog.at_level(logging.INFO): + await events.dispatch_dict_event( + {"type": "electrical_meter_configuration_accepted", "data": {}}, [] + ) + + info_records = [rec for rec in caplog.records if rec.levelno == logging.INFO] + assert any("side-channel" in rec.message for rec in info_records) + + +# --------------------------------------------------------------------------- +# Flush lifecycle edge cases and DB writeback +# --------------------------------------------------------------------------- + + +class _FakeSentry: + def __init__(self): + self.captured: list = [] + + def captureException(self, message=None, tags=None): + self.captured.append((message, tags)) + + +class _FakeFlaskApp: + def __init__(self): + self.sentry = _FakeSentry() + + def app_context(self): + return contextlib.nullcontext() + + +class TestFlushLifecycle: + @pytest.mark.asyncio + async def test_heartbeat_with_no_pending_readings_does_not_flush(self, monkeypatch): + flushed: list = [] + + async def fake_flush(batch, flask_app): + del flask_app + flushed.append(list(batch)) + + monkeypatch.setattr(events, "_flush_readings", fake_flush) + consumer = events.build_reading_consumer(app=None) + + # No reading has been buffered, so the heartbeat's cancel+flush is a no-op. + await consumer(_heartbeat(1, 1, 1)) + + assert flushed == [] + + @pytest.mark.asyncio + async def test_flush_readings_tolerates_missing_flask_app(self, caplog): + # _write_readings_sync raises without a Flask app; _flush_readings swallows it. + with caplog.at_level(logging.ERROR): + await events._flush_readings([_reading()], None) + + assert any("flush failed" in rec.message for rec in caplog.records) + + +class TestWriteReadingsSync: + def test_persists_valid_reading(self, monkeypatch): + calls: list = [] + + def fake_add_reading(reading_data, update_meter_state=False): + calls.append((reading_data, update_meter_state)) + + monkeypatch.setattr("sparkmeter.controller.add_reading", fake_add_reading) + + events._write_readings_sync([_reading(node_id=100, energy=1234.5)], _FakeFlaskApp()) + + assert len(calls) == 1 + reading_data, update_meter_state = calls[0] + assert update_meter_state is False + assert reading_data["meter"] == 100 + # The spec state id (1) is translated to its MeterState name, not passed through. + assert reading_data["state"] == "on" + assert reading_data["uptime"] == 12345 + # Both period timestamps are converted; end - start is the 900s window. + assert (reading_data["heartbeat_end"] - reading_data["heartbeat_start"]).total_seconds() == 900 + # Every electrical field maps to its own slot — distinct values catch a swap. + assert reading_data["frequency"] == pytest.approx(50.0) + assert reading_data["voltage_min"] == pytest.approx(220.0) + assert reading_data["voltage_max"] == pytest.approx(235.0) + assert reading_data["voltage_avg"] == pytest.approx(230.5) + assert reading_data["current_min"] == pytest.approx(1.0) + assert reading_data["current_max"] == pytest.approx(10.0) + assert reading_data["current_avg"] == pytest.approx(5.0) + assert reading_data["true_power_inst"] == pytest.approx(1100.0) + assert reading_data["true_power_avg"] == pytest.approx(1000.0) + assert reading_data["apparent_power_avg"] == pytest.approx(1200.0) + assert reading_data["power_factor_avg"] == pytest.approx(0.95) + assert reading_data["energy"] == pytest.approx(1234.5) + assert reading_data["user_power_limit"] == pytest.approx(1500.0) + + def test_lock_timeout_is_reported_to_sentry(self, monkeypatch): + def fake_add_reading(reading_data, update_meter_state=False): + raise DatabaseLockTimeoutException("locked") + + monkeypatch.setattr("sparkmeter.controller.add_reading", fake_add_reading) + flask_app = _FakeFlaskApp() + + events._write_readings_sync([_reading(node_id=77)], flask_app) + + assert flask_app.sentry.captured + message, tags = flask_app.sentry.captured[0] + assert message == "Meter 77 reading lock timeout" + assert tags == {"action": "reading"} + + def test_duplicate_reading_is_skipped(self, monkeypatch, caplog): + def fake_add_reading(reading_data, update_meter_state=False): + raise DuplicateReadingException("dupe") + + monkeypatch.setattr("sparkmeter.controller.add_reading", fake_add_reading) + + with caplog.at_level(logging.WARNING): + events._write_readings_sync([_reading(node_id=88)], _FakeFlaskApp()) + + assert any("duplicate reading" in rec.message for rec in caplog.records) + + def test_incomplete_heartbeat_window_is_discarded(self, monkeypatch, caplog): + calls = [] + + def fake_add_reading(reading_data, update_meter_state=False): + calls.append(reading_data) + + monkeypatch.setattr("sparkmeter.controller.add_reading", fake_add_reading) + + # A falsy period_start means the heartbeat window is incomplete, so the + # reading is dropped before it ever reaches add_reading. + with caplog.at_level(logging.WARNING): + events._write_readings_sync([_reading(node_id=55, period_start=0)], _FakeFlaskApp()) + + assert calls == [] + assert any("incomplete heartbeat window" in rec.message for rec in caplog.records) diff --git a/sparkmeter/metering/tests/test_http_sse.py b/sparkmeter/metering/tests/test_http_sse.py new file mode 100644 index 0000000..2f8a877 --- /dev/null +++ b/sparkmeter/metering/tests/test_http_sse.py @@ -0,0 +1,120 @@ +"""Tests for the shared HTTP SSE event stream helper.""" + +import pytest + +from sparkmeter.metering import http_sse + + +class _FakeResponse: + def __init__(self, lines): + self._lines = lines + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + del exc_type, exc, tb + return False + + def raise_for_status(self): + return None + + async def aiter_lines(self): + for line in self._lines: + yield line + + +class _FakeAsyncClient: + calls = [] + init_kwargs = [] + + def __init__(self, *, base_url, timeout): + self.base_url = base_url + self.timeout = timeout + type(self).init_kwargs.append({"base_url": base_url, "timeout": timeout}) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + del exc_type, exc, tb + return False + + def stream(self, method, path, params=None, headers=None): + type(self).calls.append( + { + "method": method, + "path": path, + "params": params, + "headers": headers, + } + ) + return _FakeResponse( + [ + 'data: {"type":"gateway_status"}', + "", + 'data: {"event_type":"meter_reading","meter_id":"42"}', + "", + ] + ) + + +@pytest.mark.asyncio +async def test_stream_json_events_uses_streaming_sse_request(monkeypatch): + monkeypatch.setattr(http_sse.httpx, "AsyncClient", _FakeAsyncClient) + _FakeAsyncClient.calls.clear() + _FakeAsyncClient.init_kwargs.clear() + + events = [] + async for event in http_sse.stream_json_events("http://127.0.0.1:18080/", "test-client"): + events.append(event) + + # The trailing slash is stripped and the stream uses an unbounded timeout. + assert _FakeAsyncClient.init_kwargs == [{"base_url": "http://127.0.0.1:18080", "timeout": None}] + + assert events == [ + {"type": "gateway_status"}, + {"event_type": "meter_reading", "meter_id": "42"}, + ] + assert _FakeAsyncClient.calls == [ + { + "method": "GET", + "path": "/v1/events", + "params": {"client_id": "test-client"}, + "headers": { + "Accept": "text/event-stream", + "X-Client-Id": "test-client", + }, + } + ] + + +@pytest.mark.asyncio +async def test_iter_sse_payloads_skips_comments_and_flushes_trailing_data(): + # A comment line (leading ":"), a multi-line data event terminated by a + # blank line, then a final data event with no trailing blank line. + response = _FakeResponse( + [ + ": keep-alive heartbeat", + "event: meter_reading", + "data: line-one", + "data: line-two", + "", + "data: trailing-without-blank", + ] + ) + + payloads = [payload async for payload in http_sse._iter_sse_data_payloads(response)] + + assert payloads == ["line-one\nline-two", "trailing-without-blank"] + + +@pytest.mark.asyncio +async def test_iter_sse_payloads_handles_data_field_without_space(): + # `data:x` (no space after the colon) is valid SSE and must parse the same + # as `data: x`. A leading blank line with an empty buffer is also ignored. + response = _FakeResponse(["", "data:no-space-value", ""]) + + payloads = [payload async for payload in http_sse._iter_sse_data_payloads(response)] + + assert payloads == ["no-space-value"] diff --git a/sparkmeter/metering/tests/test_lifespan.py b/sparkmeter/metering/tests/test_lifespan.py index b4b9955..db332c7 100644 --- a/sparkmeter/metering/tests/test_lifespan.py +++ b/sparkmeter/metering/tests/test_lifespan.py @@ -1,5 +1,9 @@ -"""Tests for the metering FastAPI lifespan.""" +"""Tests for gateway disconnect/reconnect handling in the metering lifespan.""" +import asyncio +import contextlib +import sys +from concurrent.futures import TimeoutError as FutureTimeoutError from types import SimpleNamespace import pytest @@ -7,16 +11,929 @@ from sparkmeter.metering import lifespan -@pytest.mark.asyncio -async def test_metering_lifespan_is_noop_on_cloud(monkeypatch): - """On cloud, the lifespan sets app.state.metering to None and starts nothing.""" - monkeypatch.setattr("sparkmeter.config.configdict.config.is_cloud", lambda: True) +class _FakeFlaskApp: + """A flask-app stand-in exposing an app_context() context manager.""" - app = SimpleNamespace(state=SimpleNamespace()) + def app_context(self): + return contextlib.nullcontext() - async with lifespan.metering_lifespan(app): + +def _build_app(**overrides): + commands_allowed = overrides.pop("commands_allowed", asyncio.Event()) + gateway_state = { + "gateway_connected": None, + "gateway_paused": False, + "gateway_recovery_task": None, + "needs_full_restart": False, + "commands_allowed": commands_allowed, + } + gateway_state.update(overrides) + app = SimpleNamespace( + state=SimpleNamespace( + metering_gateway_state=gateway_state, + metering=None, + flask_app=object(), + ) + ) + return app, gateway_state + + +class TestObserveGatewayStatus: + @pytest.mark.asyncio + async def test_connected_event_stops_recovery_poll_and_requests_restart(self): + started = asyncio.Event() + + async def waiting_recovery_task(): + started.set() + await asyncio.sleep(3600) + + recovery_task = asyncio.create_task(waiting_recovery_task()) + await started.wait() + + commands_allowed = asyncio.Event() + app, gateway_state = _build_app( + gateway_connected=False, + gateway_paused=True, + gateway_recovery_task=recovery_task, + commands_allowed=commands_allowed, + ) + + await lifespan._observe_gateway_status( + app, + {"type": "gateway_status", "data": {"connected": True}}, + ) + + assert gateway_state["gateway_connected"] is True + assert gateway_state["gateway_paused"] is False + assert gateway_state["needs_full_restart"] is True + assert gateway_state["commands_allowed"].is_set() is True + assert gateway_state["gateway_recovery_task"] is None + assert recovery_task.cancelled() is True + + @pytest.mark.asyncio + async def test_connected_event_while_healthy_does_not_request_restart(self, monkeypatch): + reconcile_calls = 0 + + async def fake_reconcile(app): + del app + nonlocal reconcile_calls + reconcile_calls += 1 + + monkeypatch.setattr(lifespan, "_run_provider_reconcile", fake_reconcile) + + commands_allowed = asyncio.Event() + commands_allowed.set() + app, gateway_state = _build_app( + gateway_connected=True, + gateway_paused=False, + commands_allowed=commands_allowed, + ) + + await lifespan._observe_gateway_status( + app, + {"type": "gateway_status", "data": {"connected": True}}, + ) + await lifespan._run_pending_provider_restart(app) + + assert reconcile_calls == 0 + assert gateway_state["needs_full_restart"] is False + + @pytest.mark.asyncio + async def test_restart_runs_once_after_connected_event_stops_poll(self, monkeypatch): + reconcile_calls = 0 + started = asyncio.Event() + + async def waiting_recovery_task(): + started.set() + await asyncio.sleep(3600) + + async def fake_reconcile(app): + del app + nonlocal reconcile_calls + reconcile_calls += 1 + + monkeypatch.setattr(lifespan, "_run_provider_reconcile", fake_reconcile) + + recovery_task = asyncio.create_task(waiting_recovery_task()) + await started.wait() + + commands_allowed = asyncio.Event() + app, gateway_state = _build_app( + gateway_connected=False, + gateway_paused=True, + gateway_recovery_task=recovery_task, + commands_allowed=commands_allowed, + ) + + await lifespan._observe_gateway_status( + app, + {"type": "gateway_status", "data": {"connected": True}}, + ) + await lifespan._run_pending_provider_restart(app) + + assert reconcile_calls == 1 + assert gateway_state["needs_full_restart"] is False + + @pytest.mark.asyncio + async def test_removed_driver_stops_gateway_recovery_poll(self, monkeypatch): + shutdown_calls = 0 + + async def fake_shutdown(app): + del app + nonlocal shutdown_calls + shutdown_calls += 1 + + monkeypatch.setattr(lifespan, "shutdown_metering_runtime", fake_shutdown) + + app, gateway_state = _build_app( + gateway_connected=False, + gateway_paused=True, + ) + + def removed_provider(*, default="", flask_app=None): + del default, flask_app + return "" + + monkeypatch.setattr(lifespan, "configured_provider_url", removed_provider) + + await lifespan._recover_provider_after_gateway_loss(app) + + assert shutdown_calls == 1 + + +class TestInProcessActivation: + def test_get_running_public_app_uses_main_module_alias(self, monkeypatch): + app = SimpleNamespace(state=SimpleNamespace(main_loop=object())) + fake_main = SimpleNamespace( + __spec__=SimpleNamespace(name="sparkmeter.asgi"), + public_app=app, + ) + + monkeypatch.delitem(sys.modules, "sparkmeter.asgi", raising=False) + monkeypatch.setitem(sys.modules, "__main__", fake_main) + + assert lifespan._get_running_public_app() is app + + def test_activate_metering_runtime_uses_existing_running_app(self, monkeypatch): + loop = object() + app = SimpleNamespace(state=SimpleNamespace(main_loop=loop)) + fake_main = SimpleNamespace( + __spec__=SimpleNamespace(name="sparkmeter.asgi"), + public_app=app, + ) + seen = {} + + monkeypatch.delitem(sys.modules, "sparkmeter.asgi", raising=False) + monkeypatch.setitem(sys.modules, "__main__", fake_main) + + async def fake_ensure(public_app, skip_provider_init=False): + seen["app"] = public_app + seen["skip_provider_init"] = skip_provider_init + return True + + def fake_run_coroutine_threadsafe(coro, running_loop): + seen["loop"] = running_loop + result = asyncio.run(coro) + + class _ImmediateFuture: + def result(self, timeout=None): + seen["timeout"] = timeout + return result + + return _ImmediateFuture() + + monkeypatch.setattr(lifespan, "ensure_metering_runtime", fake_ensure) + monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", fake_run_coroutine_threadsafe) + + activated, error = lifespan.activate_metering_runtime_in_process(timeout=3.0) + + assert activated is True + assert error is None + assert seen["app"] is app + assert seen["loop"] is loop + assert seen["timeout"] == 3.0 + + def test_activate_reports_missing_public_app(self, monkeypatch): + monkeypatch.setattr(lifespan, "_get_running_public_app", lambda: None) + activated, error = lifespan.activate_metering_runtime_in_process() + assert activated is False + assert error == "public app is not available" + + def test_activate_reports_missing_main_loop(self, monkeypatch): + app = SimpleNamespace(state=SimpleNamespace(main_loop=None)) + monkeypatch.setattr(lifespan, "_get_running_public_app", lambda: app) + activated, error = lifespan.activate_metering_runtime_in_process() + assert activated is False + assert error == "main event loop is not available" + + +class TestProviderSignature: + def test_builds_tuple(self): + provider = { + "id": "a", + "base_url": "http://x", + "selected_interface": "grpc", + "enabled": True, + } + assert lifespan._provider_signature(provider) == ("a", "http://x", "grpc", True) + + def test_none_provider(self): + assert lifespan._provider_signature(None) is None + + +class TestEnabledProviderSignature: + def test_no_flask_app_returns_none(self): + assert lifespan._enabled_provider_signature(None) is None + + def test_no_enabled_provider_returns_none(self, monkeypatch): + monkeypatch.setattr("sparkmeter.config.provider_settings.get_enabled_provider", lambda: None) + assert lifespan._enabled_provider_signature(_FakeFlaskApp()) is None + + def test_lookup_error_returns_none(self, monkeypatch): + def boom(): + raise RuntimeError("db down") + + monkeypatch.setattr("sparkmeter.config.provider_settings.get_enabled_provider", boom) + assert lifespan._enabled_provider_signature(_FakeFlaskApp()) is None + + def test_success_returns_signature(self, monkeypatch): + monkeypatch.setattr( + "sparkmeter.config.provider_settings.get_enabled_provider", + lambda: { + "id": "a", + "base_url": "http://x", + "selected_interface": "http", + "enabled": True, + }, + ) + assert lifespan._enabled_provider_signature(_FakeFlaskApp()) == ( + "a", + "http://x", + "http", + True, + ) + + +class TestInitializeConfiguredProviders: + def test_requires_flask_app(self): + with pytest.raises(RuntimeError): + lifespan._initialize_configured_providers_with_app_context(None, 10.0) + + def test_runs_within_app_context(self, monkeypatch): + monkeypatch.setattr( + "sparkmeter.config.provider_settings.initialize_configured_providers_on_startup", + lambda timeout: [{"success": True, "timeout": timeout}], + ) + result = lifespan._initialize_configured_providers_with_app_context(_FakeFlaskApp(), 5.0) + assert result == [{"success": True, "timeout": 5.0}] + + +class TestShutdownMeteringRuntime: + @pytest.mark.asyncio + async def test_cancels_tasks_and_closes_clients(self): + async def _long(): + await asyncio.sleep(3600) + + dispatcher = asyncio.create_task(_long()) + sse = asyncio.create_task(_long()) + await asyncio.sleep(0) + + closed = [] + + class _Client: + def __init__(self, name): + self.name = name + + async def close(self): + closed.append(self.name) + + app = SimpleNamespace( + state=SimpleNamespace( + metering_dispatcher_task=dispatcher, + metering_sse_task=sse, + metering_event_client=_Client("event"), + metering=_Client("command"), + ) + ) + + await lifespan.shutdown_metering_runtime(app) + + assert dispatcher.cancelled() + assert sse.cancelled() + assert sorted(closed) == ["command", "event"] + assert app.state.metering is None + assert app.state.metering_provider_signature is None + + +class TestRunProviderReconcile: + @pytest.mark.asyncio + async def test_requires_client_and_flask_app(self): + app = SimpleNamespace(state=SimpleNamespace(metering=None, flask_app=None)) + with pytest.raises(RuntimeError): + await lifespan._run_provider_reconcile(app) + + @pytest.mark.asyncio + async def test_delegates_to_reconcile_all(self, monkeypatch): + seen = {} + + async def fake_reconcile(client, flask_app, skip_provider_init=False): + seen["client"] = client + seen["flask_app"] = flask_app + seen["skip"] = skip_provider_init + + monkeypatch.setattr("sparkmeter.metering.reconcile.reconcile_all", fake_reconcile) + client_obj = object() + flask_obj = object() + app = SimpleNamespace(state=SimpleNamespace(metering=client_obj, flask_app=flask_obj)) + + await lifespan._run_provider_reconcile(app, skip_provider_init=True) + + # The active client and Flask app are forwarded, not just the flag. + assert seen["client"] is client_obj + assert seen["flask_app"] is flask_obj + assert seen["skip"] is True + + +class TestRunPendingProviderRestart: + @pytest.mark.asyncio + async def test_noop_when_not_needed(self, monkeypatch): + calls = [] + monkeypatch.setattr(lifespan, "_run_provider_reconcile", lambda app: calls.append(1) or _async_none()) + app, _ = _build_app(needs_full_restart=False) + await lifespan._run_pending_provider_restart(app) + assert calls == [] + + @pytest.mark.asyncio + async def test_defers_while_paused(self, monkeypatch): + calls = [] + + async def fake_reconcile(app): + calls.append(1) + + monkeypatch.setattr(lifespan, "_run_provider_reconcile", fake_reconcile) + app, _ = _build_app(needs_full_restart=True, gateway_paused=True) + await lifespan._run_pending_provider_restart(app) + assert calls == [] + + @pytest.mark.asyncio + async def test_reconciles_and_clears_flag(self, monkeypatch): + calls = [] + + async def fake_reconcile(app): + calls.append(1) + + monkeypatch.setattr(lifespan, "_run_provider_reconcile", fake_reconcile) + app, gateway_state = _build_app(needs_full_restart=True, gateway_paused=False) + await lifespan._run_pending_provider_restart(app) + assert calls == [1] + assert gateway_state["needs_full_restart"] is False + + +class TestObserveGatewayDisconnect: + @pytest.mark.asyncio + async def test_non_gateway_event_ignored(self): + app, gateway_state = _build_app() + await lifespan._observe_gateway_status(app, {"type": "meter_reading"}) + assert gateway_state["gateway_paused"] is False + + @pytest.mark.asyncio + async def test_disconnect_pauses_and_starts_recovery(self, monkeypatch): + async def fake_recover(app): + await asyncio.sleep(3600) + + monkeypatch.setattr(lifespan, "_recover_provider_after_gateway_loss", fake_recover) + commands_allowed = asyncio.Event() + commands_allowed.set() + app, gateway_state = _build_app(gateway_connected=True, commands_allowed=commands_allowed) + + await lifespan._observe_gateway_status(app, {"type": "gateway_status", "data": {"connected": False}}) + + assert gateway_state["gateway_paused"] is True + assert gateway_state["commands_allowed"].is_set() is False + recovery_task = gateway_state["gateway_recovery_task"] + assert recovery_task is not None + + recovery_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await recovery_task + + +class TestCancelGatewayRecoveryPoll: + @pytest.mark.asyncio + async def test_noop_without_task(self): + gateway_state = {"gateway_recovery_task": None} + await lifespan._cancel_gateway_recovery_poll(gateway_state) + assert gateway_state["gateway_recovery_task"] is None + + @pytest.mark.asyncio + async def test_cancels_running_task(self): + async def _long(): + await asyncio.sleep(3600) + + task = asyncio.create_task(_long()) + await asyncio.sleep(0) + gateway_state = {"gateway_recovery_task": task} + + await lifespan._cancel_gateway_recovery_poll(gateway_state) + + assert task.cancelled() + assert gateway_state["gateway_recovery_task"] is None + + +class TestWaitForGatewayOnline: + @pytest.mark.asyncio + async def test_removed_provider_raises(self, monkeypatch): + monkeypatch.setattr(lifespan, "configured_provider_url", lambda *, default="", flask_app=None: "") + app = SimpleNamespace(state=SimpleNamespace(flask_app=object())) + with pytest.raises(lifespan._ProviderRemovedDuringRecovery): + await lifespan._wait_for_gateway_online(app) + + @pytest.mark.asyncio + async def test_returns_when_gateway_connected(self, monkeypatch): + monkeypatch.setattr( + lifespan, "configured_provider_url", lambda *, default="", flask_app=None: "http://drv" + ) + # If it slept, that would mean it wrongly treated "connected" as not-ready. + sleeps = [] + + async def fake_sleep(seconds): + sleeps.append(seconds) + + monkeypatch.setattr(lifespan.asyncio, "sleep", fake_sleep) + + gets = [] + + class _Resp: + def raise_for_status(self): + return None + + def json(self): + return {"connected": True} + + class _AsyncClient: + def __init__(self, timeout=None): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, url): + gets.append(url) + return _Resp() + + monkeypatch.setattr(lifespan.httpx, "AsyncClient", _AsyncClient) + app = SimpleNamespace(state=SimpleNamespace(flask_app=object())) + + await lifespan._wait_for_gateway_online(app) + + # A connected gateway is detected on the first probe of the status + # endpoint, and the poll returns immediately without sleeping/retrying. + assert gets == ["http://drv/v1/status"] + assert sleeps == [] + + +class TestMeteringLifespan: + @pytest.mark.asyncio + async def test_cloud_mode_is_noop(self, monkeypatch): + monkeypatch.setattr(lifespan, "_is_ground", lambda: False) + app = SimpleNamespace(state=SimpleNamespace()) + async with lifespan.metering_lifespan(app): + pass assert app.state.metering is None - # No metering runtime was started: only the no-op no-metering state exists. - assert app.state.metering is None - assert not hasattr(app.state, "metering_command_queue") + @pytest.mark.asyncio + async def test_offline_mode_is_noop(self, monkeypatch): + monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + monkeypatch.setattr(lifespan, "_metering_enabled", lambda: False) + app = SimpleNamespace(state=SimpleNamespace()) + async with lifespan.metering_lifespan(app): + pass + assert app.state.metering is None + + @pytest.mark.asyncio + async def test_not_started_skips_shutdown(self, monkeypatch): + monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + + async def fake_ensure(app): + return False + + shutdowns = [] + + async def fake_shutdown(app): + shutdowns.append(1) + + monkeypatch.setattr(lifespan, "ensure_metering_runtime", fake_ensure) + monkeypatch.setattr(lifespan, "shutdown_metering_runtime", fake_shutdown) + + app = SimpleNamespace(state=SimpleNamespace()) + async with lifespan.metering_lifespan(app): + pass + assert shutdowns == [] + + @pytest.mark.asyncio + async def test_started_runs_shutdown(self, monkeypatch): + monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + + async def fake_ensure(app): + return True + + shutdowns = [] + + async def fake_shutdown(app): + shutdowns.append(1) + + monkeypatch.setattr(lifespan, "ensure_metering_runtime", fake_ensure) + monkeypatch.setattr(lifespan, "shutdown_metering_runtime", fake_shutdown) + + app = SimpleNamespace(state=SimpleNamespace()) + async with lifespan.metering_lifespan(app): + pass + assert shutdowns == [1] + + +class TestEnsureMeteringRuntimeEarlyReturns: + @pytest.mark.asyncio + async def test_not_ground_returns_false(self, monkeypatch): + monkeypatch.setattr(lifespan, "_is_ground", lambda: False) + app = SimpleNamespace(state=SimpleNamespace()) + assert await lifespan.ensure_metering_runtime(app) is False + assert app.state.metering is None + + @pytest.mark.asyncio + async def test_offline_returns_false(self, monkeypatch): + monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + monkeypatch.setattr(lifespan, "_metering_enabled", lambda: False) + app = SimpleNamespace(state=SimpleNamespace()) + assert await lifespan.ensure_metering_runtime(app) is False + + @pytest.mark.asyncio + async def test_already_active_reruns_reconcile(self, monkeypatch): + monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + signature = ("a", "http://x", "http", True) + monkeypatch.setattr(lifespan, "_enabled_provider_signature", lambda flask_app: signature) + + reconciles = [] + + async def fake_reconcile(app, skip_provider_init=False): + reconciles.append(skip_provider_init) + + monkeypatch.setattr(lifespan, "_run_provider_reconcile", fake_reconcile) + + app = SimpleNamespace( + state=SimpleNamespace( + flask_app=object(), + metering=object(), + metering_provider_signature=signature, + ) + ) + + assert await lifespan.ensure_metering_runtime(app, skip_provider_init=True) is True + assert reconciles == [True] + + +def _async_none(): + async def _noop(): + return None + + return _noop() + + +class _SleepController: + """Stand-in for asyncio.sleep that records durations and can break a loop. + + Records every requested delay and, once `stop_after` calls have been made, + raises CancelledError so an otherwise-infinite retry loop terminates + deterministically without any real waiting. + """ + + def __init__(self, stop_after=None): + self.stop_after = stop_after + self.calls = 0 + self.durations = [] + + async def __call__(self, seconds): + self.calls += 1 + self.durations.append(seconds) + if self.stop_after is not None and self.calls >= self.stop_after: + raise asyncio.CancelledError + + +class TestMeteringEnabled: + def test_reads_offline_config_flag(self, config): + # Metering is enabled when OFFLINE is false, and disabled when true — + # proving the happy path actually reads the OFFLINE config value. + config["OFFLINE"] = False + assert lifespan._metering_enabled() is True + config["OFFLINE"] = True + assert lifespan._metering_enabled() is False + + def test_falls_back_to_env_when_config_errors(self, monkeypatch): + def boom(*args, **kwargs): + raise RuntimeError("config unavailable") + + monkeypatch.setattr("sparkmeter.config.configdict.config", SimpleNamespace(get=boom)) + + # SM_OFFLINE truthy -> metering disabled. + monkeypatch.setenv("SM_OFFLINE", "1") + assert lifespan._metering_enabled() is False + + # SM_OFFLINE unset/empty -> metering enabled. + monkeypatch.setenv("SM_OFFLINE", "") + assert lifespan._metering_enabled() is True + + +class TestRunSseConsumer: + @pytest.mark.asyncio + async def test_stream_ended_and_broken_request_full_restart_with_backoff(self, monkeypatch): + app, gateway_state = _build_app() + + class _FakeEventClient: + def __init__(self): + self.calls = 0 + + async def stream_events(self, client_id): + index = self.calls + self.calls += 1 + if index == 0: + # Connect successfully, deliver one event, then the + # stream ends -> "treating provider as restarted". + yield {"type": "meter_reading", "data": {}} + return + # Second connection attempt breaks mid-stream. + raise RuntimeError("stream broke") + yield # pragma: no cover - makes this an async generator + + async def noop_pending(app): + del app + + async def noop_observe(app, raw_event): + del app, raw_event + + async def noop_dispatch(raw, handlers): + del raw, handlers + + monkeypatch.setattr(lifespan, "_run_pending_provider_restart", noop_pending) + monkeypatch.setattr(lifespan, "_observe_gateway_status", noop_observe) + monkeypatch.setattr("sparkmeter.metering.events.build_handlers", lambda app: []) + monkeypatch.setattr("sparkmeter.metering.events.dispatch_dict_event", noop_dispatch) + + sleeper = _SleepController(stop_after=2) + monkeypatch.setattr(asyncio, "sleep", sleeper) + + with pytest.raises(asyncio.CancelledError): + await lifespan._run_sse_consumer(app, _FakeEventClient(), "cid") + + # Stream-ended branch (first iteration) and broken-stream branch + # (second iteration) both flag a full restart. + assert gateway_state["needs_full_restart"] is True + # Backoff doubled between the first and second reconnect delays. + assert sleeper.durations == [1.0, 2.0] + + @pytest.mark.asyncio + async def test_initial_connection_failure_does_not_request_restart(self, monkeypatch): + app, gateway_state = _build_app() + + class _FailingEventClient: + async def stream_events(self, client_id): + raise RuntimeError("never connected") + yield # pragma: no cover - makes this an async generator + + async def noop_pending(app): + del app + + monkeypatch.setattr(lifespan, "_run_pending_provider_restart", noop_pending) + monkeypatch.setattr("sparkmeter.metering.events.build_handlers", lambda app: []) + + sleeper = _SleepController(stop_after=1) + monkeypatch.setattr(asyncio, "sleep", sleeper) + + with pytest.raises(asyncio.CancelledError): + await lifespan._run_sse_consumer(app, _FailingEventClient(), "cid") + + # Never connected once, so a broken stream must not flag a restart. + assert gateway_state["needs_full_restart"] is False + assert sleeper.durations == [1.0] + + +class TestWaitForGatewayOnlineRetries: + @pytest.mark.asyncio + async def test_retries_while_disconnected_then_returns(self, monkeypatch): + monkeypatch.setattr( + lifespan, "configured_provider_url", lambda *, default="", flask_app=None: "http://drv/" + ) + + class _Resp: + def __init__(self, connected): + self._connected = connected + + def raise_for_status(self): + return None + + def json(self): + return {"connected": self._connected} + + # First poll reports disconnected, second reports connected. + responses = [_Resp(False), _Resp(True)] + + class _AsyncClient: + def __init__(self, timeout=None): + del timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, url): + del url + return responses.pop(0) + + monkeypatch.setattr(lifespan.httpx, "AsyncClient", _AsyncClient) + sleeper = _SleepController() + monkeypatch.setattr(asyncio, "sleep", sleeper) + + app = SimpleNamespace(state=SimpleNamespace(flask_app=object())) + await lifespan._wait_for_gateway_online(app) + + # One disconnected poll -> one retry sleep -> connected poll returns. + assert responses == [] + assert sleeper.calls == 1 + + @pytest.mark.asyncio + async def test_retries_when_status_endpoint_errors_then_returns(self, monkeypatch): + monkeypatch.setattr( + lifespan, "configured_provider_url", lambda *, default="", flask_app=None: "http://drv" + ) + + class _Resp: + def raise_for_status(self): + return None + + def json(self): + return {"connected": True} + + # First GET raises, second GET succeeds. + outcomes = [RuntimeError("status unavailable"), _Resp()] + + class _AsyncClient: + def __init__(self, timeout=None): + del timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, url): + del url + item = outcomes.pop(0) + if isinstance(item, Exception): + raise item + return item + + monkeypatch.setattr(lifespan.httpx, "AsyncClient", _AsyncClient) + sleeper = _SleepController() + monkeypatch.setattr(asyncio, "sleep", sleeper) + + app = SimpleNamespace(state=SimpleNamespace(flask_app=object())) + await lifespan._wait_for_gateway_online(app) + + # One errored poll -> one retry sleep -> successful poll returns. + assert outcomes == [] + assert sleeper.calls == 1 + + +class TestRecoverProviderAfterGatewayLoss: + @pytest.mark.asyncio + async def test_resumes_after_gateway_online(self, monkeypatch): + reconcile_calls = 0 + + async def fake_wait(app): + del app + + async def fake_reconcile(app): + del app + nonlocal reconcile_calls + reconcile_calls += 1 + + monkeypatch.setattr(lifespan, "_wait_for_gateway_online", fake_wait) + monkeypatch.setattr(lifespan, "_run_provider_reconcile", fake_reconcile) + + commands_allowed = asyncio.Event() + commands_allowed.clear() + app, gateway_state = _build_app( + gateway_connected=False, + gateway_paused=True, + needs_full_restart=True, + commands_allowed=commands_allowed, + ) + gateway_state["gateway_recovery_task"] = object() + + await lifespan._recover_provider_after_gateway_loss(app) + + assert reconcile_calls == 1 + assert gateway_state["gateway_connected"] is True + assert gateway_state["gateway_paused"] is False + assert gateway_state["needs_full_restart"] is False + assert gateway_state["commands_allowed"].is_set() is True + # finally-block clears the recovery-task handle. + assert gateway_state["gateway_recovery_task"] is None + + @pytest.mark.asyncio + async def test_retries_reconcile_on_failure_then_resumes(self, monkeypatch): + reconcile_calls = 0 + + async def fake_wait(app): + del app + + async def fake_reconcile(app): + del app + nonlocal reconcile_calls + reconcile_calls += 1 + if reconcile_calls == 1: + raise RuntimeError("reconcile failed") + + sleeper = _SleepController() + monkeypatch.setattr(lifespan, "_wait_for_gateway_online", fake_wait) + monkeypatch.setattr(lifespan, "_run_provider_reconcile", fake_reconcile) + monkeypatch.setattr(asyncio, "sleep", sleeper) + + commands_allowed = asyncio.Event() + commands_allowed.clear() + app, gateway_state = _build_app( + gateway_connected=False, + gateway_paused=True, + commands_allowed=commands_allowed, + ) + + await lifespan._recover_provider_after_gateway_loss(app) + + # First reconcile raised -> one backoff sleep -> second reconcile resumed. + assert reconcile_calls == 2 + assert sleeper.calls == 1 + assert gateway_state["gateway_paused"] is False + assert gateway_state["commands_allowed"].is_set() is True + + +class TestActivateMeteringRuntimeErrors: + def test_reports_timeout(self, monkeypatch): + app = SimpleNamespace(state=SimpleNamespace(main_loop=object())) + monkeypatch.setattr(lifespan, "_get_running_public_app", lambda: app) + + async def fake_ensure(public_app, skip_provider_init=False): + del public_app, skip_provider_init + return True + + monkeypatch.setattr(lifespan, "ensure_metering_runtime", fake_ensure) + + class _TimingOutFuture: + def result(self, timeout=None): + del timeout + raise FutureTimeoutError() + + def fake_run_coroutine_threadsafe(coro, running_loop): + del running_loop + coro.close() + return _TimingOutFuture() + + monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", fake_run_coroutine_threadsafe) + + activated, error = lifespan.activate_metering_runtime_in_process(timeout=1.0) + + assert activated is False + assert error == "timed out activating live metering runtime" + + def test_reports_generic_error(self, monkeypatch): + app = SimpleNamespace(state=SimpleNamespace(main_loop=object())) + monkeypatch.setattr(lifespan, "_get_running_public_app", lambda: app) + + async def fake_ensure(public_app, skip_provider_init=False): + del public_app, skip_provider_init + return True + + monkeypatch.setattr(lifespan, "ensure_metering_runtime", fake_ensure) + + class _FailingFuture: + def result(self, timeout=None): + del timeout + raise RuntimeError("boom") + + def fake_run_coroutine_threadsafe(coro, running_loop): + del running_loop + coro.close() + return _FailingFuture() + + monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", fake_run_coroutine_threadsafe) + + activated, error = lifespan.activate_metering_runtime_in_process() + + assert activated is False + assert error == "boom" diff --git a/sparkmeter/metering/tests/test_provider_config.py b/sparkmeter/metering/tests/test_provider_config.py new file mode 100644 index 0000000..54da65f --- /dev/null +++ b/sparkmeter/metering/tests/test_provider_config.py @@ -0,0 +1,33 @@ +"""Tests for runtime provider URL resolution.""" + +import contextlib + +from sparkmeter.config import provider_settings +from sparkmeter.metering.provider_config import configured_provider_url + + +class _FakeApp: + """Minimal stand-in exposing an app_context() context manager.""" + + def app_context(self): + return contextlib.nullcontext() + + +def test_configured_provider_url_uses_flask_app_context(monkeypatch): + monkeypatch.setattr( + provider_settings, "get_enabled_provider", lambda: {"base_url": "http://driver:18080"} + ) + assert configured_provider_url(default="unused", flask_app=_FakeApp()) == "http://driver:18080" + + +def test_configured_provider_url_returns_default_without_saved_provider(monkeypatch): + monkeypatch.setattr(provider_settings, "get_enabled_provider", lambda: None) + assert configured_provider_url(default="http://fallback") == "http://fallback" + + +def test_configured_provider_url_swallows_lookup_errors(monkeypatch): + def boom(): + raise RuntimeError("database unavailable") + + monkeypatch.setattr(provider_settings, "get_enabled_provider", boom) + assert configured_provider_url(default="http://fallback") == "http://fallback" diff --git a/sparkmeter/metering/tests/test_reconcile.py b/sparkmeter/metering/tests/test_reconcile.py index ba14870..44b6e9d 100644 --- a/sparkmeter/metering/tests/test_reconcile.py +++ b/sparkmeter/metering/tests/test_reconcile.py @@ -3,44 +3,70 @@ The pure-functional command builders below are what most of the reconcile correctness depends on. -The DB-loading paths (`_load_provider_command`, `_load_meters`) run in +The DB-loading paths (`_load_driver_init_payload`, `_load_meters`) run in worker threads via `asyncio.to_thread` from the FastAPI lifespan, where Flask's `current_app` is unavailable. They are tested with `app` fixture calls that intentionally invoke them from a fresh thread. """ import threading +from types import SimpleNamespace import pytest +from meter_driver_spec.http.models import ( + ConfigureElectricalMeterCompatRequest, + ElectricalMeterCommandName, + RegisterNodeRequest, + SetBalanceAndFlagsRequest, +) +from sparkmeter.config import provider_settings +from sparkmeter.meter.meterdomain import MeterConfig from sparkmeter.metering import reconcile -from sparkmeter.metering._generated.models.meter_behavior_command import MeterBehaviorCommand -from sparkmeter.metering._generated.models.submit_command_v_1_commands_post_request_body_command_type_enum import ( - SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum as CommandTypeEnum, -) + + +def _raise(*args, **kwargs): + raise RuntimeError("derivation boom") class TestBuildRegister: def test_with_mac(self): body = reconcile._build_register({"meter_id": "100", "meter_type": "SM5R", "mac": 0xABCD}) - assert body.command_type is CommandTypeEnum.REGISTER_METER - assert body.params.meter_id == "100" - assert body.params.meter_type == "SM5R" - assert body.vendor_options is not None - assert body.vendor_options["mac"] == 0xABCD + assert isinstance(body, RegisterNodeRequest) + assert body.node_id == 100 + assert body.node_type == "SM5R" + assert body.mac == 0xABCD - def test_without_mac_omits_vendor_options(self): + def test_without_mac(self): body = reconcile._build_register({"meter_id": "100", "meter_type": "SM5R"}) - assert body.vendor_options is None + assert body.mac is None - def test_unknown_meter_type_falls_back(self): + def test_unknown_meter_type_passes_through(self): + # Type validation happens upstream in `_resolve_meter_type`. body = reconcile._build_register({"meter_id": "100", "meter_type": "WIBBLE"}) - # The dict already has the type; reconcile passes it through. - # Type validation only happens in `_resolve_meter_type` upstream. - assert body.params.meter_type == "WIBBLE" + assert body.node_type == "WIBBLE" class TestBuildConfigure: + def test_config_behavior_takes_precedence(self): + body = reconcile._build_configure( + { + "meter_id": "42", + "is_active": False, + "config": { + "behavior": "enable", + "power_limit": 1500, + "current_limit": 10, + "startup_delay": 2, + "throttle_on_time": 5, + "throttle_off_time": 10, + "throttle_count_limit": 5, + }, + } + ) + assert isinstance(body, ConfigureElectricalMeterCompatRequest) + assert body.command is ElectricalMeterCommandName.ELECTRICALMETERCOMMANDENABLE + def test_active_meter_emits_enable(self): body = reconcile._build_configure( { @@ -57,9 +83,8 @@ def test_active_meter_emits_enable(self): } ) assert body is not None - assert body.command_type is CommandTypeEnum.CONFIGURE_METER - assert body.params.behavior is MeterBehaviorCommand.ENABLE - assert body.params.configuration.power_limit_watts == pytest.approx(1500.0) + assert body.command is ElectricalMeterCommandName.ELECTRICALMETERCOMMANDENABLE + assert body.configuration.power_limit == pytest.approx(1500.0) def test_inactive_meter_emits_disable(self): body = reconcile._build_configure( @@ -77,28 +102,31 @@ def test_inactive_meter_emits_disable(self): } ) assert body is not None - assert body.params.behavior is MeterBehaviorCommand.DISABLE + assert body.command is ElectricalMeterCommandName.ELECTRICALMETERCOMMANDDISABLE def test_no_config_returns_none(self): assert reconcile._build_configure({"meter_id": "42", "is_active": True}) is None + def test_unmappable_behavior_returns_none(self, monkeypatch): + # If the behavior verb has no spec command, no configure is emitted. + monkeypatch.setattr(reconcile, "behavior_to_command", lambda behavior: None) + assert reconcile._build_configure({"meter_id": "42", "is_active": True, "config": {}}) is None + class TestBuildBalance: def test_with_balance(self): body = reconcile._build_balance({"meter_id": "9", "balance": 12.5, "low_balance": True}) - assert body is not None - assert body.command_type is CommandTypeEnum.SET_BALANCE - assert body.params.meter_id == "9" - assert body.params.balance == "12.5" - assert body.params.low_balance is True + assert isinstance(body, SetBalanceAndFlagsRequest) + assert body.balance.model_dump() == {"sign": 1, "coef": 125, "exp": -1} + assert body.low_balance_flag is True def test_no_balance_returns_none(self): assert reconcile._build_balance({"meter_id": "9"}) is None - def test_decimal_string_preserves_precision(self): + def test_decimal_preserves_precision(self): body = reconcile._build_balance({"meter_id": "9", "balance": "0.00001", "low_balance": False}) assert body is not None - assert body.params.balance == "0.00001" + assert body.balance.model_dump() == {"sign": 1, "coef": 1, "exp": -5} class TestResolveMeterType: @@ -111,20 +139,16 @@ def __init__(self, model_name=None): self.model = TestResolveMeterType._MockModel(model_name) if model_name else None def test_known_type_uppercase(self): - meter = self._MockMeter("SM5R") - assert reconcile._resolve_meter_type(meter) == "SM5R" + assert reconcile._resolve_meter_type(self._MockMeter("SM5R")) == "SM5R" def test_lowercase_normalizes(self): - meter = self._MockMeter("smhce") - assert reconcile._resolve_meter_type(meter) == "SMHCE" + assert reconcile._resolve_meter_type(self._MockMeter("smhce")) == "SMHCE" def test_unknown_falls_back_to_sm5r(self): - meter = self._MockMeter("WIBBLE") - assert reconcile._resolve_meter_type(meter) == "SM5R" + assert reconcile._resolve_meter_type(self._MockMeter("WIBBLE")) == "SM5R" def test_no_model_falls_back_to_sm5r(self): - meter = self._MockMeter(None) - assert reconcile._resolve_meter_type(meter) == "SM5R" + assert reconcile._resolve_meter_type(self._MockMeter(None)) == "SM5R" # ---------------------------------------------------------------------- @@ -136,11 +160,7 @@ def test_no_model_falls_back_to_sm5r(self): def _run_in_fresh_thread(fn, *args): - """Invoke `fn(*args)` on a fresh `threading.Thread` and return its - result, re-raising any exception. The fresh thread has no Flask - context — this is what `asyncio.to_thread` does inside FastAPI's - lifespan. - """ + """Invoke `fn(*args)` on a fresh `threading.Thread`, re-raising errors.""" container: dict = {} def _target(): @@ -158,30 +178,340 @@ def _target(): class TestWorkerThreadEntryPoints: - """Guard the contract that reconcile loaders accept an explicit - Flask app and push their own context on the worker thread. - - Against the pre-fix code (`_load_meters()` with no arg, body using - `current_app.app_context()`), the calls below would raise - `RuntimeError: Working outside of application context` from the - worker thread. - """ - def test_load_meters_runs_in_thread_without_outer_context(self, app, session): rows = _run_in_fresh_thread(reconcile._load_meters, app) assert isinstance(rows, list) - def test_load_provider_command_runs_in_thread_without_outer_context(self, app, session, config): - # `_load_provider_command` reads `HEARTBEAT_PERIOD`; without it - # the function returns None. Either return value is acceptable - # — the test asserts no exception escapes the worker thread. - result = _run_in_fresh_thread(reconcile._load_provider_command, app) - assert result is None or hasattr(result, "params") + def test_load_meters_maps_meter_row_shape(self, app, session): + from sparkmeter.tests.test_data_factory import MeterFactory + + meter = MeterFactory() + session.commit() + + rows = _run_in_fresh_thread(reconcile._load_meters, app) + + assert len(rows) == 1 + row = rows[0] + assert set(row) == { + "meter_id", + "meter_type", + "mac", + "balance", + "low_balance", + "config", + "is_active", + } + # meter_id and mac are derived from the meter's code. + assert row["meter_id"] == str(meter.code) + assert row["mac"] == int(meter.code) def test_load_meters_signature_takes_flask_app(self): - """Calling `_load_meters` with no arg must fail loudly. The - no-arg form was the original (broken) signature; allowing it - again would reintroduce the bug. - """ with pytest.raises(TypeError): reconcile._load_meters() # type: ignore[call-arg] + + def test_load_driver_init_payload_reads_driver_fields(self, app, session, monkeypatch): + monkeypatch.setattr( + provider_settings, + "get_enabled_provider", + lambda: {"base_url": "http://127.0.0.1:18080", "selected_interface": "http"}, + ) + monkeypatch.setattr( + provider_settings, + "load_provider_runtime_settings", + lambda provider: { + "field_values": { + "aes_key": "00112233445566778899AABBCCDDEEFF", + "channel": "26", + "heartbeat_period_duration": "60", + } + }, + ) + + payload = _run_in_fresh_thread(reconcile._load_driver_init_payload, app) + + assert payload == { + "aes_key": "00112233445566778899AABBCCDDEEFF", + "channel": 26, + "heartbeat_period_duration": 60, + } + + def test_load_driver_init_payload_skips_invalid_aes_key(self, app, session, monkeypatch): + monkeypatch.setattr( + provider_settings, + "get_enabled_provider", + lambda: {"base_url": "http://127.0.0.1:18080", "selected_interface": "http"}, + ) + monkeypatch.setattr( + provider_settings, + "load_provider_runtime_settings", + lambda provider: { + "field_values": { + "aes_key": "not-hex", + "channel": "12", + "heartbeat_period_duration": "60", + } + }, + ) + + payload = _run_in_fresh_thread(reconcile._load_driver_init_payload, app) + + assert payload is None + + def test_load_driver_init_payload_requires_heartbeat(self, app, session, monkeypatch): + monkeypatch.setattr( + provider_settings, "get_enabled_provider", lambda: {"base_url": "http://127.0.0.1:18080"} + ) + monkeypatch.setattr( + provider_settings, + "load_provider_runtime_settings", + lambda provider: {"field_values": {"aes_key": "00112233445566778899AABBCCDDEEFF"}}, + ) + + # No heartbeat period means there is nothing to init the driver with. + assert _run_in_fresh_thread(reconcile._load_driver_init_payload, app) is None + + def test_load_driver_init_payload_skips_invalid_channel(self, app, session, monkeypatch): + monkeypatch.setattr( + provider_settings, "get_enabled_provider", lambda: {"base_url": "http://127.0.0.1:18080"} + ) + monkeypatch.setattr( + provider_settings, + "load_provider_runtime_settings", + lambda provider: { + "field_values": { + "aes_key": "00112233445566778899AABBCCDDEEFF", + "channel": "not-an-int", + "heartbeat_period_duration": "60", + } + }, + ) + + payload = _run_in_fresh_thread(reconcile._load_driver_init_payload, app) + + # The bad channel is dropped; the rest of the payload still comes through. + assert payload == { + "aes_key": "00112233445566778899AABBCCDDEEFF", + "heartbeat_period_duration": 60, + } + + def test_load_driver_init_payload_requires_aes_key(self, app, session, monkeypatch): + monkeypatch.setattr( + provider_settings, "get_enabled_provider", lambda: {"base_url": "http://127.0.0.1:18080"} + ) + monkeypatch.setattr( + provider_settings, + "load_provider_runtime_settings", + lambda provider: {"field_values": {"heartbeat_period_duration": "60"}}, + ) + + # No AES key means there is nothing to initialize the driver with. + assert _run_in_fresh_thread(reconcile._load_driver_init_payload, app) is None + + +class _RecordingClient: + """Metering client double that records every reconcile command.""" + + def __init__(self): + self.inits: list = [] + self.registered: list = [] + self.configured: list = [] + self.balances: list = [] + + async def init_driver(self, payload): + self.inits.append(payload) + + async def register_node(self, req): + self.registered.append(req) + + async def configure_meter(self, req): + self.configured.append(req) + + async def set_balance(self, node_id, req): + self.balances.append((node_id, req)) + + async def unregister_node(self, node_id): + pass + + +class TestReconcileAllLoop: + @pytest.mark.asyncio + async def test_registers_configures_and_sets_balance(self, monkeypatch): + meters = [ + { + "meter_id": "1", + "meter_type": "SM5R", + "mac": 1, + "is_active": True, + "config": { + "behavior": "enable", + "power_limit": 1500, + "current_limit": 10, + "startup_delay": 0, + "throttle_on_time": 5, + "throttle_off_time": 10, + "throttle_count_limit": 5, + }, + "balance": 12.5, + "low_balance": True, + }, + { + "meter_id": "2", + "meter_type": "SM5R", + "mac": 2, + "is_active": True, + "config": None, + "balance": None, + "low_balance": False, + }, + ] + monkeypatch.setattr(reconcile, "_load_driver_init_payload", lambda flask_app: None) + monkeypatch.setattr(reconcile, "_load_meters", lambda flask_app: meters) + + client = _RecordingClient() + await reconcile.reconcile_all(client, object()) + + # Both meters registered; only the one with config/balance drove those calls. + assert [req.node_id for req in client.registered] == [1, 2] + assert [req.node_id for req in client.configured] == [1] + assert [node_id for node_id, _ in client.balances] == [1] + + @pytest.mark.asyncio + async def test_meter_failure_is_isolated(self, monkeypatch): + meters = [ + {"meter_id": "1", "meter_type": "SM5R", "mac": 1, "is_active": True, "config": None}, + {"meter_id": "2", "meter_type": "SM5R", "mac": 2, "is_active": True, "config": None}, + ] + + class _FlakyClient(_RecordingClient): + async def register_node(self, req): + if req.node_id == 1: + raise RuntimeError("provider down") + self.registered.append(req) + + monkeypatch.setattr(reconcile, "_load_driver_init_payload", lambda flask_app: None) + monkeypatch.setattr(reconcile, "_load_meters", lambda flask_app: meters) + + client = _FlakyClient() + # A failure on meter 1 must not stop meter 2 from being reconciled. + await reconcile.reconcile_all(client, object()) + + assert [req.node_id for req in client.registered] == [2] + + +class TestConfigOf: + def _customer_meter(self): + cfg = SimpleNamespace( + startup_delay=3, throttle_on_time=6, throttle_off_time=11, throttle_count_limit=4 + ) + meter = SimpleNamespace( + code=42, + config=cfg, + continuous_current_limit=10.0, + provider_id=None, + state_value=MeterConfig.STATE_ON, + model=SimpleNamespace(inrush_limit=100.0, name="SM5R"), + scalars=SimpleNamespace(power_scalar=2.0, current_scalar=2.0), + tariff=SimpleNamespace(get_current_load_limit=lambda: 5000.0), + ground=SimpleNamespace(private=SimpleNamespace(override_meter_state=False)), + ) + meter.is_customer_meter = lambda: True + return meter + + def test_derives_customer_meter_config(self, session): + from sparkmeter.config.configparameter import parameters + + result = reconcile._config_of(self._customer_meter()) + assert result["behavior"] == "enable" + + # power_limit = min(continuous_current_limit * nominal_voltage, tariff + # load limit) / power_scalar. With continuous_current_limit=10.0, + # tariff=5000, power_scalar=2.0 and no provider_id (engineering units off). + nominal_voltage = parameters.NOMINAL_VOLTAGE + expected_power = min(10.0 * nominal_voltage, 5000.0) / 2.0 + assert result["power_limit"] == pytest.approx(expected_power) + # current_limit = min(inrush_limit / current_scalar, 65535) = 100/2 = 50. + assert result["current_limit"] == pytest.approx(50.0) + assert result["startup_delay"] == 3 + assert result["throttle_count_limit"] == 4 + + def test_no_config_returns_none(self, session): + meter = self._customer_meter() + meter.config = None + assert reconcile._config_of(meter) is None + + def test_non_customer_meter_returns_none(self, session): + meter = self._customer_meter() + meter.is_customer_meter = lambda: False + assert reconcile._config_of(meter) is None + + def test_derivation_error_returns_none(self, session): + meter = self._customer_meter() + meter.tariff = SimpleNamespace(get_current_load_limit=_raise) + assert reconcile._config_of(meter) is None + + def test_engineering_units_skip_scalar_division(self, session): + # A provider_id marks the meter as reporting in engineering units, so + # limits are not divided by the meter's scalars. + scalar_meter = self._customer_meter() + eng_meter = self._customer_meter() + eng_meter.provider_id = "provider-1" + + scalar_result = reconcile._config_of(scalar_meter) + eng_result = reconcile._config_of(eng_meter) + + # Same inputs but scalar=2.0 division only applies without a provider_id, + # so the engineering-units current limit is the larger (undivided) value. + assert eng_result["current_limit"] > scalar_result["current_limit"] + + def test_override_meter_state_forces_disable(self, session): + meter = self._customer_meter() + meter.ground = SimpleNamespace(private=SimpleNamespace(override_meter_state=True)) + # STATE_ON but overridden → the derived behavior is "disable". + assert reconcile._config_of(meter)["behavior"] == "disable" + + def test_config_import_failure_returns_none(self, session, monkeypatch): + import sys + + # Force the deferred config imports inside _config_of to fail so the + # ImportError guard returns None rather than deriving a config. + monkeypatch.setitem(sys.modules, "sparkmeter.config.configparameter", None) + assert reconcile._config_of(self._customer_meter()) is None + + +class TestReconcileProviderInit: + class _Client: + def __init__(self): + self.inits: list = [] + + async def init_driver(self, payload): + self.inits.append(payload) + + async def register_node(self, req): + raise AssertionError("no meters in this test") + + async def configure_meter(self, req): + raise AssertionError("no meters in this test") + + async def set_balance(self, node_id, req): + raise AssertionError("no meters in this test") + + async def unregister_node(self, node_id): + raise AssertionError("no meters in this test") + + @pytest.mark.asyncio + async def test_reconcile_calls_provider_init_by_default(self, monkeypatch): + payload = {"aes_key": "00" * 16, "channel": 26, "heartbeat_period_duration": 60} + monkeypatch.setattr(reconcile, "_load_driver_init_payload", lambda flask_app: payload) + monkeypatch.setattr(reconcile, "_load_meters", lambda flask_app: []) + client = self._Client() + await reconcile.reconcile_all(client, object()) + assert client.inits == [payload] + + @pytest.mark.asyncio + async def test_reconcile_can_skip_provider_init(self, monkeypatch): + payload = {"aes_key": "00" * 16, "channel": 26, "heartbeat_period_duration": 60} + monkeypatch.setattr(reconcile, "_load_driver_init_payload", lambda flask_app: payload) + monkeypatch.setattr(reconcile, "_load_meters", lambda flask_app: []) + client = self._Client() + await reconcile.reconcile_all(client, object(), skip_provider_init=True) + assert client.inits == [] diff --git a/sparkmeter/metering/tests/test_runtime_client.py b/sparkmeter/metering/tests/test_runtime_client.py new file mode 100644 index 0000000..631e8e7 --- /dev/null +++ b/sparkmeter/metering/tests/test_runtime_client.py @@ -0,0 +1,700 @@ +"""Tests for metering runtime transport helpers.""" + +from types import SimpleNamespace + +import pytest +from meter_driver_spec.http.models import ( + ConfigureElectricalMeterCompatRequest, + ElectricalMeterCommandName, + ElectricalMeterConfiguration, + RegisterNodeRequest, + SetBalanceAndFlagsRequest, +) + +from sparkmeter.metering import runtime_client + + +def test_grpc_target_falls_back_to_saved_target(): + provider = { + "base_url": "http://127.0.0.1:18080", + "selected_interface_target": "127.0.0.1:50051", + } + + assert runtime_client._grpc_target(provider, provider_details=None) == "127.0.0.1:50051" + + +def test_grpc_target_derives_default_from_base_url_host(): + provider = { + "base_url": "http://127.0.0.1:18080", + "selected_interface_target": "", + } + + assert runtime_client._grpc_target(provider, provider_details=None) == "127.0.0.1:50051" + + +@pytest.mark.asyncio +async def test_http_event_client_delegates_to_shared_sse_helper(monkeypatch): + captured = {} + + async def fake_stream_json_events(base_url, client_id): + captured["base_url"] = base_url + captured["client_id"] = client_id + yield {"type": "gateway_status"} + + monkeypatch.setattr(runtime_client, "stream_json_events", fake_stream_json_events) + + client = runtime_client.HttpEventClient("http://127.0.0.1:18080/", "saved-client") + events = [event async for event in client.stream_events("")] + + assert events == [{"type": "gateway_status"}] + assert captured == { + "base_url": "http://127.0.0.1:18080", + "client_id": "saved-client", + } + + +@pytest.mark.asyncio +async def test_http_event_client_close_is_noop(): + client = runtime_client.HttpEventClient("http://127.0.0.1:18080", "cid") + assert await client.close() is None + + +# --------------------------------------------------------------------------- +# Pure value helpers +# --------------------------------------------------------------------------- + + +def test_to_spec_decimal_defaults_invalid_to_zero(): + result = runtime_client.to_spec_decimal("not-a-number") + assert result.model_dump() == {"sign": 1, "coef": 0, "exp": 0} + + +def test_to_spec_decimal_negative_sets_sign(): + assert runtime_client.to_spec_decimal("-12.5").model_dump() == { + "sign": -1, + "coef": 125, + "exp": -1, + } + + +def test_aes_key_bytes_accepts_hex_bytes_and_iterables(): + assert runtime_client._aes_key_bytes("00ff10") == b"\x00\xff\x10" + assert runtime_client._aes_key_bytes(b"\x01\x02") == b"\x01\x02" + assert runtime_client._aes_key_bytes(bytearray(b"\x03")) == b"\x03" + assert runtime_client._aes_key_bytes([1, 2, 3]) == b"\x01\x02\x03" + + +def test_meter_state_name_maps_known_and_unknown(): + on_value = getattr(runtime_client.pb2.ElectricalMeterState, "ElectricalMeterStateOn", 1) + off_value = getattr(runtime_client.pb2.ElectricalMeterState, "ElectricalMeterStateOff", 0) + assert runtime_client._meter_state_name(on_value) == "on" + assert runtime_client._meter_state_name(off_value) == "off" + assert runtime_client._meter_state_name(9999) == "unknown" + + +def test_version_dict_defaults_missing_components(): + assert runtime_client._version_dict(SimpleNamespace(major=1, minor=2, patch=3)) == { + "major": 1, + "minor": 2, + "patch": 3, + } + assert runtime_client._version_dict(SimpleNamespace()) == {"major": 0, "minor": 0, "patch": 0} + + +def test_stats_dict_handles_none_and_values(): + assert runtime_client._stats_dict(None) is None + assert runtime_client._stats_dict( + {"count": 2, "last_value": 1.5, "max": 3.0, "min": 0.5, "avg": 1.75} + ) == {"count": 2, "last_value": 1.5, "max": 3.0, "min": 0.5, "avg": 1.75} + + +def test_phases_list_reads_phase_flags(): + message = SimpleNamespace(phases=SimpleNamespace(a=True, b=False, c=True)) + assert runtime_client._phases_list(message) == ["a", "c"] + + +def test_phases_list_includes_phase_b_when_set(): + # Exercise the middle phase-b branch, which the a/c-only case skips. + message = SimpleNamespace(phases=SimpleNamespace(a=False, b=True, c=False)) + assert runtime_client._phases_list(message) == ["b"] + + +_AGG_PHASE_FIELDS = ( + "apparent_power_avg", + "current_avg", + "current_max", + "current_min", + "frequency", + "power_factor_avg", + "true_power_avg", + "true_power_inst", + "voltage_avg", + "voltage_max", + "voltage_min", +) + + +# A distinct value per source field, so any src→dest field swap is detected. +_DISTINCT_PHASE_VALUES = { + "apparent_power_avg": 1.0, + "current_avg": 2.0, + "current_max": 3.0, + "current_min": 4.0, + "frequency": 5.0, + "power_factor_avg": 6.0, + "true_power_avg": 7.0, + "true_power_inst": 8.0, + "voltage_avg": 9.0, + "voltage_max": 10.0, + "voltage_min": 11.0, +} +# The mapping the source performs, keyed as output key -> source protobuf field. +_EXPECTED_PHASE_MAP = { + "apparent_power_avg_va": "apparent_power_avg", + "current_avg_amps": "current_avg", + "current_max_amps": "current_max", + "current_min_amps": "current_min", + "frequency_hz": "frequency", + "power_factor_avg": "power_factor_avg", + "true_power_avg_watts": "true_power_avg", + "true_power_inst_watts": "true_power_inst", + "voltage_avg": "voltage_avg", + "voltage_max": "voltage_max", + "voltage_min": "voltage_min", +} + + +def test_phase_reading_dict_maps_each_field_to_the_right_key(): + message = SimpleNamespace(**_DISTINCT_PHASE_VALUES) + result = runtime_client._phase_reading_dict(message) + # Every output key must carry the value of its specific source field — + # a swap (e.g. voltage_min reading voltage_max) would change the value. + expected = {out: _DISTINCT_PHASE_VALUES[src] for out, src in _EXPECTED_PHASE_MAP.items()} + assert result == pytest.approx(expected) + + +def test_phased_per_phase_dict_maps_each_field_and_skips_inactive_phases(): + # Phase a carries the distinct values; phase b's fields exist but are inactive. + fields = {"{}_a".format(name): value for name, value in _DISTINCT_PHASE_VALUES.items()} + fields.update({"{}_b".format(name): -1.0 for name in _DISTINCT_PHASE_VALUES}) + fields["phases"] = SimpleNamespace(a=True, b=False, c=False) + message = SimpleNamespace(**fields) + + per_phase = runtime_client._phased_per_phase_dict(message) + + # Only the active phase is emitted, and each key maps to its own source field. + assert set(per_phase) == {"a"} + expected = {out: _DISTINCT_PHASE_VALUES[src] for out, src in _EXPECTED_PHASE_MAP.items()} + assert per_phase["a"] == pytest.approx(expected) + + +def test_selected_interface_details_prefers_provider_details(): + details = {"selected_interface_details": {"type": "grpc", "target": "h:1"}} + assert runtime_client._selected_interface_details({}, details) == {"type": "grpc", "target": "h:1"} + assert runtime_client._selected_interface_details({}, None) == {} + + +# --------------------------------------------------------------------------- +# Command client base + HTTP transport +# --------------------------------------------------------------------------- + + +class _FakeHttpResponse: + def raise_for_status(self): + return None + + +class _FakeHttpxClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.calls: list = [] + + async def post(self, path, json=None): + self.calls.append(("POST", path, json)) + return _FakeHttpResponse() + + async def delete(self, path): + self.calls.append(("DELETE", path, None)) + return _FakeHttpResponse() + + async def aclose(self): + self.calls.append(("CLOSE", None, None)) + + +@pytest.mark.asyncio +async def test_base_command_client_methods_are_abstract(): + client = runtime_client.MeteringCommandClient() + with pytest.raises(NotImplementedError): + await client.init_driver({}) + with pytest.raises(NotImplementedError): + await client.register_node(RegisterNodeRequest(node_id=1, node_type="SM5R")) + with pytest.raises(NotImplementedError): + await client.configure_meter(_configure_request_model()) + with pytest.raises(NotImplementedError): + await client.set_balance(1, _balance_request_model()) + with pytest.raises(NotImplementedError): + await client.unregister_node(1) + with pytest.raises(NotImplementedError): + await client.close() + + +def _configure_request_model(): + return ConfigureElectricalMeterCompatRequest( + node_id=42, + command=ElectricalMeterCommandName.ELECTRICALMETERCOMMANDENABLE, + configuration=ElectricalMeterConfiguration( + power_limit=1500.0, + current_limit=10.0, + startup_delay=0, + throttle_on_time=5, + throttle_off_time=10, + throttle_count_limit=5, + ), + ) + + +def _balance_request_model(): + return SetBalanceAndFlagsRequest(balance=runtime_client.to_spec_decimal("12.5"), low_balance_flag=True) + + +class TestHttpCommandClient: + @pytest.mark.asyncio + async def test_all_endpoints_and_close(self, monkeypatch): + monkeypatch.setattr(runtime_client.httpx, "AsyncClient", _FakeHttpxClient) + client = runtime_client.HttpCommandClient("http://driver:18080/", "cid") + + await client.init_driver({"heartbeat_period_duration": 60}) + await client.register_node(RegisterNodeRequest(node_id=7, node_type="SM5R")) + await client.configure_meter(_configure_request_model()) + await client.set_balance(7, _balance_request_model()) + await client.unregister_node(7) + await client.close() + + calls = client._client.calls + methods_and_paths = [(method, path) for method, path, _ in calls] + assert methods_and_paths == [ + ("POST", "/v1/sparknet/init"), + ("POST", "/v1/nodes/register"), + ("POST", "/v1/meters/configure"), + ("POST", "/v1/nodes/7/balance-and-flags"), + ("DELETE", "/v1/nodes/7"), + ("CLOSE", None), + ] + + # The serialized request bodies, not just routing, must be correct. + assert calls[0][2] == {"heartbeat_period_duration": 60} + register_body = calls[1][2] + assert register_body["node_id"] == 7 + assert register_body["node_type"] == "SM5R" + assert "mac" not in register_body # exclude_none drops the unset mac + assert calls[2][2]["node_id"] == 42 # configure body carries node_id + assert calls[3][2]["low_balance_flag"] is True # balance body + + +# --------------------------------------------------------------------------- +# gRPC transport construction / event client stream +# --------------------------------------------------------------------------- + + +class _FakeChannel: + def __init__(self): + self.closed = False + + async def close(self): + self.closed = True + + +class _RecordingStub: + def __init__(self, channel): + self.channel = channel + self.calls: dict = {} + + def _record(self, name, request): + self.calls.setdefault(name, []).append(request) + + async def InitDriver(self, request): + self._record("InitDriver", request) + + async def RegisterNode(self, request): + self._record("RegisterNode", request) + + async def ConfigureElectricalMeter(self, request): + self._record("ConfigureElectricalMeter", request) + + async def SetElectricalMeterBalanceAndFlags(self, request): + self._record("SetElectricalMeterBalanceAndFlags", request) + + async def UnregisterNode(self, request): + self._record("UnregisterNode", request) + + +def _patch_grpc(monkeypatch, stub_factory): + channel = _FakeChannel() + monkeypatch.setattr(runtime_client.grpc.aio, "insecure_channel", lambda target: channel) + monkeypatch.setattr(runtime_client.pb2_grpc, "MeterDriverControlStub", stub_factory) + return channel + + +class TestGrpcCommandClient: + def _client_with_stub(self, monkeypatch): + holder = {} + + def factory(channel): + holder["stub"] = _RecordingStub(channel) + return holder["stub"] + + channel = _patch_grpc(monkeypatch, factory) + client = runtime_client.GrpcCommandClient("host:50051") + return client, holder, channel + + @pytest.mark.asyncio + async def test_init_driver_marshals_payload(self, monkeypatch): + client, holder, channel = self._client_with_stub(monkeypatch) + + await client.init_driver({"heartbeat_period_duration": 60, "aes_key": "00" * 16, "channel": 26}) + + request = holder["stub"].calls["InitDriver"][0] + assert request.heartbeat_period_duration == 60 + assert request.aes_key == bytes.fromhex("00" * 16) + assert request.channel.value == 26 + + await client.close() + assert channel.closed is True + + @pytest.mark.asyncio + async def test_init_driver_omits_channel_when_absent(self, monkeypatch): + client, holder, _ = self._client_with_stub(monkeypatch) + + await client.init_driver({"heartbeat_period_duration": 60, "aes_key": "00" * 16}) + + request = holder["stub"].calls["InitDriver"][0] + assert request.HasField("channel") is False + + @pytest.mark.asyncio + async def test_command_methods_delegate_to_stub(self, monkeypatch): + client, holder, _ = self._client_with_stub(monkeypatch) + + await client.register_node(RegisterNodeRequest(node_id=100, node_type="SM5R")) + await client.configure_meter(_configure_request_model()) + await client.set_balance(7, _balance_request_model()) + await client.unregister_node(55) + + stub = holder["stub"] + assert stub.calls["RegisterNode"][0].node_id == 100 + assert stub.calls["ConfigureElectricalMeter"][0].node_id == 42 + assert stub.calls["SetElectricalMeterBalanceAndFlags"][0].node_id == 7 + assert stub.calls["UnregisterNode"][0].node_id == 55 + + +class TestGrpcEventClientStream: + @pytest.mark.asyncio + async def test_stream_translates_and_skips_none(self, monkeypatch): + class _StreamStub: + def __init__(self, channel): + self.channel = channel + + def SubscribeEvents(self, request): + async def _gen(): + yield SimpleNamespace(tag="a") + yield SimpleNamespace(tag="skip") + yield SimpleNamespace(tag="b") + + return _gen() + + _patch_grpc(monkeypatch, _StreamStub) + + def fake_translate(event, event_id): + if event.tag == "skip": + return None + return {"tag": event.tag, "event_id": event_id} + + monkeypatch.setattr(runtime_client, "_grpc_event_to_raw_dict", fake_translate) + + client = runtime_client.GrpcEventClient("host:50051") + events = [event async for event in client.stream_events("ignored")] + + # The "skip" event (translated to None) is dropped; ids increment per event. + assert events == [{"tag": "a", "event_id": 1}, {"tag": "b", "event_id": 3}] + + @pytest.mark.asyncio + async def test_close_closes_channel(self, monkeypatch): + channel = _patch_grpc(monkeypatch, _RecordingStub) + client = runtime_client.GrpcEventClient("host:50051") + await client.close() + assert channel.closed is True + + +# --------------------------------------------------------------------------- +# Transport selection + provider initialization +# --------------------------------------------------------------------------- + + +class TestBuildClients: + def test_build_command_client_uses_grpc_when_target_available(self, monkeypatch): + _patch_grpc(monkeypatch, _RecordingStub) + provider = { + "selected_interface": "grpc", + "selected_interface_target": "host:50051", + "base_url": "http://driver:18080", + } + client = runtime_client.build_command_client(provider, "cid") + assert isinstance(client, runtime_client.GrpcCommandClient) + + def test_build_command_client_falls_back_to_http(self, monkeypatch): + # gRPC selected but no target resolvable -> HTTP fallback. + provider = {"selected_interface": "grpc", "base_url": ""} + client = runtime_client.build_command_client(provider, "cid") + assert isinstance(client, runtime_client.HttpCommandClient) + + def test_build_event_client_uses_grpc_when_target_available(self, monkeypatch): + _patch_grpc(monkeypatch, _RecordingStub) + provider = { + "selected_interface": "grpc", + "selected_interface_target": "host:50051", + "base_url": "http://driver:18080", + } + client = runtime_client.build_event_client(provider, "cid") + assert isinstance(client, runtime_client.GrpcEventClient) + + def test_build_event_client_falls_back_to_http(self): + provider = {"selected_interface": "grpc", "base_url": ""} + client = runtime_client.build_event_client(provider, "cid") + assert isinstance(client, runtime_client.HttpEventClient) + + +class _RecordingCommandClient: + def __init__(self): + self.inited: list = [] + self.closed = False + + async def init_driver(self, payload): + self.inited.append(payload) + + async def close(self): + self.closed = True + + +class TestInitializeProvider: + @pytest.mark.asyncio + async def test_initialize_provider_inits_then_closes(self, monkeypatch): + client = _RecordingCommandClient() + monkeypatch.setattr(runtime_client, "build_command_client", lambda *a, **k: client) + await runtime_client.initialize_provider({"base_url": "http://x"}, {"aes_key": "00"}) + assert client.inited == [{"aes_key": "00"}] + assert client.closed is True + + @pytest.mark.asyncio + async def test_initialize_provider_closes_even_on_error(self, monkeypatch): + class _BoomClient(_RecordingCommandClient): + async def init_driver(self, payload): + raise RuntimeError("init failed") + + client = _BoomClient() + monkeypatch.setattr(runtime_client, "build_command_client", lambda *a, **k: client) + with pytest.raises(RuntimeError): + await runtime_client.initialize_provider({"base_url": "http://x"}, {}) + assert client.closed is True + + def test_initialize_provider_sync_runs_event_loop(self, monkeypatch): + client = _RecordingCommandClient() + monkeypatch.setattr(runtime_client, "build_command_client", lambda *a, **k: client) + runtime_client.initialize_provider_sync({"base_url": "http://x"}, {"aes_key": "00"}) + assert client.inited == [{"aes_key": "00"}] + assert client.closed is True + + +# --------------------------------------------------------------------------- +# Protobuf request builders +# --------------------------------------------------------------------------- + + +class TestProtoRequestBuilders: + def test_register_request_sets_optional_fields(self): + req = RegisterNodeRequest(node_id=100, node_type="SM5R", mac=0xABCD, request_phased_readings=True) + proto = runtime_client._register_request(req) + assert proto.node_id == 100 + assert proto.mac.value == 0xABCD + assert proto.request_phased_readings.value is True + + def test_register_request_without_optional_fields(self): + req = RegisterNodeRequest(node_id=100, node_type="SM5R") + proto = runtime_client._register_request(req) + assert proto.node_id == 100 + # mac left unset when not provided. + assert proto.mac.value == 0 + + def test_configure_request_maps_command_and_configuration(self): + proto = runtime_client._configure_request(_configure_request_model()) + assert proto.node_id == 42 + assert proto.configuration.power_limit == pytest.approx(1500.0) + assert proto.configuration.throttle_count_limit == 5 + + def test_set_balance_request_builds_decimal(self): + proto = runtime_client._set_balance_request(7, _balance_request_model()) + assert proto.node_id == 7 + assert proto.low_balance_flag is True + assert proto.balance.coef == 125 + assert proto.balance.exp == -1 + + +# --------------------------------------------------------------------------- +# gRPC event translation +# --------------------------------------------------------------------------- + + +def _event(name, message): + return SimpleNamespace(WhichOneof=lambda field: name, **{name: message}) + + +def _reading_message(): + on_value = getattr(runtime_client.pb2.ElectricalMeterState, "ElectricalMeterStateOn", 1) + return SimpleNamespace( + node_id=100, + period_start=1700000000, + period_end=1700000900, + state=on_value, + frequency=50.0, + current_avg=5.0, + current_min=1.0, + current_max=10.0, + voltage_avg=230.0, + voltage_min=220.0, + voltage_max=235.0, + true_power_avg=1000.0, + true_power_inst=1100.0, + apparent_power_avg=1200.0, + power_factor_avg=0.95, + energy=1234.5, + uptime_secs=12345, + user_power_limit=1500.0, + ) + + +def _phased_message(): + on_value = getattr(runtime_client.pb2.ElectricalMeterState, "ElectricalMeterStateOn", 1) + fields = {name: 1.0 for name in _AGG_PHASE_FIELDS} + fields.update({"{}_a".format(name): 2.0 for name in _AGG_PHASE_FIELDS}) + fields.update( + node_id=100, + period_start=1, + period_end=2, + state=on_value, + energy=10.0, + uptime_secs=5, + user_power_limit=1500.0, + computed_fields_version=3, + phases=SimpleNamespace(a=True, b=False, c=False), + ) + return SimpleNamespace(**fields) + + +class TestGrpcEventToRawDict: + def test_no_oneof_returns_none(self): + event = SimpleNamespace(WhichOneof=lambda field: None) + assert runtime_client._grpc_event_to_raw_dict(event, 1) is None + + def test_unsupported_event_returns_none(self, monkeypatch): + monkeypatch.setattr(runtime_client, "MessageToDict", lambda message, **kwargs: {}) + event = _event("some_future_event", SimpleNamespace()) + assert runtime_client._grpc_event_to_raw_dict(event, 1) is None + + def test_reading_event_translated(self, monkeypatch): + monkeypatch.setattr(runtime_client, "MessageToDict", lambda message, **kwargs: {}) + raw = runtime_client._grpc_event_to_raw_dict( + _event("electrical_meter_reading", _reading_message()), 5 + ) + assert raw["type"] == "electrical_meter_reading" + assert raw["event_id"] == 5 + assert raw["event_type"] == "meter_reading" + assert raw["meter_id"] == "100" + assert raw["period_start"] == 1700000000 + assert raw["period_end"] == 1700000900 + # The state id is translated to its name. + assert raw["state"] == "on" + # Every renamed top-level electrical field carries its own source value, + # so a field swap in the translation would change one of these. + assert raw["frequency_hz"] == pytest.approx(50.0) + assert raw["current_avg_amps"] == pytest.approx(5.0) + assert raw["current_min_amps"] == pytest.approx(1.0) + assert raw["current_max_amps"] == pytest.approx(10.0) + assert raw["voltage_avg"] == pytest.approx(230.0) + assert raw["voltage_min"] == pytest.approx(220.0) + assert raw["voltage_max"] == pytest.approx(235.0) + assert raw["true_power_avg_watts"] == pytest.approx(1000.0) + assert raw["true_power_inst_watts"] == pytest.approx(1100.0) + assert raw["apparent_power_avg_va"] == pytest.approx(1200.0) + assert raw["power_factor_avg"] == pytest.approx(0.95) + assert raw["energy_wh"] == pytest.approx(1234.5) + assert raw["uptime_seconds"] == 12345 + assert raw["user_power_limit_watts"] == pytest.approx(1500.0) + # The nested spec "data" block preserves the raw spec field names and + # values in full; a swap inside it (e.g. current_min/current_max) would + # change one of these, and the exact-dict compare also catches drift. + on_value = getattr(runtime_client.pb2.ElectricalMeterState, "ElectricalMeterStateOn", 1) + assert raw["data"] == pytest.approx( + { + "node_id": 100, + "period_start": 1700000000, + "period_end": 1700000900, + "state": on_value, + "frequency": 50.0, + "current_avg": 5.0, + "current_min": 1.0, + "current_max": 10.0, + "voltage_avg": 230.0, + "voltage_min": 220.0, + "voltage_max": 235.0, + "true_power_avg": 1000.0, + "true_power_inst": 1100.0, + "apparent_power_avg": 1200.0, + "power_factor_avg": 0.95, + "energy": 1234.5, + "uptime_secs": 12345, + "user_power_limit": 1500.0, + } + ) + + def test_phased_reading_event_translated(self, monkeypatch): + monkeypatch.setattr(runtime_client, "MessageToDict", lambda message, **kwargs: {}) + raw = runtime_client._grpc_event_to_raw_dict( + _event("electrical_meter_reading_phased", _phased_message()), 6 + ) + assert raw["type"] == "electrical_meter_reading_phased" + assert raw["phases"] == ["a"] + assert raw["per_phase"]["a"]["current_avg_amps"] == pytest.approx(2.0) + + def test_heartbeat_event_translated(self, monkeypatch): + stats = {"count": 1, "last_value": 1.0, "max": 1.0, "min": 1.0, "avg": 1.0} + monkeypatch.setattr( + runtime_client, + "MessageToDict", + lambda message, **kwargs: { + "millisecond_read_reply_stats": stats, + "millisecond_set_config_reply_stats": stats, + }, + ) + message = SimpleNamespace( + timestamp=1700000000, + total_registered_nodes=20, + nodes_reached_out_to_in_current_heartbeat=20, + nodes_heard_from_in_current_heartbeat=18, + packets_sent_in_current_heartbeat=40, + packets_received_in_current_heartbeat=36, + ) + raw = runtime_client._grpc_event_to_raw_dict(_event("heartbeat_statistics", message), 7) + assert raw["type"] == "heartbeat_statistics" + assert raw["total_registered_meters"] == 20 + assert raw["meters_responded"] == 18 + assert raw["read_reply_latency_ms"]["count"] == 1 + + def test_firmware_change_event_translated(self, monkeypatch): + monkeypatch.setattr(runtime_client, "MessageToDict", lambda message, **kwargs: {}) + message = SimpleNamespace(node_id=100, firmware_version=SimpleNamespace(major=1, minor=2, patch=3)) + raw = runtime_client._grpc_event_to_raw_dict(_event("node_firmware_version_changed", message), 8) + assert raw["type"] == "node_firmware_version_changed" + assert raw["firmware_version"] == {"major": 1, "minor": 2, "patch": 3} + + def test_side_channel_event_passes_through_message_dict(self, monkeypatch): + monkeypatch.setattr(runtime_client, "MessageToDict", lambda message, **kwargs: {"connected": True}) + raw = runtime_client._grpc_event_to_raw_dict(_event("gateway_status", SimpleNamespace()), 9) + assert raw == {"type": "gateway_status", "event_id": 9, "data": {"connected": True}} diff --git a/sparkmeter/metering/tools/__init__.py b/sparkmeter/metering/tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/sparkmeter/metering/tools/cli_client.py b/sparkmeter/metering/tools/cli_client.py deleted file mode 100644 index 9c3dac8..0000000 --- a/sparkmeter/metering/tools/cli_client.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -CLI helpers for one-off metering-provider commands. - -Used by Flask CLI commands (e.g. `flask meter ping`) that fan out a -single command across some set of meters and print whatever the -provider returns. - -Each helper: -1. opens an `APIClient` against the metering provider URL -2. submits the command with a fresh correlation id -3. tails the SSE stream long enough to surface the matching reply -4. prints a one-line summary per meter - -Failure cases (provider down, `command_failed` reason="unsupported", -`command_timed_out`) print to stderr and contribute to the exit code. -""" - -import asyncio -import json -import logging -import os -import sys -import uuid -from collections.abc import Awaitable, Callable - -import click - -from sparkmeter.metering._generated import APIClient, ClientConfig, HttpxTransport -from sparkmeter.metering._generated.models.ping_meter_command import PingMeterCommand -from sparkmeter.metering._generated.models.ping_meter_params import PingMeterParams -from sparkmeter.metering._generated.models.query_meter_neighbors_command import QueryMeterNeighborsCommand -from sparkmeter.metering._generated.models.query_meter_neighbors_params import QueryMeterNeighborsParams -from sparkmeter.metering._generated.models.submit_command_v_1_commands_post_request_body_command_type_enum import ( - SubmitCommandV1CommandsPostRequestBodyCommandTypeEnum as CommandTypeEnum, -) - -logger = logging.getLogger(__name__) - - -PROVIDER_URL_DEFAULT = os.environ.get("METERING_PROVIDER_URL", "http://localhost:8000") -PER_METER_TIMEOUT_SECONDS = 10.0 - - -def _make_client() -> APIClient: - """Open a short-lived client against the configured provider URL.""" - client_id = "cli-" + uuid.uuid4().hex[:8] - transport = HttpxTransport( - base_url=PROVIDER_URL_DEFAULT, - timeout=30.0, - default_headers={"X-Client-Id": client_id}, - ) - return APIClient(ClientConfig(base_url=PROVIDER_URL_DEFAULT), transport=transport) - - -async def submit_ping(client: APIClient, meter_id: str, correlation_id: str) -> None: - body = PingMeterCommand( - command_type=CommandTypeEnum.PING_METER, - correlation_id=correlation_id, - params=PingMeterParams(meter_id=meter_id), - ) - await client.default.submit_command_v1_commands_post(body) - - -async def submit_query_neighbors(client: APIClient, meter_id: str, correlation_id: str) -> None: - body = QueryMeterNeighborsCommand( - command_type=CommandTypeEnum.QUERY_METER_NEIGHBORS, - correlation_id=correlation_id, - params=QueryMeterNeighborsParams(meter_id=meter_id), - ) - await client.default.submit_command_v1_commands_post(body) - - -CommandSubmitter = Callable[[APIClient, str, str], Awaitable[None]] - - -async def run_per_meter_command(submitter: CommandSubmitter, meter_ids: list[str]) -> None: - """Submit `submitter` for each meter, then tail SSE for replies. - - Per-meter outcome is printed on a single line. Exit code is 0 on - success, 1 if any meter's reply is `command_failed` or - `command_timed_out`, 2 if the provider is unreachable. - """ - if not meter_ids: - click.echo("no meters to query", err=True) - return - - correlation_to_meter: dict[str, str] = {f"cli-{uuid.uuid4().hex[:12]}": mid for mid in meter_ids} - pending = set(correlation_to_meter) - - client = _make_client() - try: - async with client: - try: - for correlation_id, meter_id in correlation_to_meter.items(): - await submitter(client, meter_id, correlation_id) - except Exception as exc: # noqa: BLE001 - click.echo(f"failed to submit to provider: {exc!r}", err=True) - sys.exit(2) - - await _tail_until_resolved(client, correlation_to_meter, pending) - finally: - # Already inside `async with client` — but ensure close in case - # of exception paths; APIClient.close is idempotent. - await client.close() - - if pending: - sys.exit(1) - - -async def _tail_until_resolved( - client: APIClient, - correlation_to_meter: dict[str, str], - pending: set[str], -) -> None: - deadline = asyncio.get_event_loop().time() + PER_METER_TIMEOUT_SECONDS - - async def _consume() -> None: - async for raw_event in client.default.stream_events_v1_events_get(): - corr = raw_event.get("correlation_id") - if corr not in correlation_to_meter or corr not in pending: - continue - meter_id = correlation_to_meter[corr] - event_type = raw_event.get("event_type", "?") - if event_type in { - "command_applied", - "command_failed", - "command_timed_out", - "command_rejected", - "meter_neighbors", - "meter_config", - "meter_version", - "meter_instant_reading", - "meter_errors", - "rf_test_result", - }: - pending.discard(corr) - _print_summary(meter_id, event_type, raw_event) - if not pending: - return - - try: - await asyncio.wait_for(_consume(), timeout=PER_METER_TIMEOUT_SECONDS) - except asyncio.TimeoutError: - for corr in list(pending): - meter_id = correlation_to_meter[corr] - click.echo(f"{meter_id}: timed out waiting for reply", err=True) - if asyncio.get_event_loop().time() >= deadline: - return - - -def _print_summary(meter_id: str, event_type: str, raw: dict) -> None: - """Print a single-line summary of an event for a meter.""" - if event_type == "command_failed": - reason = raw.get("reason", "?") - click.echo(f"{meter_id}: failed ({reason})", err=True) - elif event_type == "command_rejected": - reason = raw.get("reason", "?") - click.echo(f"{meter_id}: rejected ({reason})", err=True) - elif event_type == "command_timed_out": - click.echo(f"{meter_id}: timed out", err=True) - elif event_type == "command_applied": - result = raw.get("result") or {} - click.echo(f"{meter_id}: applied {json.dumps(result, sort_keys=True)}") - else: - # Typed query reply (meter_neighbors, meter_config, etc.). - click.echo(f"{meter_id}: {event_type} {json.dumps(raw, sort_keys=True)}") diff --git a/sparkmeter/tests/test_asgi.py b/sparkmeter/tests/test_asgi.py new file mode 100644 index 0000000..ca972ac --- /dev/null +++ b/sparkmeter/tests/test_asgi.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""Tests for the ASGI entrypoint composition helpers.""" + +import asyncio +import contextlib +from types import SimpleNamespace + +import pytest + +from sparkmeter import asgi + + +def test_create_internal_app_shares_metering_state_via_middleware(): + from fastapi.testclient import TestClient + + public = SimpleNamespace(state=SimpleNamespace(metering="client-A")) + internal = asgi.create_internal_app(public) + + assert internal.title == "sparkmeter-internal" + # The factory publishes the app on the module so ASGI servers can reach it. + assert asgi.internal_app is internal + + client = TestClient(internal) + # The middleware runs on every request (a 404 still exercises it) and copies + # the public app's live metering client onto the internal app's state. + client.get("/anything") + assert internal.state.metering == "client-A" + + # It re-reads per request, so a change on the public app propagates through. + public.state.metering = "client-B" + client.get("/anything") + assert internal.state.metering == "client-B" + + +@pytest.mark.asyncio +async def test_app_lifespan_composes_inner_lifespans(monkeypatch): + entered = [] + + @contextlib.asynccontextmanager + async def fake_periodic(app): + entered.append("periodic") + yield + + @contextlib.asynccontextmanager + async def fake_metering(app): + entered.append("metering") + yield + + monkeypatch.setattr(asgi, "periodic_lifespan", fake_periodic) + monkeypatch.setattr(asgi, "metering_lifespan", fake_metering) + + app = SimpleNamespace(state=SimpleNamespace()) + async with asgi.app_lifespan(app): + # The running loop is stashed for in-process metering activation. + assert app.state.main_loop is asyncio.get_running_loop() + + # periodic wraps metering on the outside. + assert entered == ["periodic", "metering"] diff --git a/test-data/api/test_configviews0-TestConfigParameterList.test_get.page b/test-data/api/test_configviews0-TestConfigParameterList.test_get.page index 5b5dd77..463f70c 100644 --- a/test-data/api/test_configviews0-TestConfigParameterList.test_get.page +++ b/test-data/api/test_configviews0-TestConfigParameterList.test_get.page @@ -29,6 +29,13 @@ "value": 0.0, "value_type": "percent" }, + "metering-providers": { + "label": "Meter drivers", + "last_modified": "%% FILTERED BY UNITTEST %%", + "tooltip": "JSON-encoded list of configured meter drivers.", + "value": "[]", + "value_type": "string" + }, "nominal-voltage": { "label": "Nominal voltage of system", "last_modified": "%% FILTERED BY UNITTEST %%", diff --git a/test-data/config/test_configviews-ConfigTest.test_billing.page b/test-data/config/test_configviews-ConfigTest.test_billing.page index d8c71cf..8748433 100644 --- a/test-data/config/test_configviews-ConfigTest.test_billing.page +++ b/test-data/config/test_configviews-ConfigTest.test_billing.page @@ -88,6 +88,11 @@ Meters +
  • + + Meter Drivers + +
  • diff --git a/test-data/config/test_configviews-ConfigTest.test_meter_driver.page b/test-data/config/test_configviews-ConfigTest.test_meter_driver.page new file mode 100644 index 0000000..e2ecb00 --- /dev/null +++ b/test-data/config/test_configviews-ConfigTest.test_meter_driver.page @@ -0,0 +1,245 @@ +> GET /config/meter-driver HTTP/1.1 +> Cookie: session=%% SESSION %% +> Host: localhost +> User-Agent: Unittest/1.0 +> + +< HTTP/1.1 200 OK +< Content-Type: text/html; charset=utf-8 +< Set-Cookie: %% COOKIE %% +< Set-Cookie: %% COOKIE %% +< Vary: Cookie +< + + + + + + + + + + + + + + + SparkMeter ThunderCloud + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +

    + Meter drivers

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + Meter drivers +
    +
    +
    +

    Register a standalone meter driver such as SparkNet-Http by entering the base URL of its HTTP service.

    +

    + + Register driver + +

    + + + + + + + + + + + + + + + + + + + + + +
    InitDriver nameBase URLConfig JSONVersionAvailable interfacesSelected interfaceEnabledStatusGatewayActions
    No meter drivers registered yet.
    +
    + +
    +
    +
    +
    +
    +
    + +
    + + diff --git a/test-data/config/test_configviews-ConfigTest.test_meters.page b/test-data/config/test_configviews-ConfigTest.test_meters.page index de6d1a2..24a0a04 100644 --- a/test-data/config/test_configviews-ConfigTest.test_meters.page +++ b/test-data/config/test_configviews-ConfigTest.test_meters.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/config/test_configviews-ConfigTest.test_sms.page b/test-data/config/test_configviews-ConfigTest.test_sms.page index 399b7f6..30cb01e 100644 --- a/test-data/config/test_configviews-ConfigTest.test_sms.page +++ b/test-data/config/test_configviews-ConfigTest.test_sms.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/dashboard/test_dashboardview-DashboardViewTest.test_index.page b/test-data/dashboard/test_dashboardview-DashboardViewTest.test_index.page index 72dbe54..3243695 100644 --- a/test-data/dashboard/test_dashboardview-DashboardViewTest.test_index.page +++ b/test-data/dashboard/test_dashboardview-DashboardViewTest.test_index.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/database/test_migration-MigrationTest.test_tables-config_parameter.json b/test-data/database/test_migration-MigrationTest.test_tables-config_parameter.json index a6f4010..87521c2 100644 --- a/test-data/database/test_migration-MigrationTest.test_tables-config_parameter.json +++ b/test-data/database/test_migration-MigrationTest.test_tables-config_parameter.json @@ -8,6 +8,15 @@ "value": "0.0", "value_type": "percent" }, + { + "ground_id": null, + "id": "%% FILTERED BY UNITTEST %%", + "last_modified": "2010-01-01T00:00:00", + "name": "metering-providers", + "updated_by_id": null, + "value": "[]", + "value_type": "string" + }, { "ground_id": null, "id": "%% FILTERED BY UNITTEST %%", diff --git a/test-data/database/test_migration-MigrationTest.test_tables-meter.json b/test-data/database/test_migration-MigrationTest.test_tables-meter.json index 7b369f8..0533807 100644 --- a/test-data/database/test_migration-MigrationTest.test_tables-meter.json +++ b/test-data/database/test_migration-MigrationTest.test_tables-meter.json @@ -6,6 +6,7 @@ "id": "04131900-b9e4-11e4-9c5c-00617b7c6dac", "meter_type": "customer", "model_id": "ad4e2b68-2eea-b3b7-c439-f8456b3f334f", + "provider_id": null, "serial": "SM15R-01-0000008C" }, { @@ -15,6 +16,7 @@ "id": "162d0000-b3c3-11e4-aa48-00617b7c716d", "meter_type": "customer", "model_id": "ad4e2b68-2eea-b3b7-c439-f8456b3f334f", + "provider_id": null, "serial": "SM15R-01-0000003D" }, { @@ -24,6 +26,7 @@ "id": "162d0000-b3c3-11e4-aa49-00617b7c716d", "meter_type": "customer", "model_id": "ad4e2b68-2eea-b3b7-c439-f8456b3f334f", + "provider_id": null, "serial": "SM15R-01-0000005B" } ] diff --git a/test-data/database/test_migration-MigrationTest.test_tables-meter.sql b/test-data/database/test_migration-MigrationTest.test_tables-meter.sql index 6cad421..e987fdb 100644 --- a/test-data/database/test_migration-MigrationTest.test_tables-meter.sql +++ b/test-data/database/test_migration-MigrationTest.test_tables-meter.sql @@ -4,6 +4,7 @@ CREATE TABLE meter ( meter_type VARCHAR NOT NULL, address_id CHAR(32) NOT NULL, ground_id CHAR(32) NOT NULL, + provider_id VARCHAR, model_id CHAR(32), id CHAR(32) DEFAULT uuid_generate_v4() NOT NULL, PRIMARY KEY (id), diff --git a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_delete_collection-not-implemented-delete-collection.page b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_delete_collection-not-implemented-delete-collection.page index bb8c16b..8c5895c 100644 --- a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_delete_collection-not-implemented-delete-collection.page +++ b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_delete_collection-not-implemented-delete-collection.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_delete_item-not-implemented-delete-item.page b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_delete_item-not-implemented-delete-item.page index 0d60bf9..02bb036 100644 --- a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_delete_item-not-implemented-delete-item.page +++ b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_delete_item-not-implemented-delete-item.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_collection-not-implemented-get-collection.page b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_collection-not-implemented-get-collection.page index 24543e4..7a67214 100644 --- a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_collection-not-implemented-get-collection.page +++ b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_collection-not-implemented-get-collection.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_item-not-implemented-get-item.page b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_item-not-implemented-get-item.page index 3bec95f..0125387 100644 --- a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_item-not-implemented-get-item.page +++ b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_item-not-implemented-get-item.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_item_not_found-not-implemented-get-item-not-found.page b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_item_not_found-not-implemented-get-item-not-found.page index 3bec95f..0125387 100644 --- a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_item_not_found-not-implemented-get-item-not-found.page +++ b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_get_item_not_found-not-implemented-get-item-not-found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_post_collection-not-implemented-post-collection.page b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_post_collection-not-implemented-post-collection.page index b656790..6c64006 100644 --- a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_post_collection-not-implemented-post-collection.page +++ b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_post_collection-not-implemented-post-collection.page @@ -91,6 +91,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_post_item_not_found-not-implemented-post-item-not-found.page b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_post_item_not_found-not-implemented-post-item-not-found.page index 691a46d..370dc8d 100644 --- a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_post_item_not_found-not-implemented-post-item-not-found.page +++ b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_post_item_not_found-not-implemented-post-item-not-found.page @@ -91,6 +91,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_put_collection-not-implemented-put-collection.page b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_put_collection-not-implemented-put-collection.page index a14d7f2..dd797f6 100644 --- a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_put_collection-not-implemented-put-collection.page +++ b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_put_collection-not-implemented-put-collection.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_put_item-not-implemented-put-item.page b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_put_item-not-implemented-put-item.page index 6a104a1..484da44 100644 --- a/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_put_item-not-implemented-put-item.page +++ b/test-data/event/test_alertviews-NotImplementedCrudViewTest.test_put_item-not-implemented-put-item.page @@ -91,6 +91,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/event/test_eventviews-EventTest.test_messages.page b/test-data/event/test_eventviews-EventTest.test_messages.page index 8924cfd..732465f 100644 --- a/test-data/event/test_eventviews-EventTest.test_messages.page +++ b/test-data/event/test_eventviews-EventTest.test_messages.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_edit-post.page b/test-data/ground/test_groundviews-GroundViewTest.test_edit-post.page index 6c9ade3..8d05ccc 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_edit-post.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_edit-post.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_edit.page b/test-data/ground/test_groundviews-GroundViewTest.test_edit.page index 354b368..870794e 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_edit.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_edit.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_blank.page b/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_blank.page index 5bd5148..03a2fab 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_blank.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_blank.page @@ -92,6 +92,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_negative.page b/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_negative.page index d8afd1c..58918ab 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_negative.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_negative.page @@ -92,6 +92,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_over_max_int.page b/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_over_max_int.page index 602292f..9f2cd83 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_over_max_int.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_edit_max_capacity_over_max_int.page @@ -92,6 +92,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_edit_not_found.page b/test-data/ground/test_groundviews-GroundViewTest.test_edit_not_found.page index 03109d8..a084281 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_edit_not_found.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_edit_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_none.page b/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_none.page index 142292b..d2f57c9 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_none.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_none.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_one.page b/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_one.page index 884d99d..e6c50ee 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_one.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_one.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_two.page b/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_two.page index 884d99d..e6c50ee 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_two.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_index_cloud_two.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_index_ground.page b/test-data/ground/test_groundviews-GroundViewTest.test_index_ground.page index 98e4898..eec3098 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_index_ground.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_index_ground.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_manual_override_not_found.page b/test-data/ground/test_groundviews-GroundViewTest.test_manual_override_not_found.page index 51e9347..907a6c0 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_manual_override_not_found.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_manual_override_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_override.page b/test-data/ground/test_groundviews-GroundViewTest.test_override.page index dd02ee5..342ccce 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_override.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_override.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_override_cloud_not_found.page b/test-data/ground/test_groundviews-GroundViewTest.test_override_cloud_not_found.page index 6fe3b2c..9bdc15b 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_override_cloud_not_found.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_override_cloud_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_override_not_found.page b/test-data/ground/test_groundviews-GroundViewTest.test_override_not_found.page index 4d46111..20ede70 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_override_not_found.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_override_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_override_without_serial.page b/test-data/ground/test_groundviews-GroundViewTest.test_override_without_serial.page index 0b75a30..aa837db 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_override_without_serial.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_override_without_serial.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/ground/test_groundviews-GroundViewTest.test_status_not_found.page b/test-data/ground/test_groundviews-GroundViewTest.test_status_not_found.page index 8c2998c..af9856d 100644 --- a/test-data/ground/test_groundviews-GroundViewTest.test_status_not_found.page +++ b/test-data/ground/test_groundviews-GroundViewTest.test_status_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_absent.page b/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_absent.page index 73c8d4a..e883f1b 100644 --- a/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_absent.page +++ b/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_absent.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_absent_ground.page b/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_absent_ground.page index eb0b7c4..addeb41 100644 --- a/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_absent_ground.page +++ b/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_absent_ground.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_present.page b/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_present.page index 20e1cb7..bb41e42 100644 --- a/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_present.page +++ b/test-data/homepage/test_homepage-HomepageViewTest.test_cloud_portal_link_present.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterdomain-MeterViewTest.test_insert_customer_meter.json b/test-data/meter/test_meterdomain-MeterViewTest.test_insert_customer_meter.json index c1a58a2..7906be7 100644 --- a/test-data/meter/test_meterdomain-MeterViewTest.test_insert_customer_meter.json +++ b/test-data/meter/test_meterdomain-MeterViewTest.test_insert_customer_meter.json @@ -31,6 +31,7 @@ "model_id": "00000001-0000-0000-0000-000000000001", "model_name": "SMXR", "plan_value": 789.0, + "provider_id": null, "serial": "SM15R-01-10000002", "sparkmac_flooding_subnets": 255, "sparkmac_forwarding": "flooding", diff --git a/test-data/meter/test_meterdomain-MeterViewTest.test_insert_totalizer_meter.json b/test-data/meter/test_meterdomain-MeterViewTest.test_insert_totalizer_meter.json index 179396f..bdbe465 100644 --- a/test-data/meter/test_meterdomain-MeterViewTest.test_insert_totalizer_meter.json +++ b/test-data/meter/test_meterdomain-MeterViewTest.test_insert_totalizer_meter.json @@ -31,6 +31,7 @@ "model_id": "00000001-0000-0000-0000-000000000001", "model_name": "SMXR", "plan_value": null, + "provider_id": null, "serial": "SM15R-01-10000002", "sparkmac_flooding_subnets": 255, "sparkmac_forwarding": "flooding", diff --git a/test-data/meter/test_meterdomain-MeterViewTest.test_reset_customer_meter.json b/test-data/meter/test_meterdomain-MeterViewTest.test_reset_customer_meter.json index ace1d27..c08cece 100644 --- a/test-data/meter/test_meterdomain-MeterViewTest.test_reset_customer_meter.json +++ b/test-data/meter/test_meterdomain-MeterViewTest.test_reset_customer_meter.json @@ -31,6 +31,7 @@ "model_id": "00000001-0000-0000-0000-000000000001", "model_name": "SM25R", "plan_value": 0.0, + "provider_id": null, "serial": "SM15R-01-00000001", "sparkmac_flooding_subnets": 0, "sparkmac_forwarding": "off", diff --git a/test-data/meter/test_meterdomain-MeterViewTest.test_select.json b/test-data/meter/test_meterdomain-MeterViewTest.test_select.json index cf793a0..5b5e20c 100644 --- a/test-data/meter/test_meterdomain-MeterViewTest.test_select.json +++ b/test-data/meter/test_meterdomain-MeterViewTest.test_select.json @@ -31,6 +31,7 @@ "model_id": "00000001-0000-0000-0000-000000000001", "model_name": "SM25R", "plan_value": null, + "provider_id": null, "serial": "SM15R-01-10000002", "sparkmac_flooding_subnets": 0, "sparkmac_forwarding": "off", @@ -75,6 +76,7 @@ "model_id": "00000001-0000-0000-0000-000000000001", "model_name": "SM25R", "plan_value": 0.0, + "provider_id": null, "serial": "SM15R-01-00000001", "sparkmac_flooding_subnets": 0, "sparkmac_forwarding": "off", @@ -119,6 +121,7 @@ "model_id": "00000001-0000-0000-0000-000000000001", "model_name": "SM25R", "plan_value": 0.0, + "provider_id": null, "serial": "SM15R-01-00000003", "sparkmac_flooding_subnets": 0, "sparkmac_forwarding": "off", diff --git a/test-data/meter/test_meterdomain-MeterViewTest.test_update_customer_meter.json b/test-data/meter/test_meterdomain-MeterViewTest.test_update_customer_meter.json index 22708f6..5f1816a 100644 --- a/test-data/meter/test_meterdomain-MeterViewTest.test_update_customer_meter.json +++ b/test-data/meter/test_meterdomain-MeterViewTest.test_update_customer_meter.json @@ -31,6 +31,7 @@ "model_id": "00000001-0000-0000-0000-000000000001", "model_name": "SM25R", "plan_value": 789.0, + "provider_id": null, "serial": "SM15R-01-10000002", "sparkmac_flooding_subnets": 0, "sparkmac_forwarding": "off", diff --git a/test-data/meter/test_meterdomain-MeterViewTest.test_update_totalizer_meter.json b/test-data/meter/test_meterdomain-MeterViewTest.test_update_totalizer_meter.json index d3802b1..a33b1a3 100644 --- a/test-data/meter/test_meterdomain-MeterViewTest.test_update_totalizer_meter.json +++ b/test-data/meter/test_meterdomain-MeterViewTest.test_update_totalizer_meter.json @@ -31,6 +31,7 @@ "model_id": "00000001-0000-0000-0000-000000000001", "model_name": "SM25R", "plan_value": null, + "provider_id": null, "serial": "SM15R-01-10000002", "sparkmac_flooding_subnets": 0, "sparkmac_forwarding": "off", diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add-post.page b/test-data/meter/test_meterviews-MeterViewTest.test_add-post.page index a39d0d5..81f3e50 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add-post.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add-post.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add.page b/test-data/meter/test_meterviews-MeterViewTest.test_add.page index abb8f61..2fe439d 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add.page @@ -29,6 +29,189 @@ + + @@ -88,6 +271,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -220,7 +408,16 @@ -
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_32_bit_serial.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_32_bit_serial.page index abb8f61..2fe439d 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_32_bit_serial.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_32_bit_serial.page @@ -29,6 +29,189 @@ + + @@ -88,6 +271,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -220,7 +408,16 @@ -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_duplicated_serial.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_duplicated_serial.page index e48496a..10f5378 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_duplicated_serial.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_duplicated_serial.page @@ -33,6 +33,189 @@ + + @@ -92,6 +275,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -234,7 +422,16 @@ -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_empty_country_code.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_empty_country_code.page index ebbc860..1afc8d8 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_empty_country_code.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_empty_country_code.page @@ -33,6 +33,189 @@ + + @@ -92,6 +275,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -227,7 +415,16 @@ -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_empty_phone_number.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_empty_phone_number.page index ed3ca53..6dd13f2 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_empty_phone_number.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_empty_phone_number.page @@ -33,6 +33,189 @@ + + @@ -92,6 +275,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -227,7 +415,16 @@ -
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_from_cloud.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_from_cloud.page index 1ee1f00..49ca7c8 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_from_cloud.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_from_cloud.page @@ -29,6 +29,189 @@ + + @@ -88,6 +271,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -220,7 +408,16 @@ -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_phone_number.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_phone_number.page index f9f47ef..8d8c7b7 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_phone_number.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_phone_number.page @@ -33,6 +33,189 @@ + + @@ -92,6 +275,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -227,7 +415,16 @@ -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_serial-invalid-serial.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_serial-invalid-serial.page index eae472e..fcd5dde 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_serial-invalid-serial.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_serial-invalid-serial.page @@ -33,6 +33,189 @@ + + @@ -92,6 +275,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -234,7 +422,16 @@ -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_serial.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_serial.page index 03edcf5..c026113 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_serial.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_invalid_serial.page @@ -30,6 +30,189 @@ + + @@ -89,6 +272,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -231,7 +419,16 @@ -
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_totalizer.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_totalizer.page index 9e52a23..9609a10 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_totalizer.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_totalizer.page @@ -29,6 +29,7 @@ + @@ -88,6 +89,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -220,7 +226,16 @@ -
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_totalizer_from_cloud.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_totalizer_from_cloud.page index e2d461e..a89203b 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_totalizer_from_cloud.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_totalizer_from_cloud.page @@ -29,6 +29,7 @@ + @@ -88,6 +89,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -220,7 +226,16 @@ -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_add_unknown_model.page b/test-data/meter/test_meterviews-MeterViewTest.test_add_unknown_model.page index 32b4b60..c5487eb 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_add_unknown_model.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_add_unknown_model.page @@ -33,6 +33,189 @@ + + @@ -92,6 +275,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -234,7 +422,16 @@ -
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_chart_not_found.page b/test-data/meter/test_meterviews-MeterViewTest.test_chart_not_found.page index 9524794..f108313 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_chart_not_found.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_chart_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_chart_totalizer-totalizer.page b/test-data/meter/test_meterviews-MeterViewTest.test_chart_totalizer-totalizer.page index e13894a..e622e01 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_chart_totalizer-totalizer.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_chart_totalizer-totalizer.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_code_redirect-subpage2.page b/test-data/meter/test_meterviews-MeterViewTest.test_code_redirect-subpage2.page index e42c9f0..faf27bb 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_code_redirect-subpage2.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_code_redirect-subpage2.page @@ -85,6 +85,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_state_cloud-customer-state-cloud.page b/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_state_cloud-customer-state-cloud.page index ea27690..1b0679b 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_state_cloud-customer-state-cloud.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_state_cloud-customer-state-cloud.page @@ -29,6 +29,189 @@ + + @@ -88,6 +271,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -213,7 +401,16 @@
    -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_state_ground-customer-state-ground.page b/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_state_ground-customer-state-ground.page index ea27690..1b0679b 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_state_ground-customer-state-ground.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_state_ground-customer-state-ground.page @@ -29,6 +29,189 @@ + + @@ -88,6 +271,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -213,7 +401,16 @@
    -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_tariff_cloud-customer-tariff-cloud.page b/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_tariff_cloud-customer-tariff-cloud.page index 6ee394a..3d10ea3 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_tariff_cloud-customer-tariff-cloud.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_edit_customer_tariff_cloud-customer-tariff-cloud.page @@ -29,6 +29,189 @@ + + @@ -88,6 +271,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -213,7 +401,16 @@
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_edit_totalizer.page b/test-data/meter/test_meterviews-MeterViewTest.test_edit_totalizer.page index 897127c..41c049d 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_edit_totalizer.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_edit_totalizer.page @@ -29,6 +29,7 @@ + @@ -88,6 +89,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -213,7 +219,16 @@
    -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_edit_with_tags-prepopulate.page b/test-data/meter/test_meterviews-MeterViewTest.test_edit_with_tags-prepopulate.page index 0e67388..028453c 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_edit_with_tags-prepopulate.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_edit_with_tags-prepopulate.page @@ -28,6 +28,189 @@ + + @@ -87,6 +270,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -217,7 +405,16 @@
    -
    +
    + +
    + +
    +
    +
    + +
    +
    diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_edit_with_tags.page b/test-data/meter/test_meterviews-MeterViewTest.test_edit_with_tags.page index 41b3b26..a53b7cc 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_edit_with_tags.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_edit_with_tags.page @@ -29,6 +29,189 @@ + + @@ -88,6 +271,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -213,7 +401,16 @@
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_messages_json_not_found.page b/test-data/meter/test_meterviews-MeterViewTest.test_messages_json_not_found.page index ae19b7e..02598c2 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_messages_json_not_found.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_messages_json_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_reset_meter_not_found.page b/test-data/meter/test_meterviews-MeterViewTest.test_reset_meter_not_found.page index f0a5763..ae74628 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_reset_meter_not_found.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_reset_meter_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_serial_redirect-subpage2.page b/test-data/meter/test_meterviews-MeterViewTest.test_serial_redirect-subpage2.page index efab1d6..a062823 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_serial_redirect-subpage2.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_serial_redirect-subpage2.page @@ -85,6 +85,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_set_state_not_found.page b/test-data/meter/test_meterviews-MeterViewTest.test_set_state_not_found.page index 8e6a4fb..896b8f6 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_set_state_not_found.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_set_state_not_found.page @@ -91,6 +91,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_transactions_csv_not_found.page b/test-data/meter/test_meterviews-MeterViewTest.test_transactions_csv_not_found.page index 8bb386b..43fe09a 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_transactions_csv_not_found.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_transactions_csv_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_transactions_json_not_found.page b/test-data/meter/test_meterviews-MeterViewTest.test_transactions_json_not_found.page index 96afb63..9ece824 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_transactions_json_not_found.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_transactions_json_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_unknown_server_error.page b/test-data/meter/test_meterviews-MeterViewTest.test_unknown_server_error.page index 7d37cdb..14a05d5 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_unknown_server_error.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_unknown_server_error.page @@ -33,6 +33,7 @@ + @@ -92,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • @@ -234,7 +240,16 @@ -
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_view_ground.page b/test-data/meter/test_meterviews-MeterViewTest.test_view_ground.page index d088f3a..0f67c27 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_view_ground.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_view_ground.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_view_ground_error_state.page b/test-data/meter/test_meterviews-MeterViewTest.test_view_ground_error_state.page index 0e56922..afdb58d 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_view_ground_error_state.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_view_ground_error_state.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_view_not_found.page b/test-data/meter/test_meterviews-MeterViewTest.test_view_not_found.page index 1cc5e60..cd0102b 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_view_not_found.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_view_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_view_phone_number_verified-phone-number-not-verified.page b/test-data/meter/test_meterviews-MeterViewTest.test_view_phone_number_verified-phone-number-not-verified.page index 93bbe02..16c0843 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_view_phone_number_verified-phone-number-not-verified.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_view_phone_number_verified-phone-number-not-verified.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_view_phone_number_verified-phone-number-verified.page b/test-data/meter/test_meterviews-MeterViewTest.test_view_phone_number_verified-phone-number-verified.page index 4132578..913618e 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_view_phone_number_verified-phone-number-verified.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_view_phone_number_verified-phone-number-verified.page @@ -85,6 +85,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/meter/test_meterviews-MeterViewTest.test_view_totalizer.page b/test-data/meter/test_meterviews-MeterViewTest.test_view_totalizer.page index 23a56d4..9667daa 100644 --- a/test-data/meter/test_meterviews-MeterViewTest.test_view_totalizer.page +++ b/test-data/meter/test_meterviews-MeterViewTest.test_view_totalizer.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-all.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-all.page index 694fbc5..b6b9e89 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-all.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-all.page @@ -11,7 +11,7 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address SM15R-01-00000001,strëet,customer code 1,,,,,,,,,,,strëet street2 city state SM15R-02-00000001,strëet,customer code 2,,,,,,,,,,,strëet street2 city state SM15R-01-00000013,,,,,,,,,,,,,strëet street2 city state diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-none.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-none.page index 2b31a13..e7234cd 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-none.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-none.page @@ -11,5 +11,5 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-only-1.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-only-1.page index a06daf9..2a281b7 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-only-1.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-only-1.page @@ -11,7 +11,7 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address SM15R-01-00000001,strëet,customer code 1,,,,,,,,,,,strëet street2 city state SM15R-01-00000013,,,,,,,,,,,,,strëet street2 city state SM15R-01-00000002,strëet,customer code 4,On,60.0,120.0,1.0,1,1.0,100,12000,43200,,strëet street2 city state diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-only-2.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-only-2.page index 78aa72c..2aaf772 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-only-2.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-cloud-only-2.page @@ -11,7 +11,7 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address SM15R-02-00000001,strëet,customer code 2,,,,,,,,,,,strëet street2 city state SM15R-02-00000002,strëet,customer code 5,On,60.0,120.0,1.0,1,1.0,100,12000,43200,,strëet street2 city state SM15R-02-00000003,strëet,customer code 7,,,,,,,,,,,strëet street2 city state diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-all.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-all.page index a06daf9..2a281b7 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-all.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-all.page @@ -11,7 +11,7 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address SM15R-01-00000001,strëet,customer code 1,,,,,,,,,,,strëet street2 city state SM15R-01-00000013,,,,,,,,,,,,,strëet street2 city state SM15R-01-00000002,strëet,customer code 4,On,60.0,120.0,1.0,1,1.0,100,12000,43200,,strëet street2 city state diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-none.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-none.page index 2b31a13..e7234cd 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-none.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-none.page @@ -11,5 +11,5 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-only-1.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-only-1.page index a06daf9..2a281b7 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-only-1.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-only-1.page @@ -11,7 +11,7 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address SM15R-01-00000001,strëet,customer code 1,,,,,,,,,,,strëet street2 city state SM15R-01-00000013,,,,,,,,,,,,,strëet street2 city state SM15R-01-00000002,strëet,customer code 4,On,60.0,120.0,1.0,1,1.0,100,12000,43200,,strëet street2 city state diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-only-2.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-only-2.page index 2b31a13..e7234cd 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-only-2.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground1-only-2.page @@ -11,5 +11,5 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-all.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-all.page index 78aa72c..2aaf772 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-all.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-all.page @@ -11,7 +11,7 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address SM15R-02-00000001,strëet,customer code 2,,,,,,,,,,,strëet street2 city state SM15R-02-00000002,strëet,customer code 5,On,60.0,120.0,1.0,1,1.0,100,12000,43200,,strëet street2 city state SM15R-02-00000003,strëet,customer code 7,,,,,,,,,,,strëet street2 city state diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-none.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-none.page index 2b31a13..e7234cd 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-none.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-none.page @@ -11,5 +11,5 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-only-1.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-only-1.page index 2b31a13..e7234cd 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-only-1.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-only-1.page @@ -11,5 +11,5 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-only-2.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-only-2.page index 78aa72c..2aaf772 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-only-2.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-csv-ground2-only-2.page @@ -11,7 +11,7 @@ < Set-Cookie: %% COOKIE %% < Vary: Cookie < -serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_inst,energy,uptime,user_power_limit,age,prr,address +serial,customer_name,customer_code,state,frequency,voltage_avg,current_avg,true_power_avg,energy,uptime,user_power_limit,age,prr,address SM15R-02-00000001,strëet,customer code 2,,,,,,,,,,,strëet street2 city state SM15R-02-00000002,strëet,customer code 5,On,60.0,120.0,1.0,1,1.0,100,12000,43200,,strëet street2 city state SM15R-02-00000003,strëet,customer code 7,,,,,,,,,,,strëet street2 city state diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-all.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-all.page index a11cab5..c155c53 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-all.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-all.page @@ -25,6 +25,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -47,6 +48,7 @@ "heartbeat_end": "", "serial": "SM15R-02-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -69,6 +71,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000013", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -90,6 +93,7 @@ "heartbeat_end": "2014-01-01T12:00:00", "serial": "SM15R-01-00000002", "state": "On", + "true_power_avg": 1, "true_power_inst": 1, "uptime": 100, "user_power_limit": 12000, @@ -111,6 +115,7 @@ "heartbeat_end": "2014-01-01T12:00:00", "serial": "SM15R-02-00000002", "state": "On", + "true_power_avg": 1, "true_power_inst": 1, "uptime": 100, "user_power_limit": 12000, @@ -133,6 +138,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000003", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -155,6 +161,7 @@ "heartbeat_end": "", "serial": "SM15R-02-00000003", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-only-1.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-only-1.page index 9cbdd48..1ddb036 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-only-1.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-only-1.page @@ -25,6 +25,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -47,6 +48,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000013", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -68,6 +70,7 @@ "heartbeat_end": "2014-01-01T12:00:00", "serial": "SM15R-01-00000002", "state": "On", + "true_power_avg": 1, "true_power_inst": 1, "uptime": 100, "user_power_limit": 12000, @@ -90,6 +93,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000003", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-only-2.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-only-2.page index 74bff3c..7c06566 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-only-2.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-cloud-only-2.page @@ -25,6 +25,7 @@ "heartbeat_end": "", "serial": "SM15R-02-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -46,6 +47,7 @@ "heartbeat_end": "2014-01-01T12:00:00", "serial": "SM15R-02-00000002", "state": "On", + "true_power_avg": 1, "true_power_inst": 1, "uptime": 100, "user_power_limit": 12000, @@ -68,6 +70,7 @@ "heartbeat_end": "", "serial": "SM15R-02-00000003", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground1-all.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground1-all.page index 9cbdd48..1ddb036 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground1-all.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground1-all.page @@ -25,6 +25,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -47,6 +48,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000013", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -68,6 +70,7 @@ "heartbeat_end": "2014-01-01T12:00:00", "serial": "SM15R-01-00000002", "state": "On", + "true_power_avg": 1, "true_power_inst": 1, "uptime": 100, "user_power_limit": 12000, @@ -90,6 +93,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000003", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground1-only-1.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground1-only-1.page index 9cbdd48..1ddb036 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground1-only-1.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground1-only-1.page @@ -25,6 +25,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -47,6 +48,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000013", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -68,6 +70,7 @@ "heartbeat_end": "2014-01-01T12:00:00", "serial": "SM15R-01-00000002", "state": "On", + "true_power_avg": 1, "true_power_inst": 1, "uptime": 100, "user_power_limit": 12000, @@ -90,6 +93,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000003", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground2-all.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground2-all.page index 74bff3c..7c06566 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground2-all.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground2-all.page @@ -25,6 +25,7 @@ "heartbeat_end": "", "serial": "SM15R-02-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -46,6 +47,7 @@ "heartbeat_end": "2014-01-01T12:00:00", "serial": "SM15R-02-00000002", "state": "On", + "true_power_avg": 1, "true_power_inst": 1, "uptime": 100, "user_power_limit": 12000, @@ -68,6 +70,7 @@ "heartbeat_end": "", "serial": "SM15R-02-00000003", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground2-only-2.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground2-only-2.page index 74bff3c..7c06566 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground2-only-2.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings-json-ground2-only-2.page @@ -25,6 +25,7 @@ "heartbeat_end": "", "serial": "SM15R-02-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -46,6 +47,7 @@ "heartbeat_end": "2014-01-01T12:00:00", "serial": "SM15R-02-00000002", "state": "On", + "true_power_avg": 1, "true_power_inst": 1, "uptime": 100, "user_power_limit": 12000, @@ -68,6 +70,7 @@ "heartbeat_end": "", "serial": "SM15R-02-00000003", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_page_cloud.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_page_cloud.page index e1e41d4..db06964 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_page_cloud.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_page_cloud.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_page_ground.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_page_ground.page index c1b0e61..f3d6b35 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_page_ground.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_page_ground.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_totalizers_present.page b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_totalizers_present.page index 34f6988..ddb3b36 100644 --- a/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_totalizers_present.page +++ b/test-data/reading/test_readingviews-ReadingViewTest.test_latest_readings_totalizers_present.page @@ -28,6 +28,7 @@ "heartbeat_end": "", "serial": "SM15R-01-00000001", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", @@ -50,6 +51,7 @@ "heartbeat_end": "", "serial": "SM200E-01-00000011", "state": "", + "true_power_avg": "", "true_power_inst": "", "uptime": "", "user_power_limit": "", diff --git a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_add_duplicated_name.page b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_add_duplicated_name.page index 8e01a7d..14236d0 100644 --- a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_add_duplicated_name.page +++ b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_add_duplicated_name.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_add_missing_name.page b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_add_missing_name.page index e04daf0..6b91c36 100644 --- a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_add_missing_name.page +++ b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_add_missing_name.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_edit_global-get.page b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_edit_global-get.page index 5c888e9..bbd7fab 100644 --- a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_edit_global-get.page +++ b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_edit_global-get.page @@ -89,6 +89,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_edit_restricted.page b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_edit_restricted.page index 1e854d7..1bfbf2b 100644 --- a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_edit_restricted.page +++ b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_edit_restricted.page @@ -89,6 +89,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_index.page b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_index.page index 4465088..cd159f9 100644 --- a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_index.page +++ b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_index.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_global.page b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_global.page index 339c9f6..d88f166 100644 --- a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_global.page +++ b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_global.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_no_permissions.page b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_no_permissions.page index 13a00f7..4edae7a 100644 --- a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_no_permissions.page +++ b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_no_permissions.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_restricted.page b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_restricted.page index d4a1746..842181c 100644 --- a/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_restricted.page +++ b/test-data/salesaccount/test_salesaccountview-SalesAccountViewTest.test_view_restricted.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_add.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_add.page index ea47c62..da7e2ad 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_add.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_add.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form.page index 2a10b96..14bbd39 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_int_max_allowed.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_int_max_allowed.page index d861bd6..a85bd38 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_int_max_allowed.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_int_max_allowed.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_int_outrange.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_int_outrange.page index 9038aac..f19412f 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_int_outrange.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_int_outrange.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_blockrates.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_blockrates.page index f793df8..e4f3fe9 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_blockrates.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_blockrates.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_load_limits.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_load_limits.page index 2a091c0..b7bb941 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_load_limits.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_load_limits.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_tous-tous-post.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_tous-tous-post.page index 6021475..07c34b6 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_tous-tous-post.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_form_with_tous-tous-post.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_with_invalid_blockrates.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_with_invalid_blockrates.page index 64a5b40..392857c 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_with_invalid_blockrates.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_with_invalid_blockrates.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_with_invalid_tous.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_with_invalid_tous.page index 4db7654..cfc0147 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_add_with_invalid_tous.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_add_with_invalid_tous.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-edit-negative-post-error.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-edit-negative-post-error.page index 988d2ec..ba9254e 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-edit-negative-post-error.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-edit-negative-post-error.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-edit-post-error.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-edit-post-error.page index d0ce8fc..c140f21 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-edit-post-error.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-edit-post-error.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-new-tariff.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-new-tariff.page index ca05edb..18a266f 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-new-tariff.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_edit-new-tariff.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_edit.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_edit.page index cc88916..37f432b 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_edit.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_edit.page @@ -89,6 +89,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_blockrate.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_blockrate.page index 03ca7dc..9ae8d91 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_blockrate.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_blockrate.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_duplicate_name.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_duplicate_name.page index 9b5013b..18546b3 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_duplicate_name.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_duplicate_name.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_empty_name.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_empty_name.page index 2652f10..a735a8a 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_empty_name.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_empty_name.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_enter_load_limit.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_enter_load_limit.page index cc600db..746a3ba 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_enter_load_limit.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_enter_load_limit.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_enter_monthly_plan_price.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_enter_monthly_plan_price.page index 1ab550a..afdfc11 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_enter_monthly_plan_price.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_enter_monthly_plan_price.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_existing_duplicate_names.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_existing_duplicate_names.page index 7b325ed..edbb6f1 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_existing_duplicate_names.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_existing_duplicate_names.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_flat_rate.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_flat_rate.page index 67234ca..753a4da 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_flat_rate.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_flat_rate.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_low_balance_empty.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_low_balance_empty.page index e6b8d80..61aad99 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_low_balance_empty.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_low_balance_empty.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_low_balance_negative.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_low_balance_negative.page index aace06d..c493935 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_low_balance_negative.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_low_balance_negative.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_negative_flat_rate.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_negative_flat_rate.page index 850b78d..e0c4675 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_negative_flat_rate.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_negative_flat_rate.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_negative_load_limit.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_negative_load_limit.page index ebfae4c..41c2474 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_negative_load_limit.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_negative_load_limit.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_no_scheduled_load_limits.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_no_scheduled_load_limits.page index 8739e41..9b7ff45 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_no_scheduled_load_limits.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_no_scheduled_load_limits.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_tous.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_tous.page index 9b271f2..86a1263 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_error_tous.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_error_tous.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_list.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_list.page index 7afe477..f8ed924 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_list.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_list.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_view.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_view.page index 96fc727..7970e18 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_view.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_view.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/tariff/test_tariffviews-TariffViewTest.test_view_not_found.page b/test-data/tariff/test_tariffviews-TariffViewTest.test_view_not_found.page index 58023b6..3d1e26f 100644 --- a/test-data/tariff/test_tariffviews-TariffViewTest.test_view_not_found.page +++ b/test-data/tariff/test_tariffviews-TariffViewTest.test_view_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add.page b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add.page index 6aeb265..53f4bce 100644 --- a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add.page +++ b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_debt.page b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_debt.page index 6aeb265..53f4bce 100644 --- a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_debt.page +++ b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_debt.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_debt_error.page b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_debt_error.page index d18654d..0679376 100644 --- a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_debt_error.page +++ b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_debt_error.page @@ -92,6 +92,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_from_disabled_account-post.page b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_from_disabled_account-post.page index cb49f2e..a8f3726 100644 --- a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_from_disabled_account-post.page +++ b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_from_disabled_account-post.page @@ -89,6 +89,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_from_disabled_account.page b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_from_disabled_account.page index a3d14a7..1577bd6 100644 --- a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_from_disabled_account.page +++ b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_from_disabled_account.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_meter_not_found.page b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_meter_not_found.page index 20504a0..0b41649 100644 --- a/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_meter_not_found.page +++ b/test-data/transaction/test_transactionviews-TransactionViewTest.test_add_meter_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/transaction/test_transactionviews-TransactionViewTest.test_reverse_not_found.page b/test-data/transaction/test_transactionviews-TransactionViewTest.test_reverse_not_found.page index 98a33ed..8ac05fb 100644 --- a/test-data/transaction/test_transactionviews-TransactionViewTest.test_reverse_not_found.page +++ b/test-data/transaction/test_transactionviews-TransactionViewTest.test_reverse_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/transaction/test_transactionviews-TransactionViewTest.test_transactions.page b/test-data/transaction/test_transactionviews-TransactionViewTest.test_transactions.page index ed72811..49fb3ac 100644 --- a/test-data/transaction/test_transactionviews-TransactionViewTest.test_transactions.page +++ b/test-data/transaction/test_transactionviews-TransactionViewTest.test_transactions.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/transaction/test_transactionviews-TransactionViewTest.test_transfer_account_not_found.page b/test-data/transaction/test_transactionviews-TransactionViewTest.test_transfer_account_not_found.page index a87377d..01b0bf8 100644 --- a/test-data/transaction/test_transactionviews-TransactionViewTest.test_transfer_account_not_found.page +++ b/test-data/transaction/test_transactionviews-TransactionViewTest.test_transfer_account_not_found.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_account_all_access-empty.page b/test-data/user/test_userviews-UserViewTest.test_add_account_all_access-empty.page index b41819a..39fd71c 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_account_all_access-empty.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_account_all_access-empty.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_duplicate_email.page b/test-data/user/test_userviews-UserViewTest.test_add_duplicate_email.page index 5e88f1d..bba8501 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_duplicate_email.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_duplicate_email.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_duplicate_username.page b/test-data/user/test_userviews-UserViewTest.test_add_duplicate_username.page index 3255c19..3609b15 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_duplicate_username.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_duplicate_username.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_errors.page b/test-data/user/test_userviews-UserViewTest.test_add_errors.page index 4692969..f3d3d0b 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_errors.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_errors.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_ground_all_access-empty.page b/test-data/user/test_userviews-UserViewTest.test_add_ground_all_access-empty.page index 1ebaa94..081aa6e 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_ground_all_access-empty.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_ground_all_access-empty.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_mismatching_passwords.page b/test-data/user/test_userviews-UserViewTest.test_add_mismatching_passwords.page index c64804b..d82e60a 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_mismatching_passwords.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_mismatching_passwords.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_missing_email-empty.page b/test-data/user/test_userviews-UserViewTest.test_add_missing_email-empty.page index f028ee5..3344361 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_missing_email-empty.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_missing_email-empty.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_missing_passwords.page b/test-data/user/test_userviews-UserViewTest.test_add_missing_passwords.page index b58de0e..68c6f73 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_missing_passwords.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_missing_passwords.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_missing_username.page b/test-data/user/test_userviews-UserViewTest.test_add_missing_username.page index d3e5f9d..c3aeaf4 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_missing_username.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_missing_username.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_operator-empty.page b/test-data/user/test_userviews-UserViewTest.test_add_operator-empty.page index f028ee5..3344361 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_operator-empty.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_operator-empty.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_add_vendor-empty.page b/test-data/user/test_userviews-UserViewTest.test_add_vendor-empty.page index 04f4837..e507146 100644 --- a/test-data/user/test_userviews-UserViewTest.test_add_vendor-empty.page +++ b/test-data/user/test_userviews-UserViewTest.test_add_vendor-empty.page @@ -93,6 +93,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_bad_username.page b/test-data/user/test_userviews-UserViewTest.test_bad_username.page index 5e88d9a..fc5ac5a 100644 --- a/test-data/user/test_userviews-UserViewTest.test_bad_username.page +++ b/test-data/user/test_userviews-UserViewTest.test_bad_username.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_edit-redirected.page b/test-data/user/test_userviews-UserViewTest.test_edit-redirected.page index 16d709f..f6df805 100644 --- a/test-data/user/test_userviews-UserViewTest.test_edit-redirected.page +++ b/test-data/user/test_userviews-UserViewTest.test_edit-redirected.page @@ -86,6 +86,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_edit.page b/test-data/user/test_userviews-UserViewTest.test_edit.page index 8742214..3c6285d 100644 --- a/test-data/user/test_userviews-UserViewTest.test_edit.page +++ b/test-data/user/test_userviews-UserViewTest.test_edit.page @@ -89,6 +89,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_edit_unknown_user.page b/test-data/user/test_userviews-UserViewTest.test_edit_unknown_user.page index 9efcfd4..a5e8f39 100644 --- a/test-data/user/test_userviews-UserViewTest.test_edit_unknown_user.page +++ b/test-data/user/test_userviews-UserViewTest.test_edit_unknown_user.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_list.page b/test-data/user/test_userviews-UserViewTest.test_list.page index 5e88d9a..fc5ac5a 100644 --- a/test-data/user/test_userviews-UserViewTest.test_list.page +++ b/test-data/user/test_userviews-UserViewTest.test_list.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_reset_credentials_unknown_user.page b/test-data/user/test_userviews-UserViewTest.test_reset_credentials_unknown_user.page index 3d55912..de00813 100644 --- a/test-data/user/test_userviews-UserViewTest.test_reset_credentials_unknown_user.page +++ b/test-data/user/test_userviews-UserViewTest.test_reset_credentials_unknown_user.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_update_locale-not-found.page b/test-data/user/test_userviews-UserViewTest.test_update_locale-not-found.page index b7bdd75..109397b 100644 --- a/test-data/user/test_userviews-UserViewTest.test_update_locale-not-found.page +++ b/test-data/user/test_userviews-UserViewTest.test_update_locale-not-found.page @@ -87,6 +87,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_user_add_api-empty.page b/test-data/user/test_userviews-UserViewTest.test_user_add_api-empty.page index 93f1607..3644865 100644 --- a/test-data/user/test_userviews-UserViewTest.test_user_add_api-empty.page +++ b/test-data/user/test_userviews-UserViewTest.test_user_add_api-empty.page @@ -89,6 +89,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_user_sales_accounts_invalid_user.page b/test-data/user/test_userviews-UserViewTest.test_user_sales_accounts_invalid_user.page index fc59637..875ed61 100644 --- a/test-data/user/test_userviews-UserViewTest.test_user_sales_accounts_invalid_user.page +++ b/test-data/user/test_userviews-UserViewTest.test_user_sales_accounts_invalid_user.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_view.page b/test-data/user/test_userviews-UserViewTest.test_view.page index 212d299..61b0a7c 100644 --- a/test-data/user/test_userviews-UserViewTest.test_view.page +++ b/test-data/user/test_userviews-UserViewTest.test_view.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_view_api.page b/test-data/user/test_userviews-UserViewTest.test_view_api.page index 0f9f73c..f6c6665 100644 --- a/test-data/user/test_userviews-UserViewTest.test_view_api.page +++ b/test-data/user/test_userviews-UserViewTest.test_view_api.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/user/test_userviews-UserViewTest.test_view_unknown_user.page b/test-data/user/test_userviews-UserViewTest.test_view_unknown_user.page index 9b75a96..6fe7ba0 100644 --- a/test-data/user/test_userviews-UserViewTest.test_view_unknown_user.page +++ b/test-data/user/test_userviews-UserViewTest.test_view_unknown_user.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/web/test_views-WebViewTest.test_change_password_view.page b/test-data/web/test_views-WebViewTest.test_change_password_view.page index 969db8b..d829a34 100644 --- a/test-data/web/test_views-WebViewTest.test_change_password_view.page +++ b/test-data/web/test_views-WebViewTest.test_change_password_view.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/web/test_views-WebViewTest.test_change_password_view_portal.page b/test-data/web/test_views-WebViewTest.test_change_password_view_portal.page index 4fe182a..1a932ce 100644 --- a/test-data/web/test_views-WebViewTest.test_change_password_view_portal.page +++ b/test-data/web/test_views-WebViewTest.test_change_password_view_portal.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/web/test_views-WebViewTest.test_override_banner_on_cloud.page b/test-data/web/test_views-WebViewTest.test_override_banner_on_cloud.page index a8aad4b..93b7e8a 100644 --- a/test-data/web/test_views-WebViewTest.test_override_banner_on_cloud.page +++ b/test-data/web/test_views-WebViewTest.test_override_banner_on_cloud.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/web/test_views-WebViewTest.test_override_banner_on_ground.page b/test-data/web/test_views-WebViewTest.test_override_banner_on_ground.page index c7c69c9..c14fcf5 100644 --- a/test-data/web/test_views-WebViewTest.test_override_banner_on_ground.page +++ b/test-data/web/test_views-WebViewTest.test_override_banner_on_ground.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/web/test_views-WebViewTest.test_readonly_app.page b/test-data/web/test_views-WebViewTest.test_readonly_app.page index bb382b6..580a04e 100644 --- a/test-data/web/test_views-WebViewTest.test_readonly_app.page +++ b/test-data/web/test_views-WebViewTest.test_readonly_app.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/web/test_views-WebViewTest.test_reset_demo_data.page b/test-data/web/test_views-WebViewTest.test_reset_demo_data.page index 2c54ee2..7333a93 100644 --- a/test-data/web/test_views-WebViewTest.test_reset_demo_data.page +++ b/test-data/web/test_views-WebViewTest.test_reset_demo_data.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/web/test_views-WebViewTest.test_reset_demo_data_disabled.page b/test-data/web/test_views-WebViewTest.test_reset_demo_data_disabled.page index 39b050a..89bb427 100644 --- a/test-data/web/test_views-WebViewTest.test_reset_demo_data_disabled.page +++ b/test-data/web/test_views-WebViewTest.test_reset_demo_data_disabled.page @@ -88,6 +88,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/test-data/web/test_views-WebViewTest.test_reset_demo_data_post-post.page b/test-data/web/test_views-WebViewTest.test_reset_demo_data_post-post.page index 742da67..176fd39 100644 --- a/test-data/web/test_views-WebViewTest.test_reset_demo_data_post-post.page +++ b/test-data/web/test_views-WebViewTest.test_reset_demo_data_post-post.page @@ -90,6 +90,11 @@ Meters
  • +
  • + + Meter Drivers + +
  • diff --git a/uv.lock b/uv.lock index 76434d1..80b9f82 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -577,66 +577,33 @@ wheels = [ [[package]] name = "grpcio" -version = "1.80.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, - { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, - { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, - { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, - { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, - { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, - { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, - { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, - { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, - { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, - { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, - { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, - { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, - { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, - { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, -] - -[[package]] -name = "grpcio-tools" -version = "1.80.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "grpcio" }, - { name = "protobuf" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/c8/1223f29c84a143ae9a56c084fc96894de0ba84b6e8d60a26241abd81d278/grpcio_tools-1.80.0.tar.gz", hash = "sha256:26052b19c6ce0dcf52d1024496aea3e2bdfa864159f06dc7b97b22d041a94b26", size = 6133212, upload-time = "2026-03-30T08:52:39.077Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/d9/f7887a4805939e9a85d03744b66fc02575dc1df3c3e8b4d9ec000ee7a33d/grpcio_tools-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e7046837859bbfd10b01786056145480155c16b222c9e209215b68d3be13060e", size = 2550319, upload-time = "2026-03-30T08:51:19.117Z" }, - { url = "https://files.pythonhosted.org/packages/57/5a/c8a05b32bd7203f1b9f4c0151090a2d6179d6c97692d32f2066dc29c67a6/grpcio_tools-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a447f28958a8fe84ff0d9d3d9473868feb27ee4a9c9c805e66f5b670121cec59", size = 5709681, upload-time = "2026-03-30T08:51:21.991Z" }, - { url = "https://files.pythonhosted.org/packages/82/6b/794350ed645c12c310008f97068f6a6fd927150b0d0d08aad1d909e880b1/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75f00450e08fe648ad8a1eeb25bc52219679d54cdd02f04dfdddc747309d83f6", size = 2596820, upload-time = "2026-03-30T08:51:24.323Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b2/b39e7b79f7c878135e0784a53cd7260ee77260c8c7f2c9e46bca8e05d017/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3db830eaff1f2c2797328f2fa86c9dcdbd7d81af573a68db81e27afa2182a611", size = 2909193, upload-time = "2026-03-30T08:51:27.025Z" }, - { url = "https://files.pythonhosted.org/packages/10/f3/abe089b058f87f9910c9a458409505cbeb0b3e1c2d993a79721d02ee6a32/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7982b5fe42f012686b667dda12916884de95c4b1c65ff64371fb7232a1474b23", size = 2660197, upload-time = "2026-03-30T08:51:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/09/c3/3f7806ad8b731d8a89fe3c6ed496473abd1ef4c9c42c9e9a8836ce96e377/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6451b3f4eb52d12c7f32d04bf8e0185f80521f3f088ad04b8d222b3a4819c71e", size = 3113144, upload-time = "2026-03-30T08:51:31.671Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f5/415ef205e0b7e75d2a2005df6120145c4f02fda28d7b3715b55d924fe1a4/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:258bc30654a9a2236be4ca8e2ad443e2ac6db7c8cc20454d34cce60265922726", size = 3661897, upload-time = "2026-03-30T08:51:34.849Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d3/2ad54764c2a9547080dd8518f4a4dc7899c7e6e747a1b1de542ce6a12066/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:865a2b8e6334c838976ab02a322cbd55c863d2eaf3c1e1a0255883c63996772a", size = 3328786, upload-time = "2026-03-30T08:51:37.265Z" }, - { url = "https://files.pythonhosted.org/packages/eb/63/23ab7db01f9630ab4f3742a2fc9fbff38b0cfc30c976114f913950664a75/grpcio_tools-1.80.0-cp313-cp313-win32.whl", hash = "sha256:f760ac1722f33e774814c37b6aa0444143f612e85088ead7447a0e9cd306a1f1", size = 997087, upload-time = "2026-03-30T08:51:39.137Z" }, - { url = "https://files.pythonhosted.org/packages/9b/af/b1c1c4423fb49cb7c8e9d2c02196b038c44160b7028b425466743c6c81fa/grpcio_tools-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:7843b9ac6ff8ca508424d0dd968bd9a1a4559967e4a290f26be5bd6f04af2234", size = 1162167, upload-time = "2026-03-30T08:51:41.498Z" }, - { url = "https://files.pythonhosted.org/packages/0e/44/7beeee2348f9f412804f5bf80b7d13b81d522bf926a338ae3da46b2213b7/grpcio_tools-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:12f950470449dbeec78317dbc090add7a00eb6ca812af7b0538ab7441e0a42c3", size = 2550303, upload-time = "2026-03-30T08:51:44.373Z" }, - { url = "https://files.pythonhosted.org/packages/2d/aa/f77dd85409a1855f8c6319ffc69d81e8c3ffe122ee3a7136653e1991d8b6/grpcio_tools-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d3f9a376a29c9adf62bb56f7ff5bc81eb4abeaf53d1e7dde5015564832901a51", size = 5709778, upload-time = "2026-03-30T08:51:47.112Z" }, - { url = "https://files.pythonhosted.org/packages/9c/7c/ab7af4883ebdfdc228b853de89fed409703955e8d47285b321a5794856bd/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ba1ffbf2cff71533615e2c5a138ed5569611eec9ae7f9c67b8898e127b54ac0", size = 2597928, upload-time = "2026-03-30T08:51:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/22/e8/4381a963d472e3ab6690ba067ed2b1f1abf8518b10f402678bd2dcb79a54/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:13f60f8d9397c514c6745a967d22b5c8c698347e88deebca1ff2e1b94555e450", size = 2909333, upload-time = "2026-03-30T08:51:52.124Z" }, - { url = "https://files.pythonhosted.org/packages/94/cb/356b5fdf79dd99455b425fb16302fe60995554ceb721afbf3cf770a19208/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d77bad5dd3cd5e6f952c4ecdd0ee33e0c02ecfc2e4b0cbee3391ac19e0a431", size = 2660217, upload-time = "2026-03-30T08:51:55.066Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d7/1752018cc2c36b2c5612051379e2e5f59f2dbe612de23e817d2f066a9487/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017945c3e98a4ed1c4e21399781b4137fc08dfc1f802c8ace2e64ef52d32b142", size = 3113896, upload-time = "2026-03-30T08:51:57.3Z" }, - { url = "https://files.pythonhosted.org/packages/cc/17/695bbe454f70df35c03e22b48c5314683b913d3e6ed35ec90d065418c1ab/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a33e265d4db803495007a6c623eafb0f6b9bb123ff4a0af89e44567dad809b88", size = 3661950, upload-time = "2026-03-30T08:51:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d0/533d87629ec823c02c9169ee20228f734c264b209dcdf55268b5a14cde0a/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c129da370c5f85f569be2e545317dda786a60dd51d7deea29b03b0c05f6aac3", size = 3328755, upload-time = "2026-03-30T08:52:02.942Z" }, - { url = "https://files.pythonhosted.org/packages/08/a1/504d7838770c73a9761e8a8ff4869dba1146b44f297ff0ac6641481942d3/grpcio_tools-1.80.0-cp314-cp314-win32.whl", hash = "sha256:25742de5958ae4325249a37e724e7c0e5120f8e302a24a977ebd1737b48a5e97", size = 1019620, upload-time = "2026-03-30T08:52:05.342Z" }, - { url = "https://files.pythonhosted.org/packages/f3/75/8b7cd281c5cdfb4ca2c308f7e9b2799bab2be6e7a9e9212ea5a82e2aecd4/grpcio_tools-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:bbf8eeef78fda1966f732f79c1c802fadd5cfd203d845d2af4d314d18569069c", size = 1194210, upload-time = "2026-03-30T08:52:08.105Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, + { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, + { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, ] [[package]] @@ -959,6 +926,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "meter-driver-spec" +version = "1.4.0" +source = { url = "https://github.com/EarthSpark/meter-driver-spec/releases/download/v1.4.0/meter_driver_spec-1.4.0-py3-none-any.whl" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +wheels = [ + { url = "https://github.com/EarthSpark/meter-driver-spec/releases/download/v1.4.0/meter_driver_spec-1.4.0-py3-none-any.whl", hash = "sha256:02ab641bfdbb048cb396ab9c1545d4d836aea076088e08f4afaa3a4c0dbf3577" }, +] + +[package.metadata] +requires-dist = [ + { name = "grpcio", specifier = ">=1.82,<2" }, + { name = "protobuf", specifier = ">=7.35,<8" }, + { name = "pydantic", specifier = ">=2.11" }, + { name = "python-dateutil", specifier = ">=2.8.2" }, + { name = "typing-extensions", specifier = ">=4.7.1" }, + { name = "urllib3", specifier = ">=2.1.0,<3" }, +] + [[package]] name = "more-itertools" version = "11.0.2" @@ -1159,17 +1152,17 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.6" +version = "7.35.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, ] [[package]] @@ -1718,9 +1711,11 @@ dependencies = [ { name = "flask-sqlalchemy" }, { name = "flask-wtf" }, { name = "future" }, + { name = "grpcio" }, { name = "httpx" }, { name = "hypercorn" }, { name = "itsdangerous" }, + { name = "meter-driver-spec" }, { name = "numpy" }, { name = "packaging" }, { name = "pandas" }, @@ -1746,7 +1741,6 @@ dev = [ { name = "factory-boy" }, { name = "filelock" }, { name = "freezegun" }, - { name = "grpcio-tools" }, { name = "html5lib" }, { name = "httmock" }, { name = "pre-commit" }, @@ -1781,9 +1775,11 @@ requires-dist = [ { name = "flask-sqlalchemy" }, { name = "flask-wtf" }, { name = "future" }, + { name = "grpcio" }, { name = "httpx" }, { name = "hypercorn" }, { name = "itsdangerous" }, + { name = "meter-driver-spec", url = "https://github.com/EarthSpark/meter-driver-spec/releases/download/v1.4.0/meter_driver_spec-1.4.0-py3-none-any.whl" }, { name = "numpy" }, { name = "packaging" }, { name = "pandas" }, @@ -1809,7 +1805,6 @@ dev = [ { name = "factory-boy" }, { name = "filelock" }, { name = "freezegun" }, - { name = "grpcio-tools" }, { name = "html5lib" }, { name = "httmock" }, { name = "pre-commit" }, From b18ec9b7d9f753dfc81f811128da4de8ad4a1bcf Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:38:25 -0400 Subject: [PATCH 4/9] Remove the fake-reading generator command Delete the `reading create-fake` command and its ReadingGenerator, which synthesized random readings for demo/testing. Drop the now-empty reading CLI group registration and its coverage in the CLI-registration and meter-command tests. --- sparkmeter/cli.py | 2 - sparkmeter/meter/tests/test_metercommand.py | 14 -- sparkmeter/reading/readingcommand.py | 187 ------------------ .../reading/tests/test_readingcommand.py | 132 ------------- sparkmeter/tests/test_managecommands.py | 1 - 5 files changed, 336 deletions(-) delete mode 100644 sparkmeter/reading/readingcommand.py delete mode 100644 sparkmeter/reading/tests/test_readingcommand.py diff --git a/sparkmeter/cli.py b/sparkmeter/cli.py index 369ee53..d9be372 100644 --- a/sparkmeter/cli.py +++ b/sparkmeter/cli.py @@ -20,7 +20,6 @@ def register_cli_commands(app): from sparkmeter.ground.groundcommand import create_ground from sparkmeter.meter.metercommand import meter from sparkmeter.metering.cli import metering - from sparkmeter.reading.readingcommand import reading from sparkmeter.salesaccount.salesaccountcommand import salesaccount from sparkmeter.servercommand import server, shell from sparkmeter.system.systemcommand import status, system @@ -34,7 +33,6 @@ def register_cli_commands(app): app.cli.add_command(event) app.cli.add_command(meter) app.cli.add_command(metering) - app.cli.add_command(reading) app.cli.add_command(salesaccount) app.cli.add_command(server) app.cli.add_command(system) diff --git a/sparkmeter/meter/tests/test_metercommand.py b/sparkmeter/meter/tests/test_metercommand.py index 48c9457..13adca8 100644 --- a/sparkmeter/meter/tests/test_metercommand.py +++ b/sparkmeter/meter/tests/test_metercommand.py @@ -335,17 +335,3 @@ def counting_fn(): with pytest.raises(StopIteration): counting_fn(forever=True, delay=0) assert call_count[0] == 3 - - -class ReadingGeneratorTest(SparkMeterTestCaseBase): - def test_heartbeat(self, mocker): - from sparkmeter.reading.readingcommand import ReadingGenerator - - event_create = mocker.patch("sparkmeter.event.eventdomain.Event.create") - event_create.return_value = EventFactory() - - m = MeterFactory() - self.session.commit() - - rg = ReadingGenerator(energy_watts=60, cycle_length=15) - rg.create_for_meter(m.serial) diff --git a/sparkmeter/reading/readingcommand.py b/sparkmeter/reading/readingcommand.py deleted file mode 100644 index 42b910a..0000000 --- a/sparkmeter/reading/readingcommand.py +++ /dev/null @@ -1,187 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright © 2013-2017 SparkMeter, Inc. -# All Rights Reserved. -"""Reading manage commands.py.""" - -from __future__ import division - -import datetime -import logging -import random -import time -from builtins import object, range, str - -import click -from flask.cli import with_appcontext -from past.utils import old_div -from zope.component import getUtility - -from sparkmeter.interface import IApplication - -logger = logging.getLogger(__name__) - -reading = click.Group("reading", help="Reading management commands.") - - -class DisableLogger(object): - def __enter__(self): - logging.disable(logging.CRITICAL) - - def __exit__(self, a, b, c): - logging.disable(logging.NOTSET) - - -class ReadingGenerator(object): - def __init__(self, energy_watts, cycle_length): - self.energy_watts = energy_watts - self.cycle_length = cycle_length - - def run_cycle_loop(self): - from dateutil.relativedelta import relativedelta - - from sparkmeter.meter.meterdomain import Meter - from sparkmeter.models import session_scope - - with session_scope(): - all_meters = Meter.get_all() - now = datetime.datetime.utcnow() - heartbeat_start = now.replace( - microsecond=0, second=0, minute=(old_div(now.minute, self.cycle_length)) * self.cycle_length - ) - - while True: - heartbeat_end = heartbeat_start + relativedelta(minutes=self.cycle_length) - logging.info("* heartbeat period %s TO %s" % (heartbeat_start, heartbeat_end)) - meters = self.get_meters(all_meters) - self.heartbeat(meters, start=heartbeat_start, end=heartbeat_end) - heartbeat_start = heartbeat_end - - def create_for_meter(self, serial): - from sparkmeter.meter.meterdomain import Meter - from sparkmeter.models import session_scope - - with session_scope(): - meter = Meter.get_by_serial(serial) - if meter is None: - logger.error("Meter does not exist: %s" % (serial,)) - raise SystemExit(1) - self.create_reading(meter) - - def heartbeat(self, meters, start, end): - from sparkmeter.models import session_scope - - interval = old_div((end - start).seconds, 60) - for i in range(interval): - t = time.time() - n_readings = 0 - if i < len(meters): - with session_scope(), DisableLogger(): - for meter in meters[i]: - n_readings += self.create_reading( - meter, heartbeat_length=interval, heartbeat_start=start, heartbeat_end=end - ) - - delta = time.time() - t - sleep_period = int(old_div(float(self.cycle_length * 60), interval)) - logger.info("Added %d readings in %ds (sleeping %ds)" % (n_readings, delta, sleep_period - delta)) - time.sleep(sleep_period) - - def create_reading(self, meter, heartbeat_length=15, heartbeat_start=None, heartbeat_end=None): - """ - Create a new reading. - - :param meter: meter to create reading for - :type meter: Meter - :param heartbeat_length: length of the heart beat (the period), in minutes - :type heartbeat_length: int - :param heartbeat_start: beginning of the heartbeat - :type heartbeat_start: datetime.datetime - :param heartbeat_end: end of the heartbeat - :type heartbeat_end: datetime.datetime - :return: - """ - from dateutil.relativedelta import relativedelta - - from sparkmeter.controller import add_reading - - voltages = [random.uniform(118.0, 122.0) for _ in range(10)] - - if meter.system_info.reading is None: - now = datetime.datetime.now() - last_datetime = now.replace( - microsecond=0, second=0, minute=(old_div(now.minute, heartbeat_length)) * heartbeat_length - ) - else: - last_datetime = meter.system_info.reading.heartbeat_end - if heartbeat_end is not None and heartbeat_end == last_datetime: - return 0 - - last_energy = meter.system_info.last_energy - cycles_per_hour = old_div(60.0, self.cycle_length) - consumption_in_kwh = old_div(self.energy_watts, (cycles_per_hour * 1000.0)) - new_energy = last_energy + consumption_in_kwh - if heartbeat_start is None: - heartbeat_start = last_datetime - if heartbeat_end is None: - heartbeat_end = heartbeat_start + relativedelta(minutes=15) - assert heartbeat_start != heartbeat_end - data = { - "meter": str(meter.code), - "heartbeat_start": heartbeat_start, - "heartbeat_end": heartbeat_end, - "frequency": round(random.uniform(60.5, 60.6), 2), - "state": "on", - "uptime": 100, - "voltage_min": round(min(voltages), 2), - "voltage_max": round(max(voltages), 2), - "voltage_avg": round(old_div(sum(voltages), 10.0), 2), - "current_min": 1.0, - "current_max": 1.0, - "current_avg": 1.0, - "energy": old_div(new_energy, meter.scalars.energy_scalar), - "true_power_inst": 1.0, - "true_power_avg": 1.0, - "apparent_power_avg": 1.0, - "power_factor_avg": 1.0, - "user_power_limit": 12000, - } - add_reading(data, update_meter_state=False) - return 1 - - def get_meters(self, all_meters): - left = len(all_meters) - parts = [] - while left: - if left <= 10: - parts.append(left) - break - part = random.uniform(0.5, 0.8) - value = int(left * part) - left -= value - parts.append(value) - - meters = all_meters[:] - meterss = [] - random.shuffle(meters) - for part in parts: - meterss.append(meters[:part]) - meters[:] = meters[part:] - return meterss - - -@reading.command("create-fake") -@click.option("-s", "--serial", default=None, help="Serial of meter") -@click.option("-c", "--cycle", type=int, default=0, help="Run in a cycle of n minutes") -@click.option("-e", "--energy", "energy_watts", type=int, default=60, help="Energy in watts") -@with_appcontext -def create_fake(serial, cycle=0, energy_watts=60): - """Add a random reading for this meter.""" - app = getUtility(IApplication) - app.setup_databases() - - if cycle: - generator = ReadingGenerator(energy_watts, cycle_length=cycle) - return generator.run_cycle_loop() - else: - generator = ReadingGenerator(energy_watts, cycle_length=15) - return generator.create_for_meter(serial) diff --git a/sparkmeter/reading/tests/test_readingcommand.py b/sparkmeter/reading/tests/test_readingcommand.py deleted file mode 100644 index 7347f61..0000000 --- a/sparkmeter/reading/tests/test_readingcommand.py +++ /dev/null @@ -1,132 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright © 2013-2018 SparkMeter, Inc. -# All Rights Reserved. -import contextlib -import datetime -import random -from builtins import map, range, str -from unittest import mock - -import pytest -from freezegun import freeze_time - -from sparkmeter.meter.meterstate import MeterState -from sparkmeter.reading.readingcommand import ReadingGenerator -from sparkmeter.reading.readingdomain import Reading -from sparkmeter.tests.base import SparkMeterTestCaseBase -from sparkmeter.tests.test_data_factory import EventFactory, MeterFactory - - -@contextlib.contextmanager -def deterministic_random(seed=0): - state = random.getstate() - random.seed(seed) - yield - random.setstate(state) - - -class ReadingCommandTest(SparkMeterTestCaseBase): - @freeze_time("2013-01-01T01:01:01") - def test_create_fake(self, cli, mocker): - event_create = mocker.patch("sparkmeter.event.eventdomain.Event.create") - event_create.return_value = EventFactory() - meter = MeterFactory() - self.session.commit() - - cli("reading", "create-fake", "-s", meter.serial) - - reading = Reading.query.one() - assert reading.meter == str(meter.code) - assert reading.heartbeat_start == datetime.datetime(2013, 1, 1, 1, 0, 0) - assert reading.heartbeat_end == datetime.datetime(2013, 1, 1, 1, 15, 0) - assert reading.state == MeterState.STATE_ON.id - assert reading.energy == 0.015 - - cli("reading", "create-fake", "-s", meter.serial) - - reading = ( - Reading.query.filter_by(meter=str(meter.code)) - .order_by(Reading.heartbeat_end.desc()) - .limit(1) - .scalar() - ) - assert reading.meter == str(meter.code) - assert reading.heartbeat_start == datetime.datetime(2013, 1, 1, 1, 15, 0) - assert reading.heartbeat_end == datetime.datetime(2013, 1, 1, 1, 30, 0) - assert reading.state == MeterState.STATE_ON.id - assert reading.energy == 0.03 - assert event_create.mock_calls == [ - mock.call("customer-low-balance", obj=mock.ANY), - mock.call("customer-low-balance", obj=mock.ANY), - ] - - def test_create_fake_error(self, cli): - result = cli("reading", "create-fake", "-s", "abracadabra") - assert result.exit_code == 1 - - def test_create_fake_cycle(self, cli): - with mock.patch.object(ReadingGenerator, "run_cycle_loop") as run_cycle_loop: - cli("reading", "create-fake", "-c", "15") - run_cycle_loop.assert_called_once() - - -class ReadingGeneratorTest(SparkMeterTestCaseBase): - def test_get_meters(self): - gen = ReadingGenerator(60, 15) - with deterministic_random(): - all_meters = list(range(500)) - parts = gen.get_meters(all_meters) - assert list(map(len, parts)), [376, 90, 21, 7 == 6] - - with deterministic_random(): - all_meters = list(range(70000)) - parts = gen.get_meters(all_meters) - assert len(parts) == 10 - - @mock.patch("time.sleep") - @freeze_time("2013-01-01T01:01:01") - def test_run_cycle_loop(self, sleep): - gen = ReadingGenerator(60, 15) - gen.heartbeat = mock.Mock() - MyException = type("MyException", (Exception,), {}) - gen.heartbeat.side_effect = [None, MyException] - with pytest.raises(MyException): - gen.run_cycle_loop() - - assert gen.heartbeat.mock_calls == [ - mock.call( - [], start=datetime.datetime(2013, 1, 1, 1, 0), end=datetime.datetime(2013, 1, 1, 1, 15) - ), - mock.call( - [], start=datetime.datetime(2013, 1, 1, 1, 15), end=datetime.datetime(2013, 1, 1, 1, 30) - ), - ] - - def test_heartbeat(self, mocker): - event_create = mocker.patch("sparkmeter.event.eventdomain.Event.create") - event_create.return_value = EventFactory() - sleep = mocker.patch("time.sleep") - m1 = MeterFactory() - self.session.commit() - - heartbeat_start = datetime.datetime(2013, 1, 1, 1, 15, 0) - heartbeat_end = datetime.datetime(2013, 1, 1, 1, 30, 0) - gen = ReadingGenerator(60, 15) - gen.heartbeat([[m1]], heartbeat_start, heartbeat_end) - - readings = Reading.get_all() - assert len(readings) == 1 - reading = readings[0] - assert reading.meter == str(m1.code) - assert reading.heartbeat_start == heartbeat_start - assert reading.heartbeat_end == heartbeat_end - - # Ensure that no duplicates are inserted - gen.heartbeat([[m1]], heartbeat_start, heartbeat_end) - readings = Reading.get_all() - assert len(readings) == 1 - - assert event_create.mock_calls == [ - mock.call("customer-low-balance", obj=mock.ANY), - ] - assert sleep.mock_calls == [mock.call(60)] * 30 diff --git a/sparkmeter/tests/test_managecommands.py b/sparkmeter/tests/test_managecommands.py index 21bba89..7e756d8 100644 --- a/sparkmeter/tests/test_managecommands.py +++ b/sparkmeter/tests/test_managecommands.py @@ -19,7 +19,6 @@ def test_cli_commands_registered(self, app): "initdb", "meter", "metering", - "reading", "resetdb", "salesaccount", "server", From f4adc351b102e220161b06cdceea89752abd4fcc Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:38:43 -0400 Subject: [PATCH 5/9] Remove the dead legacy IC meter scalars No source path scales readings any longer: slipstream is gone, and the fake-reading generator (its only remaining caller) was just removed. Provider readings arrive in engineering units and are persisted as-is. Drop the apply_meter_scalars parameter from add_reading / save_raw_reading and delete Meter.apply_scalars. Update the reading controller tests to supply realistic engineering-unit values directly instead of raw counts that were scaled down at persist time, and remove the apply_scalars unit test. --- sparkmeter/controller.py | 19 +- sparkmeter/meter/meterdomain.py | 24 --- sparkmeter/meter/tests/test_meterdomain.py | 27 --- sparkmeter/tests/test_controllers.py | 193 ++++++++------------- 4 files changed, 77 insertions(+), 186 deletions(-) diff --git a/sparkmeter/controller.py b/sparkmeter/controller.py index 4534407..22146d2 100644 --- a/sparkmeter/controller.py +++ b/sparkmeter/controller.py @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) -def add_reading(data, update_meter_state=True, apply_meter_scalars=True): +def add_reading(data, update_meter_state=True): """ Save a raw reading to the database and process it. @@ -41,34 +41,29 @@ def add_reading(data, update_meter_state=True, apply_meter_scalars=True): :type data: dict :param update_meter_state: enable sending of config packets :type update_meter_state: bool - :param apply_meter_scalars: apply legacy meter IC scalars before persisting - :type apply_meter_scalars: bool :return: reading id """ with session_scope() as session: - reading, meter = save_raw_reading(data, session, apply_meter_scalars=apply_meter_scalars) + reading, meter = save_raw_reading(data, session) with session_scope() as session: reading_id = process_reading(reading, meter, session, update_meter_state) return reading_id -def save_raw_reading(data, session, apply_meter_scalars=True): +def save_raw_reading(data, session): """ Save a raw reading to the database. This method is exposed to make existing unit testing possible and should otherwise not be used directly. - A raw reading is the the direct output of a slipstreamj response, it will - need to be scaled appropriately and mapped to the database. This method - commits the data to the database. + `data` is a dict of reading field values. This method commits the data to + the database. - :param data: incoming slipstreamj data + :param data: reading field values :type data: dict :param session: The database session to use :type Session: - :param apply_meter_scalars: apply legacy meter IC scalars before persisting - :type apply_meter_scalars: bool :return: (reading, meter) """ # First check if there are any existing readings with the same heartbeat @@ -84,8 +79,6 @@ def save_raw_reading(data, session, apply_meter_scalars=True): ) meter = session.query(Meter).filter_by(code=data["meter"]).one() - if apply_meter_scalars: - data = meter.apply_scalars(data) snapshot = Snapshot.get_or_create_meter_snapshot(code=str(data["meter"]), session=session) session.add(snapshot) reading = Reading( diff --git a/sparkmeter/meter/meterdomain.py b/sparkmeter/meter/meterdomain.py index 516bae1..0766cd7 100644 --- a/sparkmeter/meter/meterdomain.py +++ b/sparkmeter/meter/meterdomain.py @@ -1312,30 +1312,6 @@ def _maybe_send_set_config(self, state=None, load_limit=None, nominal_voltage=No ) return True - def apply_scalars(self, data): - """Apply the scalars from the metering IC.""" - scalars = self.scalars - scalar_map = { - "frequency": scalars.frequency_scalar, - "energy": scalars.energy_scalar, - "power_factor_avg": scalars.power_factor_scalar, - "voltage_min": scalars.voltage_scalar, - "voltage_max": scalars.voltage_scalar, - "voltage_avg": scalars.voltage_scalar, - "current_min": scalars.current_scalar, - "current_max": scalars.current_scalar, - "current_avg": scalars.current_scalar, - "true_power_inst": scalars.power_scalar, - "true_power_avg": scalars.power_scalar, - "apparent_power_avg": scalars.power_scalar, - "user_power_limit": scalars.power_scalar, - } - - for field, scalar in list(scalar_map.items()): - if field in data: - data[field] *= scalar - return data - @property def product_code(self): """ diff --git a/sparkmeter/meter/tests/test_meterdomain.py b/sparkmeter/meter/tests/test_meterdomain.py index 4de4036..bfd002d 100644 --- a/sparkmeter/meter/tests/test_meterdomain.py +++ b/sparkmeter/meter/tests/test_meterdomain.py @@ -598,33 +598,6 @@ def test_override_state_kept(self, config, mocker, send_set_config): assert disable_all_meters.mock_calls == [] assert event_create.mock_calls == [] - def test_apply_scalars(self): - m = MeterFactory(serial="SM15R-01-00000001") - m.model = MeterModels.get_by_serial(m.serial) - self.session.commit() - data = m.apply_scalars( - dict(frequency=1, energy=1, power_factor_avg=1, voltage_min=1, current_min=1, user_power_limit=1) - ) - assert data["frequency"] == 0.01 - assert data["energy"] == 0.00003125 - assert data["power_factor_avg"] == 0.001 - assert data["voltage_min"] == 0.01 - assert data["current_min"] == 0.002 - assert data["user_power_limit"] == 2.0 - - m = MeterFactory(serial="SM200E-01-00000001") - m.model = MeterModels.get_by_serial(m.serial) - self.session.commit() - data = m.apply_scalars( - dict(frequency=1, energy=1, power_factor_avg=1, voltage_min=1, current_min=1, user_power_limit=1) - ) - assert data["frequency"] == 0.01 - assert data["energy"] == 0.00003125 - assert data["power_factor_avg"] == 0.001 - assert data["voltage_min"] == 0.01 - assert data["current_min"] == 0.004 - assert data["user_power_limit"] == 4.0 - def test_product_code(self): m = MeterFactory(serial="SM15R-01-00000001") m.model = MeterModels.get_by_serial(m.serial) diff --git a/sparkmeter/tests/test_controllers.py b/sparkmeter/tests/test_controllers.py index 63c51c8..27e228f 100644 --- a/sparkmeter/tests/test_controllers.py +++ b/sparkmeter/tests/test_controllers.py @@ -349,24 +349,22 @@ def test_add_customer_reading(self, config, send_set_config): "heartbeat_start": datetime(2013, 1, 1, 1, 0, 1), "heartbeat_end": datetime(2013, 1, 1, 1, 1, 1), "frequency": 60.0, - "voltage_min": 120.0, - "voltage_max": 120.0, + "voltage_min": 118.0, + "voltage_max": 122.0, "voltage_avg": 120.0, - "current_min": 1.0, - "current_max": 1.0, - "current_avg": 1.0, - "true_power_inst": 1.0, - "true_power_avg": 1.0, - "apparent_power_avg": 1.0, - "power_factor_avg": 1.0, - "energy": meter.system_info.last_energy + 500000, + "current_min": 4.8, + "current_max": 5.2, + "current_avg": 5.0, + "true_power_inst": 600.0, + "true_power_avg": 600.0, + "apparent_power_avg": 632.0, + "power_factor_avg": 0.95, + "energy": 15.625375, "uptime": 100, "state": "on", - "user_power_limit": 12000, + "user_power_limit": 24000, } - scaled_voltage = reading_data["voltage_avg"] * 0.01 - reading_id = add_reading(reading_data) reading = Reading.get_by_id(reading_id) @@ -374,8 +372,7 @@ def test_add_customer_reading(self, config, send_set_config): wallet_id = meter.credit_wallet.id wallet = Wallet.get_by_id(wallet_id) - # make sure the scalars were applied - assert reading.voltage_avg == scaled_voltage + assert reading.voltage_avg == reading_data["voltage_avg"] assert wallet.value == 963.74625 assert snapshot.hash_ == "c07510ca03a4c5920efcb2e11c4b5171db842404700a7ecb6c266ad7997b949f" snapshot_payload = json.loads(snapshot.payload) @@ -410,29 +407,27 @@ def test_add_totalizer_reading(self, config, send_set_config): "heartbeat_start": datetime(2013, 1, 1, 1, 0, 1), "heartbeat_end": datetime(2013, 1, 1, 1, 1, 1), "frequency": 60.0, - "voltage_min": 1000, - "voltage_max": 1000, - "voltage_avg": 1000, - "current_min": 1.0, - "current_max": 1.0, - "current_avg": 1.0, - "true_power_inst": 1.0, - "true_power_avg": 1.0, - "apparent_power_avg": 1.0, - "power_factor_avg": 1.0, - "energy": 500000, + "voltage_min": 238.0, + "voltage_max": 242.0, + "voltage_avg": 240.0, + "current_min": 4.8, + "current_max": 5.2, + "current_avg": 5.0, + "true_power_inst": 600.0, + "true_power_avg": 600.0, + "apparent_power_avg": 632.0, + "power_factor_avg": 0.95, + "energy": 15.625, "uptime": 100, "state": "on", - "user_power_limit": 12000, + "user_power_limit": 24000, } - scaled_voltage = reading_data["voltage_avg"] * 0.01 reading_id = add_reading(reading_data) reading = Reading.get_by_id(reading_id) snapshot = Snapshot.get_by_id(reading.snapshot_id) - # make sure the scalars were applied - assert reading.voltage_avg == scaled_voltage + assert reading.voltage_avg == reading_data["voltage_avg"] # make sure kilowatt hours and period are calculated assert reading.kilowatt_hours == 5.625 assert reading.kilowatt_hours_period == 60 @@ -442,46 +437,6 @@ def test_add_totalizer_reading(self, config, send_set_config): assert "tariff" not in snapshot_payload assert send_set_config.mock_calls == [] - def test_add_reading_can_skip_legacy_scaling(self, config, send_set_config): - config["HEROKU"] = False - meter = MeterFactory(system_info__last_energy=0, credit_wallet__value=1000) - self.session.commit() - - reading_data = { - "kilowatt_hours": 0, - "kilowatt_hours_period": 0, - "meter": meter.code, - "heartbeat_start": datetime(2013, 1, 1, 1, 0, 1), - "heartbeat_end": datetime(2013, 1, 1, 1, 1, 1), - "frequency": 49.98, - "voltage_min": 240.1, - "voltage_max": 240.2, - "voltage_avg": 240.15, - "current_min": 0.098, - "current_max": 0.100, - "current_avg": 0.098, - "true_power_inst": 22.0, - "true_power_avg": 22.0, - "apparent_power_avg": 22.0, - "power_factor_avg": 0.996, - "energy": 6.195, - "uptime": 100, - "state": "on", - "user_power_limit": 1200, - } - - reading_id = add_reading(reading_data, apply_meter_scalars=False) - - reading = Reading.get_by_id(reading_id) - - assert reading.frequency == reading_data["frequency"] - assert reading.voltage_avg == reading_data["voltage_avg"] - assert reading.current_avg == reading_data["current_avg"] - assert reading.true_power_inst == reading_data["true_power_inst"] - assert reading.energy == reading_data["energy"] - assert reading.user_power_limit == reading_data["user_power_limit"] - assert send_set_config.mock_calls == [] - def test_add_duplicate_reading(self, config, send_set_config): config["HEROKU"] = False meter = MeterFactory(system_info__last_energy=12, credit_wallet__value=1000) @@ -497,20 +452,20 @@ def test_add_duplicate_reading(self, config, send_set_config): "heartbeat_start": datetime(2013, 1, 1, 1, 0, 1), "heartbeat_end": datetime(2013, 1, 1, 1, 1, 1), "frequency": 60.0, - "voltage_min": 120.0, - "voltage_max": 120.0, + "voltage_min": 118.0, + "voltage_max": 122.0, "voltage_avg": 120.0, - "current_min": 1.0, - "current_max": 1.0, - "current_avg": 1.0, - "true_power_inst": 1.0, - "true_power_avg": 1.0, - "apparent_power_avg": 1.0, - "power_factor_avg": 1.0, - "energy": meter.system_info.last_energy + 500000, + "current_min": 4.8, + "current_max": 5.2, + "current_avg": 5.0, + "true_power_inst": 600.0, + "true_power_avg": 600.0, + "apparent_power_avg": 632.0, + "power_factor_avg": 0.95, + "energy": 15.625375, "uptime": 100, "state": "on", - "user_power_limit": 12000, + "user_power_limit": 24000, } # First reading works correctly @@ -551,24 +506,22 @@ def test_add_reading_wallet_locking(self, config, send_set_config): "heartbeat_start": datetime(2013, 1, 1, 1, 0, 1), "heartbeat_end": datetime(2013, 1, 1, 1, 1, 1), "frequency": 60.0, - "voltage_min": 120.0, - "voltage_max": 120.0, + "voltage_min": 118.0, + "voltage_max": 122.0, "voltage_avg": 120.0, - "current_min": 1.0, - "current_max": 1.0, - "current_avg": 1.0, - "true_power_inst": 1.0, - "true_power_avg": 1.0, - "apparent_power_avg": 1.0, - "power_factor_avg": 1.0, - "energy": meter.system_info.last_energy + 500000, + "current_min": 4.8, + "current_max": 5.2, + "current_avg": 5.0, + "true_power_inst": 600.0, + "true_power_avg": 600.0, + "apparent_power_avg": 632.0, + "power_factor_avg": 0.95, + "energy": 15.625375, "uptime": 100, "state": "on", - "user_power_limit": 12000, + "user_power_limit": 24000, } - scaled_voltage = reading_data["voltage_avg"] * 0.01 - with LogCapture("sparkmeter.controller") as logger: reading_id = add_reading(reading_data) @@ -577,8 +530,7 @@ def test_add_reading_wallet_locking(self, config, send_set_config): wallet_id = meter.credit_wallet.id wallet = Wallet.get_by_id(wallet_id) - # make sure the scalars were applied - assert reading.voltage_avg == scaled_voltage + assert reading.voltage_avg == reading_data["voltage_avg"] assert wallet.value == 963.74625 logger.check( ( @@ -611,24 +563,22 @@ def test_add_reading_wallet_locking_disabled(self, config, send_set_config): "heartbeat_start": datetime(2013, 1, 1, 1, 0, 1), "heartbeat_end": datetime(2013, 1, 1, 1, 1, 1), "frequency": 60.0, - "voltage_min": 120.0, - "voltage_max": 120.0, + "voltage_min": 118.0, + "voltage_max": 122.0, "voltage_avg": 120.0, - "current_min": 1.0, - "current_max": 1.0, - "current_avg": 1.0, - "true_power_inst": 1.0, - "true_power_avg": 1.0, - "apparent_power_avg": 1.0, - "power_factor_avg": 1.0, - "energy": meter.system_info.last_energy + 500000, + "current_min": 4.8, + "current_max": 5.2, + "current_avg": 5.0, + "true_power_inst": 600.0, + "true_power_avg": 600.0, + "apparent_power_avg": 632.0, + "power_factor_avg": 0.95, + "energy": 15.625375, "uptime": 100, "state": "on", - "user_power_limit": 12000, + "user_power_limit": 24000, } - scaled_voltage = reading_data["voltage_avg"] * 0.01 - with LogCapture("sparkmeter.controller") as logger: reading_id = add_reading(reading_data) @@ -637,8 +587,7 @@ def test_add_reading_wallet_locking_disabled(self, config, send_set_config): wallet_id = meter.credit_wallet.id wallet = Wallet.get_by_id(wallet_id) - # make sure the scalars were applied - assert reading.voltage_avg == scaled_voltage + assert reading.voltage_avg == reading_data["voltage_avg"] assert wallet.value == 963.74625 logger.check() # verify no logs @@ -912,20 +861,20 @@ def _do_reading(meter, delay, session_manager, exceptions=None): "heartbeat_start": datetime(2013, 1, 1, 1, 0, 1), "heartbeat_end": datetime(2013, 1, 1, 1, 1, 1), "frequency": 60.0, - "voltage_min": 120.0, - "voltage_max": 120.0, + "voltage_min": 118.0, + "voltage_max": 122.0, "voltage_avg": 120.0, - "current_min": 1.0, - "current_max": 1.0, - "current_avg": 1.0, - "true_power_inst": 1.0, - "true_power_avg": 1.0, - "apparent_power_avg": 1.0, - "power_factor_avg": 1.0, - "energy": meter.system_info.last_energy + 500000, + "current_min": 4.8, + "current_max": 5.2, + "current_avg": 5.0, + "true_power_inst": 600.0, + "true_power_avg": 600.0, + "apparent_power_avg": 632.0, + "power_factor_avg": 0.95, + "energy": 15.625375, "uptime": 100, "state": "on", - "user_power_limit": 12000, + "user_power_limit": 24000, } read_sessions = [session_manager.create("rprocessor1"), session_manager.create("rprocessor2")] with mock.patch("sparkmeter.controller.session_scope") as session_mock: From 4f5ac38bfd5249d9d533ee036d46d84ddff4f1b5 Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:55:15 -0400 Subject: [PATCH 6/9] Replace running-app reflection with a registry; drop globals() publishing Add sparkmeter/metering/runtime_registry.py (set_running_app/get_running_app) as the single source for the running public ASGI app, replacing the sys.modules/__dict__ reflection that located it for the sync-to-async metering bridge. Remove all globals()[...] app-publishing from asgi.py: the registry holds the public app and a private _internal_app caches the internal app, both resolved via __getattr__. Inline the redundant _get_running_public_app wrapper into its one call site. --- sparkmeter/asgi.py | 34 +- sparkmeter/metering/lifespan.py | 25 +- sparkmeter/metering/runtime_registry.py | 29 + sparkmeter/metering/tests/test_lifespan.py | 543 +++++++++++++++++- .../metering/tests/test_runtime_registry.py | 36 ++ sparkmeter/tests/test_asgi.py | 79 ++- 6 files changed, 683 insertions(+), 63 deletions(-) create mode 100644 sparkmeter/metering/runtime_registry.py create mode 100644 sparkmeter/metering/tests/test_runtime_registry.py diff --git a/sparkmeter/asgi.py b/sparkmeter/asgi.py index 8827449..dc0e55b 100644 --- a/sparkmeter/asgi.py +++ b/sparkmeter/asgi.py @@ -32,6 +32,10 @@ from sparkmeter.app import SparkmeterApplication # noqa: E402 from sparkmeter.cli import register_cli_commands # noqa: E402 from sparkmeter.metering.lifespan import metering_lifespan # noqa: E402 +from sparkmeter.metering.runtime_registry import ( # noqa: E402 + get_running_app, + set_running_app, +) from sparkmeter.periodic import periodic_lifespan # noqa: E402 # When this file is launched via `python -m sparkmeter.asgi`, Python executes it @@ -88,7 +92,9 @@ async def health(): # which is bound to a per-request thread-local that lifespan threads # never enter. api.state.flask_app = flask_app - globals()["public_app"] = api + # Publish to the registry so the sync→async metering bridge can reach the + # running app without reflecting over sys.modules. + set_running_app(api) # Mount Flask under "/" — FastAPI's own routes take precedence; everything # else falls through to the WSGI app. api.mount("/", WSGIMiddleware(flask_app)) @@ -109,7 +115,6 @@ async def share_metering_state(request, call_next): request.app.state.metering = getattr(public_app.state, "metering", None) return await call_next(request) - globals()["internal_app"] = api return api @@ -118,19 +123,26 @@ async def share_metering_state(request, call_next): # a live DB connection). ASGI servers reference these by attribute, which fires # the factory at first access. Tests / introspection that just want to import # the module don't pay that cost. +# +# The public app's single cache is the registry (`get_running_app`); the +# internal app has no cross-module consumer, so it caches here in a private +# module-level variable. Neither writes a string-keyed module global. +_internal_app = None + + +def _ensure_public_app(): + app = get_running_app() + return app if app is not None else create_public_app() def __getattr__(name): # PEP 562 module-level __getattr__ + global _internal_app if name == "public_app": - app = create_public_app() - globals()["public_app"] = app - return app + return _ensure_public_app() if name == "internal_app": - public = globals().get("public_app") or create_public_app() - globals()["public_app"] = public - app = create_internal_app(public) - globals()["internal_app"] = app - return app + if _internal_app is None: + _internal_app = create_internal_app(_ensure_public_app()) + return _internal_app raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -141,8 +153,6 @@ async def _serve_both() -> None: public = create_public_app() internal = create_internal_app(public) - globals()["public_app"] = public - globals()["internal_app"] = internal public_cfg = Config() public_cfg.bind = [os.environ.get("PUBLIC_BIND", "0.0.0.0:5000")] diff --git a/sparkmeter/metering/lifespan.py b/sparkmeter/metering/lifespan.py index 2de590d..78199f3 100644 --- a/sparkmeter/metering/lifespan.py +++ b/sparkmeter/metering/lifespan.py @@ -14,7 +14,6 @@ import asyncio import logging import os -import sys import uuid from collections.abc import AsyncIterator from concurrent.futures import TimeoutError as FutureTimeoutError @@ -25,6 +24,7 @@ from sparkmeter.metering.provider_config import configured_provider_url from sparkmeter.metering.runtime_client import build_command_client, build_event_client +from sparkmeter.metering.runtime_registry import get_running_app logger = logging.getLogger(__name__) _GATEWAY_RECOVERY_POLL_SECONDS = 5.0 @@ -289,7 +289,7 @@ def activate_metering_runtime_in_process( ) -> tuple[bool, str | None]: """Try to activate live metering inside the already-running ASGI process.""" try: - public_app = _get_running_public_app() + public_app = get_running_app() if public_app is None: return False, "public app is not available" @@ -310,27 +310,6 @@ def activate_metering_runtime_in_process( return False, str(exc) -def _get_running_public_app(): - """Return the already-running public ASGI app without importing a fresh module.""" - candidates = [] - - asgi_module = sys.modules.get("sparkmeter.asgi") - if asgi_module is not None: - candidates.append(asgi_module) - - main_module = sys.modules.get("__main__") - main_spec_name = getattr(getattr(main_module, "__spec__", None), "name", None) - if main_module is not None and main_spec_name == "sparkmeter.asgi": - candidates.append(main_module) - - for module in candidates: - public_app = getattr(module, "__dict__", {}).get("public_app") - if public_app is not None: - return public_app - - return None - - @asynccontextmanager async def metering_lifespan(app: FastAPI) -> AsyncIterator[None]: """Owns the metering-provider connection on ground; no-op on cloud.""" diff --git a/sparkmeter/metering/runtime_registry.py b/sparkmeter/metering/runtime_registry.py new file mode 100644 index 0000000..26b41a8 --- /dev/null +++ b/sparkmeter/metering/runtime_registry.py @@ -0,0 +1,29 @@ +"""Process-global handle on the running public ASGI app. + +The sync→async metering bridge (`activate_metering_runtime_in_process`) runs +inside a synchronous Flask request handler with no request-scoped reference to +the ASGI app it needs to reach. `create_public_app` publishes the app here via +`set_running_app`; the bridge reads it back via `get_running_app`. This is an +explicit typed accessor in place of reflecting over `sys.modules`. + +Dependency-free by design: it imports only `typing`, so it can be pulled in from +anywhere without dragging in FastAPI at import time. +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi import FastAPI + +_running_public_app = None + + +def set_running_app(app: "FastAPI | None") -> None: + """Publish the running public ASGI app for the sync bridge to reach.""" + global _running_public_app + _running_public_app = app + + +def get_running_app() -> "FastAPI | None": + """Return the running public ASGI app, or `None` if none is published.""" + return _running_public_app diff --git a/sparkmeter/metering/tests/test_lifespan.py b/sparkmeter/metering/tests/test_lifespan.py index db332c7..ad50a39 100644 --- a/sparkmeter/metering/tests/test_lifespan.py +++ b/sparkmeter/metering/tests/test_lifespan.py @@ -2,13 +2,13 @@ import asyncio import contextlib -import sys +import logging from concurrent.futures import TimeoutError as FutureTimeoutError from types import SimpleNamespace import pytest -from sparkmeter.metering import lifespan +from sparkmeter.metering import lifespan, runtime_registry class _FakeFlaskApp: @@ -162,30 +162,11 @@ def removed_provider(*, default="", flask_app=None): class TestInProcessActivation: - def test_get_running_public_app_uses_main_module_alias(self, monkeypatch): - app = SimpleNamespace(state=SimpleNamespace(main_loop=object())) - fake_main = SimpleNamespace( - __spec__=SimpleNamespace(name="sparkmeter.asgi"), - public_app=app, - ) - - monkeypatch.delitem(sys.modules, "sparkmeter.asgi", raising=False) - monkeypatch.setitem(sys.modules, "__main__", fake_main) - - assert lifespan._get_running_public_app() is app - def test_activate_metering_runtime_uses_existing_running_app(self, monkeypatch): loop = object() app = SimpleNamespace(state=SimpleNamespace(main_loop=loop)) - fake_main = SimpleNamespace( - __spec__=SimpleNamespace(name="sparkmeter.asgi"), - public_app=app, - ) seen = {} - monkeypatch.delitem(sys.modules, "sparkmeter.asgi", raising=False) - monkeypatch.setitem(sys.modules, "__main__", fake_main) - async def fake_ensure(public_app, skip_provider_init=False): seen["app"] = public_app seen["skip_provider_init"] = skip_provider_init @@ -205,7 +186,12 @@ def result(self, timeout=None): monkeypatch.setattr(lifespan, "ensure_metering_runtime", fake_ensure) monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", fake_run_coroutine_threadsafe) - activated, error = lifespan.activate_metering_runtime_in_process(timeout=3.0) + saved = runtime_registry.get_running_app() + try: + runtime_registry.set_running_app(app) + activated, error = lifespan.activate_metering_runtime_in_process(timeout=3.0) + finally: + runtime_registry.set_running_app(saved) assert activated is True assert error is None @@ -214,14 +200,14 @@ def result(self, timeout=None): assert seen["timeout"] == 3.0 def test_activate_reports_missing_public_app(self, monkeypatch): - monkeypatch.setattr(lifespan, "_get_running_public_app", lambda: None) + monkeypatch.setattr(lifespan, "get_running_app", lambda: None) activated, error = lifespan.activate_metering_runtime_in_process() assert activated is False assert error == "public app is not available" def test_activate_reports_missing_main_loop(self, monkeypatch): app = SimpleNamespace(state=SimpleNamespace(main_loop=None)) - monkeypatch.setattr(lifespan, "_get_running_public_app", lambda: app) + monkeypatch.setattr(lifespan, "get_running_app", lambda: app) activated, error = lifespan.activate_metering_runtime_in_process() assert activated is False assert error == "main event loop is not available" @@ -886,7 +872,7 @@ async def fake_reconcile(app): class TestActivateMeteringRuntimeErrors: def test_reports_timeout(self, monkeypatch): app = SimpleNamespace(state=SimpleNamespace(main_loop=object())) - monkeypatch.setattr(lifespan, "_get_running_public_app", lambda: app) + monkeypatch.setattr(lifespan, "get_running_app", lambda: app) async def fake_ensure(public_app, skip_provider_init=False): del public_app, skip_provider_init @@ -913,7 +899,7 @@ def fake_run_coroutine_threadsafe(coro, running_loop): def test_reports_generic_error(self, monkeypatch): app = SimpleNamespace(state=SimpleNamespace(main_loop=object())) - monkeypatch.setattr(lifespan, "_get_running_public_app", lambda: app) + monkeypatch.setattr(lifespan, "get_running_app", lambda: app) async def fake_ensure(public_app, skip_provider_init=False): del public_app, skip_provider_init @@ -937,3 +923,508 @@ def fake_run_coroutine_threadsafe(coro, running_loop): assert activated is False assert error == "boom" + + +class TestIsGround: + def test_is_ground_reads_config_flag(self, monkeypatch): + # _is_ground mirrors config.is_ground() when the config loads. + monkeypatch.setattr("sparkmeter.config.configdict.config", SimpleNamespace(is_ground=lambda: True)) + assert lifespan._is_ground() is True + monkeypatch.setattr("sparkmeter.config.configdict.config", SimpleNamespace(is_ground=lambda: False)) + assert lifespan._is_ground() is False + + def test_is_ground_falls_back_to_env(self, monkeypatch): + def boom(): + raise RuntimeError("config unavailable") + + monkeypatch.setattr("sparkmeter.config.configdict.config", SimpleNamespace(is_ground=boom)) + + # When config.is_ground() raises, the SPARKMETER_MODE env var decides. + monkeypatch.setenv("SPARKMETER_MODE", "ground") + assert lifespan._is_ground() is True + monkeypatch.setenv("SPARKMETER_MODE", "cloud") + assert lifespan._is_ground() is False + + +class TestShutdownSkipsMissingTaskSlot: + @pytest.mark.asyncio + async def test_shutdown_skips_missing_task_slot(self): + # Only the dispatcher task slot is populated; the sse slot is None. + # The `if task is None: continue` guard must skip the empty slot while + # still cancelling the present task and closing both clients. + async def _long(): + await asyncio.sleep(3600) + + dispatcher = asyncio.create_task(_long()) + await asyncio.sleep(0) + + closed = [] + + class _Client: + def __init__(self, name): + self.name = name + + async def close(self): + closed.append(self.name) + + app = SimpleNamespace( + state=SimpleNamespace( + metering_dispatcher_task=dispatcher, + metering_sse_task=None, + metering_event_client=_Client("event"), + metering=_Client("command"), + ) + ) + + await lifespan.shutdown_metering_runtime(app) + + assert dispatcher.cancelled() + assert sorted(closed) == ["command", "event"] + assert app.state.metering is None + assert app.state.metering_sse_task is None + + +class _FakeRuntimeClient: + """Command/event client stand-in with an async close() and transport name.""" + + def __init__(self, name, transport_name): + self.name = name + self.transport_name = transport_name + self.closed = False + + async def close(self): + self.closed = True + + +def _make_ground_app(): + return SimpleNamespace(state=SimpleNamespace(flask_app=_FakeFlaskApp(), metering=None)) + + +def _install_ensure_harness( + monkeypatch, + *, + provider_url="http://drv", + signature=("id", "http://drv", "http", True), +): + """Wire up ensure_metering_runtime with real create_task but stubbed coroutines. + + Returns a `records` dict capturing collaborator arguments so tests can assert + the runtime built what it should. + """ + records = { + "reconcile": [], + "register_loop": [], + "command_clients": [], + "event_clients": [], + } + + monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + monkeypatch.setattr(lifespan, "_enabled_provider_signature", lambda flask_app: signature) + monkeypatch.setattr( + lifespan, "configured_provider_url", lambda *, default="", flask_app=None: provider_url + ) + + def fake_build_command(provider, client_id, provider_details=None): + records["command_clients"].append( + {"provider": provider, "client_id": client_id, "provider_details": provider_details} + ) + return _FakeRuntimeClient("command", "fake-cmd") + + def fake_build_event(provider, client_id, provider_details=None): + records["event_clients"].append( + {"provider": provider, "client_id": client_id, "provider_details": provider_details} + ) + return _FakeRuntimeClient("event", "fake-evt") + + monkeypatch.setattr(lifespan, "build_command_client", fake_build_command) + monkeypatch.setattr(lifespan, "build_event_client", fake_build_event) + + from sparkmeter.metering import dispatch + + def fake_register(loop, queue): + records["register_loop"].append((loop, queue)) + + async def fake_dispatcher(client, queue, commands_allowed=None): + return + + monkeypatch.setattr(dispatch, "register_loop", fake_register) + monkeypatch.setattr(dispatch, "command_dispatcher", fake_dispatcher) + + async def fake_sse(app, event_client, client_id): + return + + monkeypatch.setattr(lifespan, "_run_sse_consumer", fake_sse) + + async def fake_reconcile(app, skip_provider_init=False): + records["reconcile"].append(skip_provider_init) + + monkeypatch.setattr(lifespan, "_run_provider_reconcile", fake_reconcile) + + return records + + +async def _drain_runtime_tasks(app): + """Cancel+await the spawned stub tasks so no pending-task warnings leak.""" + for task in ( + getattr(app.state, "metering_dispatcher_task", None), + getattr(app.state, "metering_sse_task", None), + ): + if task is not None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + +class TestEnsureMeteringRuntimeStartup: + @pytest.mark.asyncio + async def test_ensure_config_change_shuts_down_then_early_returns(self, monkeypatch): + # An existing client with a *different* signature forces a restart: + # shutdown_metering_runtime is awaited, then the empty provider URL + # early-returns False with metering cleared. + monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + monkeypatch.setattr( + lifespan, "_enabled_provider_signature", lambda flask_app: ("new", "http://new", "http", True) + ) + monkeypatch.setattr(lifespan, "configured_provider_url", lambda *, default="", flask_app=None: "") + + shutdowns = [] + + async def fake_shutdown(app): + shutdowns.append(1) + app.state.metering = None + + monkeypatch.setattr(lifespan, "shutdown_metering_runtime", fake_shutdown) + + app = SimpleNamespace( + state=SimpleNamespace( + flask_app=_FakeFlaskApp(), + metering=object(), + metering_provider_signature=("old", "http://old", "http", True), + ) + ) + + result = await lifespan.ensure_metering_runtime(app, skip_provider_init=True) + + assert result is False + assert shutdowns == [1] + assert app.state.metering is None + + @pytest.mark.asyncio + async def test_ensure_init_pass_logs_per_result_branch(self, monkeypatch, caplog): + monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + monkeypatch.setattr( + lifespan, "_enabled_provider_signature", lambda flask_app: ("x", "y", "http", True) + ) + + results = [ + {"success": True, "provider": {"name": "Alpha"}}, + {"attempted": True, "reason": "conn refused", "provider": {"base_url": "http://b"}}, + {"provider": {}}, + ] + monkeypatch.setattr( + lifespan, + "_initialize_configured_providers_with_app_context", + lambda flask_app, timeout: results, + ) + # Empty provider URL early-returns after the init-pass logging. + monkeypatch.setattr(lifespan, "configured_provider_url", lambda *, default="", flask_app=None: "") + + app = SimpleNamespace(state=SimpleNamespace(flask_app=_FakeFlaskApp(), metering=None)) + + with caplog.at_level(logging.INFO): + result = await lifespan.ensure_metering_runtime(app) + + assert result is False + by_message = {r.getMessage(): r.levelname for r in caplog.records} + # success -> INFO, name from provider["name"] + assert by_message["metering startup init succeeded for Alpha"] == "INFO" + # attempted failure -> WARNING, name falls back to base_url, reason included + assert by_message["metering startup init failed for http://b: conn refused"] == "WARNING" + # not attempted -> INFO skipped, name falls back to "meter driver", default reason + assert by_message["metering startup init skipped for meter driver: not configured"] == "INFO" + + @pytest.mark.asyncio + async def test_ensure_init_pass_exception_is_swallowed(self, monkeypatch, caplog): + monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + monkeypatch.setattr( + lifespan, "_enabled_provider_signature", lambda flask_app: ("x", "y", "http", True) + ) + + def boom(flask_app, timeout): + raise RuntimeError("init boom") + + monkeypatch.setattr(lifespan, "_initialize_configured_providers_with_app_context", boom) + monkeypatch.setattr(lifespan, "configured_provider_url", lambda *, default="", flask_app=None: "") + + app = SimpleNamespace(state=SimpleNamespace(flask_app=_FakeFlaskApp(), metering=None)) + + with caplog.at_level(logging.INFO): + # The init-pass exception must NOT propagate; startup continues and + # then early-returns on the empty provider URL. + result = await lifespan.ensure_metering_runtime(app) + + assert result is False + assert "metering startup init pass failed" in caplog.text + + @pytest.mark.asyncio + async def test_ensure_provider_resolution_falls_back_to_http(self, monkeypatch, caplog): + records = _install_ensure_harness(monkeypatch, provider_url="http://drv") + + def boom(): + raise RuntimeError("interface lookup down") + + monkeypatch.setattr("sparkmeter.config.provider_settings.get_enabled_provider", boom) + + app = _make_ground_app() + with caplog.at_level(logging.INFO): + result = await lifespan.ensure_metering_runtime(app, skip_provider_init=True) + + assert result is True + # When provider resolution fails, the command client is built from the + # synthetic HTTP-fallback provider record. + assert records["command_clients"][0]["provider"] == { + "base_url": "http://drv", + "selected_interface": "http", + } + assert "falling back to HTTP" in caplog.text + + await lifespan.shutdown_metering_runtime(app) + + @pytest.mark.asyncio + async def test_ensure_happy_path_builds_runtime(self, monkeypatch): + records = _install_ensure_harness(monkeypatch) + provider = { + "id": "id", + "base_url": "http://drv", + "selected_interface": "http", + "enabled": True, + } + monkeypatch.setattr("sparkmeter.config.provider_settings.get_enabled_provider", lambda: provider) + monkeypatch.setattr( + "sparkmeter.config.provider_settings.get_live_interface_details", + lambda base_url, selected_interface=None: {"iface": selected_interface}, + ) + + app = _make_ground_app() + loop = asyncio.get_running_loop() + + result = await lifespan.ensure_metering_runtime(app, skip_provider_init=True) + + assert result is True + # register_loop received the running loop and the created command queue. + assert len(records["register_loop"]) == 1 + reg_loop, reg_queue = records["register_loop"][0] + assert reg_loop is loop + assert reg_queue is app.state.metering_command_queue + assert isinstance(reg_queue, asyncio.Queue) + # app.state populated with the live runtime handles. + assert app.state.metering.name == "command" + assert app.state.metering_event_client.name == "event" + assert app.state.metering_client_id.startswith("webapp-") + assert app.state.metering_provider_signature == ("id", "http://drv", "http", True) + assert isinstance(app.state.metering_dispatcher_task, asyncio.Task) + assert isinstance(app.state.metering_sse_task, asyncio.Task) + assert app.state.metering_gateway_state["commands_allowed"].is_set() is True + # The real provider record and its interface details reached build_command_client. + assert records["command_clients"][0]["provider"] is provider + assert records["command_clients"][0]["provider_details"] == {"iface": "http"} + + await lifespan.shutdown_metering_runtime(app) + + @pytest.mark.asyncio + async def test_ensure_reconcile_success_passes_skip_flag(self, monkeypatch): + records = _install_ensure_harness(monkeypatch) + provider = { + "id": "id", + "base_url": "http://drv", + "selected_interface": "http", + "enabled": True, + } + # Init pass runs (skip_provider_init=False) and reports success for the + # provider whose signature matches the desired one, so the reconcile is + # invoked with skip_provider_init=True. + monkeypatch.setattr( + lifespan, + "_initialize_configured_providers_with_app_context", + lambda flask_app, timeout: [{"success": True, "provider": provider}], + ) + monkeypatch.setattr("sparkmeter.config.provider_settings.get_enabled_provider", lambda: provider) + monkeypatch.setattr( + "sparkmeter.config.provider_settings.get_live_interface_details", + lambda base_url, selected_interface=None: None, + ) + + app = _make_ground_app() + result = await lifespan.ensure_metering_runtime(app, skip_provider_init=False) + + assert result is True + assert records["reconcile"] == [True] + + await lifespan.shutdown_metering_runtime(app) + + @pytest.mark.asyncio + async def test_ensure_reconcile_failure_aborts_and_reraises(self, monkeypatch, caplog): + _install_ensure_harness(monkeypatch) + provider = { + "id": "id", + "base_url": "http://drv", + "selected_interface": "http", + "enabled": True, + } + monkeypatch.setattr("sparkmeter.config.provider_settings.get_enabled_provider", lambda: provider) + monkeypatch.setattr( + "sparkmeter.config.provider_settings.get_live_interface_details", + lambda base_url, selected_interface=None: None, + ) + + async def boom_reconcile(app, skip_provider_init=False): + raise RuntimeError("reconcile boom") + + monkeypatch.setattr(lifespan, "_run_provider_reconcile", boom_reconcile) + + shutdowns = [] + + async def fake_shutdown(app): + shutdowns.append(1) + + monkeypatch.setattr(lifespan, "shutdown_metering_runtime", fake_shutdown) + + app = _make_ground_app() + with caplog.at_level(logging.INFO): + with pytest.raises(RuntimeError, match="reconcile boom"): + await lifespan.ensure_metering_runtime(app, skip_provider_init=True) + + assert shutdowns == [1] + assert "aborting startup" in caplog.text + + await _drain_runtime_tasks(app) + + +class TestSseConsumerCancelled: + @pytest.mark.asyncio + async def test_sse_consumer_cancelled_in_stream_propagates(self, monkeypatch): + app, gateway_state = _build_app() + + class _CancelClient: + async def stream_events(self, client_id): + raise asyncio.CancelledError + yield # pragma: no cover - makes this an async generator + + async def noop_pending(app): + del app + + monkeypatch.setattr(lifespan, "_run_pending_provider_restart", noop_pending) + monkeypatch.setattr("sparkmeter.metering.events.build_handlers", lambda app: []) + + sleeper = _SleepController() + monkeypatch.setattr(asyncio, "sleep", sleeper) + + with pytest.raises(asyncio.CancelledError): + await lifespan._run_sse_consumer(app, _CancelClient(), "cid") + + # CancelledError re-raises without triggering the generic-Exception + # side effects: no restart flag, no backoff sleep. + assert gateway_state["needs_full_restart"] is False + assert sleeper.calls == 0 + + +class TestObserveGatewayStatusGuards: + @pytest.mark.asyncio + async def test_observe_gateway_status_noop_without_state(self): + # A gateway_status event with no gateway_state must return without + # touching (None-dereferencing) anything. + app = SimpleNamespace(state=SimpleNamespace(metering_gateway_state=None)) + await lifespan._observe_gateway_status(app, {"type": "gateway_status", "data": {"connected": False}}) + assert app.state.metering_gateway_state is None + + @pytest.mark.asyncio + async def test_observe_disconnect_while_paused_is_noop(self): + # Already paused + a fresh disconnect event: the early return prevents a + # second recovery task from being spawned. + app, gateway_state = _build_app(gateway_connected=False, gateway_paused=True) + gateway_state["gateway_recovery_task"] = None + + await lifespan._observe_gateway_status(app, {"type": "gateway_status", "data": {"connected": False}}) + + assert gateway_state["gateway_paused"] is True + assert gateway_state["gateway_recovery_task"] is None + assert gateway_state["gateway_connected"] is False + + +class TestRecoverProviderGuards: + @pytest.mark.asyncio + async def test_recover_noop_without_state(self, monkeypatch): + waits = [] + + async def fake_wait(app): + waits.append(1) + + monkeypatch.setattr(lifespan, "_wait_for_gateway_online", fake_wait) + + app = SimpleNamespace(state=SimpleNamespace(metering_gateway_state=None)) + await lifespan._recover_provider_after_gateway_loss(app) + + # Missing state -> early return, the polling collaborator is never awaited. + assert waits == [] + + @pytest.mark.asyncio + async def test_recover_reconcile_cancelled_reraises(self, monkeypatch): + async def fake_wait(app): + del app + + async def cancel_reconcile(app): + raise asyncio.CancelledError + + sleeper = _SleepController() + monkeypatch.setattr(lifespan, "_wait_for_gateway_online", fake_wait) + monkeypatch.setattr(lifespan, "_run_provider_reconcile", cancel_reconcile) + monkeypatch.setattr(asyncio, "sleep", sleeper) + + app, gateway_state = _build_app(gateway_connected=False, gateway_paused=True) + gateway_state["gateway_recovery_task"] = object() + + with pytest.raises(asyncio.CancelledError): + await lifespan._recover_provider_after_gateway_loss(app) + + # CancelledError propagates without the retry backoff sleep firing, and + # the finally-block still clears the recovery-task handle. + assert sleeper.calls == 0 + assert gateway_state["gateway_recovery_task"] is None + + +class TestWaitForGatewayOnlineCancelled: + @pytest.mark.asyncio + async def test_wait_for_gateway_online_cancelled_reraises(self, monkeypatch): + monkeypatch.setattr( + lifespan, "configured_provider_url", lambda *, default="", flask_app=None: "http://drv" + ) + + sleeper = _SleepController() + monkeypatch.setattr(asyncio, "sleep", sleeper) + + class _AsyncClient: + def __init__(self, timeout=None): + del timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, url): + del url + raise asyncio.CancelledError + + monkeypatch.setattr(lifespan.httpx, "AsyncClient", _AsyncClient) + app = SimpleNamespace(state=SimpleNamespace(flask_app=object())) + + with pytest.raises(asyncio.CancelledError): + await lifespan._wait_for_gateway_online(app) + + # CancelledError propagates without the retry sleep firing. + assert sleeper.calls == 0 diff --git a/sparkmeter/metering/tests/test_runtime_registry.py b/sparkmeter/metering/tests/test_runtime_registry.py new file mode 100644 index 0000000..94f8a0b --- /dev/null +++ b/sparkmeter/metering/tests/test_runtime_registry.py @@ -0,0 +1,36 @@ +"""Tests for the running-public-app registry.""" + +from types import SimpleNamespace + +import pytest + +from sparkmeter.metering import runtime_registry + + +@pytest.fixture +def clean_registry(): + """Save and restore the process-global registry via the public API.""" + saved = runtime_registry.get_running_app() + try: + yield + finally: + runtime_registry.set_running_app(saved) + + +def test_set_none_clears_the_registry(clean_registry): + runtime_registry.set_running_app(None) + assert runtime_registry.get_running_app() is None + + +def test_publish_then_get_returns_same_object(clean_registry): + app = SimpleNamespace(name="public") + runtime_registry.set_running_app(app) + assert runtime_registry.get_running_app() is app + + +def test_republish_latest_wins(clean_registry): + first = SimpleNamespace(name="first") + second = SimpleNamespace(name="second") + runtime_registry.set_running_app(first) + runtime_registry.set_running_app(second) + assert runtime_registry.get_running_app() is second diff --git a/sparkmeter/tests/test_asgi.py b/sparkmeter/tests/test_asgi.py index ca972ac..2fc6455 100644 --- a/sparkmeter/tests/test_asgi.py +++ b/sparkmeter/tests/test_asgi.py @@ -8,6 +8,7 @@ import pytest from sparkmeter import asgi +from sparkmeter.metering import runtime_registry def test_create_internal_app_shares_metering_state_via_middleware(): @@ -17,8 +18,6 @@ def test_create_internal_app_shares_metering_state_via_middleware(): internal = asgi.create_internal_app(public) assert internal.title == "sparkmeter-internal" - # The factory publishes the app on the module so ASGI servers can reach it. - assert asgi.internal_app is internal client = TestClient(internal) # The middleware runs on every request (a 404 still exercises it) and copies @@ -32,6 +31,82 @@ def test_create_internal_app_shares_metering_state_via_middleware(): assert internal.state.metering == "client-B" +def test_create_public_app_publishes_itself_to_the_registry(monkeypatch): + # Skip the heavy Flask DB bootstrap and the real WSGI wrapper so + # construction is cheap; the seam under test is unrelated to either. + monkeypatch.setattr(asgi, "create_flask_app", lambda: SimpleNamespace()) + monkeypatch.setattr(asgi, "WSGIMiddleware", lambda app: app) + + # Save/restore the process-global registry so this test leaks no state. + saved = runtime_registry.get_running_app() + try: + app = asgi.create_public_app() + # The production seam: create_public_app must publish the running app + # to the registry. If `set_running_app(api)` were removed from the + # factory, get_running_app() would still hold `saved` (a different + # object), and this identity assertion would fail. + assert runtime_registry.get_running_app() is app + # The registry is the single source: the factory must NOT write a + # `public_app` module global (the reflective self-mutation we removed). + assert "public_app" not in asgi.__dict__ + finally: + runtime_registry.set_running_app(saved) + + +def test_public_app_attribute_resolves_from_registry_and_caches(monkeypatch): + # Cheap construction: skip the Flask DB bootstrap and the real WSGI wrapper. + monkeypatch.setattr(asgi, "create_flask_app", lambda: SimpleNamespace()) + monkeypatch.setattr(asgi, "WSGIMiddleware", lambda app: app) + + saved = runtime_registry.get_running_app() + saved_internal = asgi._internal_app + try: + runtime_registry.set_running_app(None) + asgi._internal_app = None + + # First access builds via create_public_app (which publishes to the + # registry); a second access reads the same object back — no rebuild. + first = asgi.public_app + assert runtime_registry.get_running_app() is first + second = asgi.public_app + assert second is first + finally: + runtime_registry.set_running_app(saved) + asgi._internal_app = saved_internal + + +def test_internal_app_attribute_builds_once_and_caches(monkeypatch): + monkeypatch.setattr(asgi, "create_flask_app", lambda: SimpleNamespace()) + monkeypatch.setattr(asgi, "WSGIMiddleware", lambda app: app) + + saved = runtime_registry.get_running_app() + saved_internal = asgi._internal_app + try: + runtime_registry.set_running_app(None) + asgi._internal_app = None + + # First access builds the internal app once and caches it in the + # private module var; a second access returns the identical object. + first = asgi.internal_app + second = asgi.internal_app + assert second is first + assert asgi._internal_app is first + finally: + runtime_registry.set_running_app(saved) + asgi._internal_app = saved_internal + + +def test_unknown_attribute_raises_attribute_error(): + saved = runtime_registry.get_running_app() + saved_internal = asgi._internal_app + try: + with pytest.raises(AttributeError): + asgi.does_not_exist + finally: + runtime_registry.set_running_app(saved) + asgi._internal_app = saved_internal + + @pytest.mark.asyncio async def test_app_lifespan_composes_inner_lifespans(monkeypatch): entered = [] From b57e090e4dcf3515cad3362232dbb960e0474c8c Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:23:18 -0400 Subject: [PATCH 7/9] Guard metering lifespan on config.is_cloud() directly Drop the _is_ground() wrapper and its SPARKMETER_MODE env fallback, now that main derives ground/cloud from the HEROKU config key. The two lifespan guards call config.is_cloud() directly, and the lifespan tests drive the branch by setting config["HEROKU"] rather than stubbing the removed helper. --- sparkmeter/metering/lifespan.py | 18 ++---- sparkmeter/metering/tests/test_lifespan.py | 65 ++++++++-------------- 2 files changed, 29 insertions(+), 54 deletions(-) diff --git a/sparkmeter/metering/lifespan.py b/sparkmeter/metering/lifespan.py index 78199f3..2c72e54 100644 --- a/sparkmeter/metering/lifespan.py +++ b/sparkmeter/metering/lifespan.py @@ -34,16 +34,6 @@ class _ProviderRemovedDuringRecovery(RuntimeError): """Raised when the configured driver disappears during gateway recovery.""" -def _is_ground() -> bool: - """Whether this deployment should drive a meter network.""" - try: - from sparkmeter.config.configdict import config - - return config.is_ground() - except Exception: # noqa: BLE001 - return os.environ.get("SPARKMETER_MODE", "ground") == "ground" - - def _metering_enabled() -> bool: """Whether metering-provider startup should run for this process.""" try: @@ -144,7 +134,9 @@ async def ensure_metering_runtime( skip_provider_init: bool = False, ) -> bool: """Ensure live metering is active for the currently configured provider.""" - if not _is_ground(): + from sparkmeter.config.configdict import config + + if config.is_cloud(): app.state.metering = None return False @@ -313,7 +305,9 @@ def activate_metering_runtime_in_process( @asynccontextmanager async def metering_lifespan(app: FastAPI) -> AsyncIterator[None]: """Owns the metering-provider connection on ground; no-op on cloud.""" - if not _is_ground(): + from sparkmeter.config.configdict import config + + if config.is_cloud(): app.state.metering = None yield return diff --git a/sparkmeter/metering/tests/test_lifespan.py b/sparkmeter/metering/tests/test_lifespan.py index ad50a39..4389cdc 100644 --- a/sparkmeter/metering/tests/test_lifespan.py +++ b/sparkmeter/metering/tests/test_lifespan.py @@ -483,16 +483,16 @@ async def get(self, url): class TestMeteringLifespan: @pytest.mark.asyncio - async def test_cloud_mode_is_noop(self, monkeypatch): - monkeypatch.setattr(lifespan, "_is_ground", lambda: False) + async def test_cloud_mode_is_noop(self, config): + config["HEROKU"] = True app = SimpleNamespace(state=SimpleNamespace()) async with lifespan.metering_lifespan(app): pass assert app.state.metering is None @pytest.mark.asyncio - async def test_offline_mode_is_noop(self, monkeypatch): - monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + async def test_offline_mode_is_noop(self, monkeypatch, config): + config["HEROKU"] = False monkeypatch.setattr(lifespan, "_metering_enabled", lambda: False) app = SimpleNamespace(state=SimpleNamespace()) async with lifespan.metering_lifespan(app): @@ -500,8 +500,8 @@ async def test_offline_mode_is_noop(self, monkeypatch): assert app.state.metering is None @pytest.mark.asyncio - async def test_not_started_skips_shutdown(self, monkeypatch): - monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + async def test_not_started_skips_shutdown(self, monkeypatch, config): + config["HEROKU"] = False monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) async def fake_ensure(app): @@ -521,8 +521,8 @@ async def fake_shutdown(app): assert shutdowns == [] @pytest.mark.asyncio - async def test_started_runs_shutdown(self, monkeypatch): - monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + async def test_started_runs_shutdown(self, monkeypatch, config): + config["HEROKU"] = False monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) async def fake_ensure(app): @@ -544,22 +544,22 @@ async def fake_shutdown(app): class TestEnsureMeteringRuntimeEarlyReturns: @pytest.mark.asyncio - async def test_not_ground_returns_false(self, monkeypatch): - monkeypatch.setattr(lifespan, "_is_ground", lambda: False) + async def test_not_ground_returns_false(self, config): + config["HEROKU"] = True app = SimpleNamespace(state=SimpleNamespace()) assert await lifespan.ensure_metering_runtime(app) is False assert app.state.metering is None @pytest.mark.asyncio - async def test_offline_returns_false(self, monkeypatch): - monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + async def test_offline_returns_false(self, monkeypatch, config): + config["HEROKU"] = False monkeypatch.setattr(lifespan, "_metering_enabled", lambda: False) app = SimpleNamespace(state=SimpleNamespace()) assert await lifespan.ensure_metering_runtime(app) is False @pytest.mark.asyncio - async def test_already_active_reruns_reconcile(self, monkeypatch): - monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + async def test_already_active_reruns_reconcile(self, monkeypatch, config): + config["HEROKU"] = False monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) signature = ("a", "http://x", "http", True) monkeypatch.setattr(lifespan, "_enabled_provider_signature", lambda flask_app: signature) @@ -925,27 +925,6 @@ def fake_run_coroutine_threadsafe(coro, running_loop): assert error == "boom" -class TestIsGround: - def test_is_ground_reads_config_flag(self, monkeypatch): - # _is_ground mirrors config.is_ground() when the config loads. - monkeypatch.setattr("sparkmeter.config.configdict.config", SimpleNamespace(is_ground=lambda: True)) - assert lifespan._is_ground() is True - monkeypatch.setattr("sparkmeter.config.configdict.config", SimpleNamespace(is_ground=lambda: False)) - assert lifespan._is_ground() is False - - def test_is_ground_falls_back_to_env(self, monkeypatch): - def boom(): - raise RuntimeError("config unavailable") - - monkeypatch.setattr("sparkmeter.config.configdict.config", SimpleNamespace(is_ground=boom)) - - # When config.is_ground() raises, the SPARKMETER_MODE env var decides. - monkeypatch.setenv("SPARKMETER_MODE", "ground") - assert lifespan._is_ground() is True - monkeypatch.setenv("SPARKMETER_MODE", "cloud") - assert lifespan._is_ground() is False - - class TestShutdownSkipsMissingTaskSlot: @pytest.mark.asyncio async def test_shutdown_skips_missing_task_slot(self): @@ -1018,7 +997,9 @@ def _install_ensure_harness( "event_clients": [], } - monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + from sparkmeter.config.configdict import config + + monkeypatch.setitem(config, "HEROKU", False) monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) monkeypatch.setattr(lifespan, "_enabled_provider_signature", lambda flask_app: signature) monkeypatch.setattr( @@ -1078,11 +1059,11 @@ async def _drain_runtime_tasks(app): class TestEnsureMeteringRuntimeStartup: @pytest.mark.asyncio - async def test_ensure_config_change_shuts_down_then_early_returns(self, monkeypatch): + async def test_ensure_config_change_shuts_down_then_early_returns(self, monkeypatch, config): # An existing client with a *different* signature forces a restart: # shutdown_metering_runtime is awaited, then the empty provider URL # early-returns False with metering cleared. - monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + config["HEROKU"] = False monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) monkeypatch.setattr( lifespan, "_enabled_provider_signature", lambda flask_app: ("new", "http://new", "http", True) @@ -1112,8 +1093,8 @@ async def fake_shutdown(app): assert app.state.metering is None @pytest.mark.asyncio - async def test_ensure_init_pass_logs_per_result_branch(self, monkeypatch, caplog): - monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + async def test_ensure_init_pass_logs_per_result_branch(self, monkeypatch, caplog, config): + config["HEROKU"] = False monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) monkeypatch.setattr( lifespan, "_enabled_provider_signature", lambda flask_app: ("x", "y", "http", True) @@ -1147,8 +1128,8 @@ async def test_ensure_init_pass_logs_per_result_branch(self, monkeypatch, caplog assert by_message["metering startup init skipped for meter driver: not configured"] == "INFO" @pytest.mark.asyncio - async def test_ensure_init_pass_exception_is_swallowed(self, monkeypatch, caplog): - monkeypatch.setattr(lifespan, "_is_ground", lambda: True) + async def test_ensure_init_pass_exception_is_swallowed(self, monkeypatch, caplog, config): + config["HEROKU"] = False monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) monkeypatch.setattr( lifespan, "_enabled_provider_signature", lambda flask_app: ("x", "y", "http", True) From e242d23f4a067d6065b73a1518afd0869649270b Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:34:36 -0400 Subject: [PATCH 8/9] Add config.is_offline() and drop the _metering_enabled env helper The _metering_enabled() helper re-read and re-normalized SM_OFFLINE in an except-fallback, duplicating the SM_-prefix env normalization ConfigDict already does at load time. Replace it with a config.is_offline() method that reads the normalized OFFLINE value, alongside is_ground()/is_cloud(). The two metering guards call config.is_offline() directly, and the tests drive the branch by setting config["OFFLINE"] rather than stubbing the removed helper. --- sparkmeter/config/configdict.py | 4 ++ sparkmeter/config/tests/test_configdict.py | 10 +++++ sparkmeter/metering/lifespan.py | 15 +------ sparkmeter/metering/tests/test_lifespan.py | 46 ++++++---------------- 4 files changed, 27 insertions(+), 48 deletions(-) diff --git a/sparkmeter/config/configdict.py b/sparkmeter/config/configdict.py index 63aa3d6..f0e087b 100644 --- a/sparkmeter/config/configdict.py +++ b/sparkmeter/config/configdict.py @@ -151,5 +151,9 @@ def is_cloud(self): """Get if this a cloud system.""" return self.local_system == self.CLOUD + def is_offline(self): + """Get if metering-provider startup is disabled for this process.""" + return bool(self.get("OFFLINE", False)) + config = ConfigDict() diff --git a/sparkmeter/config/tests/test_configdict.py b/sparkmeter/config/tests/test_configdict.py index a11c339..b57aaba 100644 --- a/sparkmeter/config/tests/test_configdict.py +++ b/sparkmeter/config/tests/test_configdict.py @@ -15,3 +15,13 @@ def test_system_methods(self, config): assert config.local_system == config.CLOUD assert config.is_ground() is False assert config.is_cloud() is True + + def test_is_offline(self, config): + # OFFLINE unset defaults to online; the flag reads the OFFLINE config + # value, which config normalizes from SM_OFFLINE at load time. + config.pop("OFFLINE", None) + assert config.is_offline() is False + config["OFFLINE"] = False + assert config.is_offline() is False + config["OFFLINE"] = True + assert config.is_offline() is True diff --git a/sparkmeter/metering/lifespan.py b/sparkmeter/metering/lifespan.py index 2c72e54..96710e3 100644 --- a/sparkmeter/metering/lifespan.py +++ b/sparkmeter/metering/lifespan.py @@ -13,7 +13,6 @@ import asyncio import logging -import os import uuid from collections.abc import AsyncIterator from concurrent.futures import TimeoutError as FutureTimeoutError @@ -34,16 +33,6 @@ class _ProviderRemovedDuringRecovery(RuntimeError): """Raised when the configured driver disappears during gateway recovery.""" -def _metering_enabled() -> bool: - """Whether metering-provider startup should run for this process.""" - try: - from sparkmeter.config.configdict import config - - return not bool(config.get("OFFLINE", False)) - except Exception: # noqa: BLE001 - return os.environ.get("SM_OFFLINE", "").lower() not in {"1", "true", "yes", "on"} - - def _initialize_configured_providers_with_app_context(flask_app, timeout): """Run the startup init pass inside an explicit Flask app context.""" from sparkmeter.config.provider_settings import initialize_configured_providers_on_startup @@ -140,7 +129,7 @@ async def ensure_metering_runtime( app.state.metering = None return False - if not _metering_enabled(): + if config.is_offline(): logger.info("metering provider disabled in offline mode; skipping startup") app.state.metering = None return False @@ -312,7 +301,7 @@ async def metering_lifespan(app: FastAPI) -> AsyncIterator[None]: yield return - if not _metering_enabled(): + if config.is_offline(): logger.info("metering provider disabled in offline mode; skipping startup") app.state.metering = None yield diff --git a/sparkmeter/metering/tests/test_lifespan.py b/sparkmeter/metering/tests/test_lifespan.py index 4389cdc..4b263d6 100644 --- a/sparkmeter/metering/tests/test_lifespan.py +++ b/sparkmeter/metering/tests/test_lifespan.py @@ -491,9 +491,9 @@ async def test_cloud_mode_is_noop(self, config): assert app.state.metering is None @pytest.mark.asyncio - async def test_offline_mode_is_noop(self, monkeypatch, config): + async def test_offline_mode_is_noop(self, config): config["HEROKU"] = False - monkeypatch.setattr(lifespan, "_metering_enabled", lambda: False) + config["OFFLINE"] = True app = SimpleNamespace(state=SimpleNamespace()) async with lifespan.metering_lifespan(app): pass @@ -502,7 +502,7 @@ async def test_offline_mode_is_noop(self, monkeypatch, config): @pytest.mark.asyncio async def test_not_started_skips_shutdown(self, monkeypatch, config): config["HEROKU"] = False - monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + config["OFFLINE"] = False async def fake_ensure(app): return False @@ -523,7 +523,7 @@ async def fake_shutdown(app): @pytest.mark.asyncio async def test_started_runs_shutdown(self, monkeypatch, config): config["HEROKU"] = False - monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + config["OFFLINE"] = False async def fake_ensure(app): return True @@ -551,16 +551,16 @@ async def test_not_ground_returns_false(self, config): assert app.state.metering is None @pytest.mark.asyncio - async def test_offline_returns_false(self, monkeypatch, config): + async def test_offline_returns_false(self, config): config["HEROKU"] = False - monkeypatch.setattr(lifespan, "_metering_enabled", lambda: False) + config["OFFLINE"] = True app = SimpleNamespace(state=SimpleNamespace()) assert await lifespan.ensure_metering_runtime(app) is False @pytest.mark.asyncio async def test_already_active_reruns_reconcile(self, monkeypatch, config): config["HEROKU"] = False - monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + config["OFFLINE"] = False signature = ("a", "http://x", "http", True) monkeypatch.setattr(lifespan, "_enabled_provider_signature", lambda flask_app: signature) @@ -610,30 +610,6 @@ async def __call__(self, seconds): raise asyncio.CancelledError -class TestMeteringEnabled: - def test_reads_offline_config_flag(self, config): - # Metering is enabled when OFFLINE is false, and disabled when true — - # proving the happy path actually reads the OFFLINE config value. - config["OFFLINE"] = False - assert lifespan._metering_enabled() is True - config["OFFLINE"] = True - assert lifespan._metering_enabled() is False - - def test_falls_back_to_env_when_config_errors(self, monkeypatch): - def boom(*args, **kwargs): - raise RuntimeError("config unavailable") - - monkeypatch.setattr("sparkmeter.config.configdict.config", SimpleNamespace(get=boom)) - - # SM_OFFLINE truthy -> metering disabled. - monkeypatch.setenv("SM_OFFLINE", "1") - assert lifespan._metering_enabled() is False - - # SM_OFFLINE unset/empty -> metering enabled. - monkeypatch.setenv("SM_OFFLINE", "") - assert lifespan._metering_enabled() is True - - class TestRunSseConsumer: @pytest.mark.asyncio async def test_stream_ended_and_broken_request_full_restart_with_backoff(self, monkeypatch): @@ -1000,7 +976,7 @@ def _install_ensure_harness( from sparkmeter.config.configdict import config monkeypatch.setitem(config, "HEROKU", False) - monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + monkeypatch.setitem(config, "OFFLINE", False) monkeypatch.setattr(lifespan, "_enabled_provider_signature", lambda flask_app: signature) monkeypatch.setattr( lifespan, "configured_provider_url", lambda *, default="", flask_app=None: provider_url @@ -1064,7 +1040,7 @@ async def test_ensure_config_change_shuts_down_then_early_returns(self, monkeypa # shutdown_metering_runtime is awaited, then the empty provider URL # early-returns False with metering cleared. config["HEROKU"] = False - monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + config["OFFLINE"] = False monkeypatch.setattr( lifespan, "_enabled_provider_signature", lambda flask_app: ("new", "http://new", "http", True) ) @@ -1095,7 +1071,7 @@ async def fake_shutdown(app): @pytest.mark.asyncio async def test_ensure_init_pass_logs_per_result_branch(self, monkeypatch, caplog, config): config["HEROKU"] = False - monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + config["OFFLINE"] = False monkeypatch.setattr( lifespan, "_enabled_provider_signature", lambda flask_app: ("x", "y", "http", True) ) @@ -1130,7 +1106,7 @@ async def test_ensure_init_pass_logs_per_result_branch(self, monkeypatch, caplog @pytest.mark.asyncio async def test_ensure_init_pass_exception_is_swallowed(self, monkeypatch, caplog, config): config["HEROKU"] = False - monkeypatch.setattr(lifespan, "_metering_enabled", lambda: True) + config["OFFLINE"] = False monkeypatch.setattr( lifespan, "_enabled_provider_signature", lambda flask_app: ("x", "y", "http", True) ) From 1f5758ab978405d2600bef84da38594d2a1a4559 Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:42:45 -0400 Subject: [PATCH 9/9] Document registering a meter driver --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 0e48cef..cdbfab7 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,20 @@ For Docker deployments, run the same commands inside the webapp container with ` docker compose exec ground uv run flask user create ``` +## Meter drivers + +Thundercloud is not tied to any one meter driver. It talks to a driver over the HTTP+SSE +contract (and optionally gRPC) from the Thunder-Cloud 2.0 Open Source Meter Driver +Specification, so any compliant driver works — SparkNet-Http or a third party's. + +Run the driver as its own service, then register it from the running ground app under +**Global Settings > Meter Drivers > Register driver** by entering the base URL of its HTTP +service. Registered drivers become selectable per meter on the meter form. + +The groundbolt-dev workspace metarepo runs `sparknet-http` as part of its stack for +convenience during development; that is a choice of that stack, not a dependency of this +application. + ## Development This document is to help you get the development environment up and running. You can choose between two options, hit either of the links below for more details: