diff --git a/python/packages/jumpstarter-driver-http-power/README.md b/python/packages/jumpstarter-driver-http-power/README.md index 4095096f0..98007b27b 100644 --- a/python/packages/jumpstarter-driver-http-power/README.md +++ b/python/packages/jumpstarter-driver-http-power/README.md @@ -35,7 +35,7 @@ export: password: "secret" ``` -### Example configuration for Shelly Smart Plug: +### Example configuration for Shelly Smart Plug (Gen1): ```yaml apiVersion: jumpstarter.dev/v1alpha1 @@ -60,6 +60,38 @@ export: password: something ``` +### Example configuration for Shelly Smart Plug (Gen2/Gen3): + +Gen2/Gen3 plugs (e.g. Plug S G3) use the RPC API and report `voltage`/`current` +as top-level keys, so `read()` works with no path configuration: + +```yaml +export: + power: + type: jumpstarter_driver_http_power.driver.HttpPower + config: + name: "my-splug" + power_on: + url: "http://192.168.0.111/rpc/Switch.Set?id=0&on=true" + power_off: + url: "http://192.168.0.111/rpc/Switch.Set?id=0&on=false" + power_read: + url: "http://192.168.0.111/rpc/Switch.GetStatus?id=0" +``` + +Using the `examples/exporter-shelly-gen3.yaml` config, power on, take 4 measurements one second apart, then power off: + +```shell +$ jmp shell --exporter-config exporter.yaml -- sh -c 'j power on && j power read -n 4 -i 1 && j power off' +[06/23/26 23:00:26] INFO [driver.HttpPower] Powering on shellyplugsg3 via +HTTP +voltage=236.6 V current=0.0 A apparent_power=0.0 VA +voltage=236.5 V current=0.09 A apparent_power=21.285 VA +voltage=236.6 V current=0.016 A apparent_power=3.7856 VA +voltage=236.6 V current=0.0 A apparent_power=0.0 VA +[06/23/26 23:00:30] INFO Powering off shellyplugsg3 via HTTP +``` + ### Config parameters | Parameter | Description | Type | Required | Default | @@ -67,7 +99,7 @@ export: | name | Name of the device, for logging purposes | str | no | "device" | | power_on | HTTP endpoint config for powering on | HttpEndpointConfig | yes | | | power_off | HTTP endpoint config for powering off | HttpEndpointConfig | yes | | -| power_read | HTTP endpoint config for reading power measurements | HttpEndpointConfig | no | None | +| power_read | HTTP endpoint config for reading power measurements. When unset, `read()` raises rather than returning a fake zero measurement | HttpEndpointConfig | no | None | | auth | Authentication configuration | HttpAuthConfig | no | None | | auth.basic | Basic authentication credentials | HttpBasicAuth | no | None | @@ -78,6 +110,8 @@ export: | url | The HTTP endpoint URL | str | yes | | | method | HTTP method (GET, POST, PUT, etc.) | str | no | "GET" | | data | Request body data for POST/PUT/PATCH requests | str | no | None | +| voltage_path | On a `power_read` endpoint: dotted JSON path to the voltage value (e.g. `emeter.voltage`, `StatusSNS.ENERGY.Voltage`) | str | no | top-level `voltage` | +| current_path | On a `power_read` endpoint: dotted JSON path to the current value | str | no | top-level `current` | #### HttpBasicAuth parameters @@ -106,8 +140,18 @@ http_power_client.off() ``` +Reading measurements: `read()` parses the JSON returned by `power_read` and pulls +voltage and current from `voltage_path` / `current_path` (defaulting to top-level +`voltage` and `current` keys). A field the device doesn't report reads as `0.0`; a +configured path that isn't found raises an error. + +```yaml + power_read: + url: "http://192.168.1.65/cm?cmnd=Status%2010" # Tasmota + voltage_path: "StatusSNS.ENERGY.Voltage" + current_path: "StatusSNS.ENERGY.Current" +``` + ```{note} -Power reading response parsing is not yet implemented - the driver returns -dummy values (0.0V, 0.0A). Authentication is optional and supports HTTP -Basic Auth only. +Authentication is optional and supports HTTP Basic Auth only. ``` diff --git a/python/packages/jumpstarter-driver-http-power/examples/exporter.yaml b/python/packages/jumpstarter-driver-http-power/examples/exporter.yaml deleted file mode 100644 index 2e8994150..000000000 --- a/python/packages/jumpstarter-driver-http-power/examples/exporter.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: jumpstarter.dev/v1alpha1 -kind: ExporterConfig -metadata: - namespace: default - name: demo -endpoint: "" -token: "" -export: - power: - type: jumpstarter_driver_http_power.driver.HttpPower - config: - name: "splug" - power_on: - url: "http://192.168.1.65/relay/0?turn=on" - power_off: - url: "http://192.168.1.65/relay/0?turn=off" - auth: - basic: - user: admin - password: something diff --git a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py index 8c68f4cea..fdfbc6ae6 100644 --- a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py +++ b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py @@ -1,5 +1,6 @@ +import json from dataclasses import dataclass, field -from typing import Generator, Optional +from typing import Any, Generator, Optional import requests from jumpstarter_driver_power.common import PowerReading @@ -8,11 +9,23 @@ from jumpstarter.driver import Driver, export +def _json_path(data: Any, path: str) -> Any: + """Walk a dotted path through nested dicts/lists, e.g. 'meters.0.power'.""" + cur = data + for part in path.split("."): + cur = cur[int(part)] if isinstance(cur, list) else cur[part] + return cur + + @dataclass(kw_only=True) class HttpEndpointConfig: url: str = field() method: str = field(default='GET') data: Optional[str] = field(default=None) + # For read endpoints: dotted JSON paths to the values (e.g. "emeter.voltage"). + # When unset, read() looks for top-level "voltage"/"current" keys. + voltage_path: Optional[str] = field(default=None) + current_path: Optional[str] = field(default=None) @dataclass(kw_only=True) @@ -92,20 +105,40 @@ def off(self): @export def read(self) -> Generator[PowerReading, None, None]: - """Read power measurements via HTTP request + """Read a power measurement from the configured read endpoint. + + Parses the JSON response and pulls voltage/current from the paths set on + ``power_read`` (defaulting to top-level ``voltage``/``current`` keys). - Note: Response parsing for voltage/current is not implemented yet. - Returns dummy values for now. + Requires ``power_read`` to be configured; raises ``ValueError`` if it is + not, rather than reporting a fake zero measurement. """ - self.logger.info("Reading power measurements via HTTP") if self.power_read is None: - self.logger.error("Power read endpoint not configured") - yield PowerReading(voltage=0.0, current=0.0) - return - - self._make_http_request(self.power_read) - - # TODO: Parse response_text to extract voltage and current values - # For now, return dummy values - self.logger.warning("Power reading response parsing not implemented, returning dummy values") - yield PowerReading(voltage=0.0, current=0.0) + raise ValueError("power_read endpoint is not configured") + + self.logger.debug("Reading power measurements via HTTP") + text = self._make_http_request(self.power_read) + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise ValueError(f"read endpoint did not return JSON: {e}") from e + + voltage = self._extract_reading(data, self.power_read.voltage_path, "voltage") + current = self._extract_reading(data, self.power_read.current_path, "current") + yield PowerReading(voltage=voltage, current=current) + + @staticmethod + def _extract_reading(data: Any, path: Optional[str], default_key: str) -> float: + """Pull one numeric reading. A configured path that's missing is an error; + a missing default key just means the device doesn't report it (0.0).""" + key = path or default_key + try: + value = _json_path(data, key) + except (KeyError, IndexError, TypeError, ValueError): + if path is not None: + raise ValueError(f"configured path {key!r} not found in read response") from None + return 0.0 + try: + return float(value) + except (TypeError, ValueError): + raise ValueError(f"value at {key!r} is not numeric (got {type(value).__name__})") from None diff --git a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver_test.py b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver_test.py index 1f4e990b9..b0407ad3b 100644 --- a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver_test.py +++ b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver_test.py @@ -1,6 +1,8 @@ import threading from http.server import BaseHTTPRequestHandler, HTTPServer +import pytest + from .driver import HttpEndpointConfig, HttpPower from jumpstarter.common.utils import serve @@ -76,11 +78,11 @@ def test_drivers_http_power(): client.on() client.off() - # Test read method + # Test read method — parses the JSON the mock /read endpoint returns readings = list(client.read()) assert len(readings) == 1 - assert readings[0].voltage == 0.0 # Currently returns dummy values - assert readings[0].current == 0.0 + assert readings[0].voltage == 12.0 + assert readings[0].current == 2.5 # Verify HTTP requests were made assert len(server.requests) == 3 # ty: ignore[unresolved-attribute] @@ -106,3 +108,58 @@ def test_drivers_http_power(): finally: server.shutdown() server_thread.join(timeout=1) + + +def _power(power_read): + return HttpPower( + power_on=HttpEndpointConfig(url="http://x/on"), + power_off=HttpEndpointConfig(url="http://x/off"), + power_read=power_read, + ) + + +def test_read_parses_nested_paths(): + drv = _power( + HttpEndpointConfig( + url="http://x/read", voltage_path="emeter.voltage", current_path="emeter.current" + ) + ) + drv._make_http_request = lambda cfg: '{"emeter": {"voltage": 231.0, "current": 0.45}}' + reading = next(iter(drv.read())) + assert reading.voltage == 231.0 + assert reading.current == 0.45 + + +def test_read_missing_default_key_is_zero(): + drv = _power(HttpEndpointConfig(url="http://x/read")) + drv._make_http_request = lambda cfg: '{"voltage": 230.0}' # device reports no current + reading = next(iter(drv.read())) + assert reading.voltage == 230.0 + assert reading.current == 0.0 + + +def test_read_configured_path_missing_raises(): + drv = _power(HttpEndpointConfig(url="http://x/read", voltage_path="nope.here")) + drv._make_http_request = lambda cfg: '{"voltage": 1.0}' + with pytest.raises(ValueError, match="not found in read response"): + list(drv.read()) + + +def test_read_non_numeric_list_index_raises_not_found(): + drv = _power(HttpEndpointConfig(url="http://x/read", voltage_path="meters.x.voltage")) + drv._make_http_request = lambda cfg: '{"meters": [{"voltage": 1.0}]}' + with pytest.raises(ValueError, match="not found in read response"): + list(drv.read()) + + +def test_read_non_json_raises(): + drv = _power(HttpEndpointConfig(url="http://x/read")) + drv._make_http_request = lambda cfg: "OK" + with pytest.raises(ValueError, match="did not return JSON"): + list(drv.read()) + + +def test_read_without_endpoint_raises(): + drv = _power(None) + with pytest.raises(ValueError, match="not configured"): + list(drv.read()) diff --git a/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py b/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py index 3fc25598e..10ba3ccec 100644 --- a/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py +++ b/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py @@ -58,6 +58,22 @@ def cycle(wait): click.echo(f"Power cycling with {wait} seconds wait time...") self.cycle(wait) + @base.command() + @click.option("--count", "-n", default=1, help="Number of readings (0 = infinite)", show_default=True) + @click.option("--interval", "-i", default=1.0, help="Seconds between readings", show_default=True) + def read(count, interval): + """Read power measurements""" + i = 0 + while count == 0 or i < count: + for reading in self.read(): + click.echo( + f"voltage={reading.voltage} V current={reading.current} A " + f"apparent_power={reading.apparent_power} VA" + ) + i += 1 + if (count == 0 or i < count) and interval > 0: + time.sleep(interval) + return base diff --git a/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client_test.py b/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client_test.py index 6dc2d3db9..82525d0a1 100644 --- a/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client_test.py +++ b/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client_test.py @@ -1,6 +1,8 @@ import logging import time +from click.testing import CliRunner + from .driver import MockPower from jumpstarter.common.utils import serve @@ -18,3 +20,18 @@ def test_log_stream(caplog): client.off() time.sleep(1) assert "power off" in caplog.text + + +def test_read_values(): + with serve(MockPower()) as client: + readings = list(client.read()) + assert [(r.voltage, r.current) for r in readings] == [(0.0, 0.0), (5.0, 2.0)] + assert readings[1].apparent_power == 10.0 + + +def test_read_cli(): + with serve(MockPower()) as client: + result = CliRunner().invoke(client.cli(), ["read"]) + assert result.exit_code == 0 + assert "voltage=0.0 V" in result.output + assert "voltage=5.0 V current=2.0 A apparent_power=10.0 VA" in result.output