Skip to content

Commit d0897d8

Browse files
committed
feat: parse power measurements in http-power and add a power read CLI command
1 parent 24473f3 commit d0897d8

5 files changed

Lines changed: 169 additions & 22 deletions

File tree

python/packages/jumpstarter-driver-http-power/README.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export:
3535
password: "secret"
3636
```
3737
38-
### Example configuration for Shelly Smart Plug:
38+
### Example configuration for Shelly Smart Plug (Gen1):
3939
4040
```yaml
4141
apiVersion: jumpstarter.dev/v1alpha1
@@ -60,14 +60,33 @@ export:
6060
password: something
6161
```
6262
63+
### Example configuration for Shelly Smart Plug (Gen2/Gen3):
64+
65+
Gen2/Gen3 plugs (e.g. Plug S G3) use the RPC API and report `voltage`/`current`
66+
as top-level keys, so `read()` works with no path configuration:
67+
68+
```yaml
69+
export:
70+
power:
71+
type: jumpstarter_driver_http_power.driver.HttpPower
72+
config:
73+
name: "my-splug"
74+
power_on:
75+
url: "http://192.168.0.111/rpc/Switch.Set?id=0&on=true"
76+
power_off:
77+
url: "http://192.168.0.111/rpc/Switch.Set?id=0&on=false"
78+
power_read:
79+
url: "http://192.168.0.111/rpc/Switch.GetStatus?id=0"
80+
```
81+
6382
### Config parameters
6483

6584
| Parameter | Description | Type | Required | Default |
6685
|-----------|-------------|------|----------|---------|
6786
| name | Name of the device, for logging purposes | str | no | "device" |
6887
| power_on | HTTP endpoint config for powering on | HttpEndpointConfig | yes | |
6988
| power_off | HTTP endpoint config for powering off | HttpEndpointConfig | yes | |
70-
| power_read | HTTP endpoint config for reading power measurements | HttpEndpointConfig | no | None |
89+
| power_read | HTTP endpoint config for reading power measurements. When unset, `read()` raises rather than returning a fake zero measurement | HttpEndpointConfig | no | None |
7190
| auth | Authentication configuration | HttpAuthConfig | no | None |
7291
| auth.basic | Basic authentication credentials | HttpBasicAuth | no | None |
7392

@@ -78,6 +97,8 @@ export:
7897
| url | The HTTP endpoint URL | str | yes | |
7998
| method | HTTP method (GET, POST, PUT, etc.) | str | no | "GET" |
8099
| data | Request body data for POST/PUT/PATCH requests | str | no | None |
100+
| 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` |
101+
| current_path | On a `power_read` endpoint: dotted JSON path to the current value | str | no | top-level `current` |
81102

82103
#### HttpBasicAuth parameters
83104

@@ -106,8 +127,18 @@ http_power_client.off()
106127
```
107128

108129

130+
Reading measurements: `read()` parses the JSON returned by `power_read` and pulls
131+
voltage and current from `voltage_path` / `current_path` (defaulting to top-level
132+
`voltage` and `current` keys). A field the device doesn't report reads as `0.0`; a
133+
configured path that isn't found raises an error.
134+
135+
```yaml
136+
power_read:
137+
url: "http://192.168.1.65/cm?cmnd=Status%2010" # Tasmota
138+
voltage_path: "StatusSNS.ENERGY.Voltage"
139+
current_path: "StatusSNS.ENERGY.Current"
140+
```
141+
109142
```{note}
110-
Power reading response parsing is not yet implemented - the driver returns
111-
dummy values (0.0V, 0.0A). Authentication is optional and supports HTTP
112-
Basic Auth only.
143+
Authentication is optional and supports HTTP Basic Auth only.
113144
```

python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from dataclasses import dataclass, field
23
from typing import Generator, Optional
34

@@ -8,11 +9,23 @@
89
from jumpstarter.driver import Driver, export
910

1011

12+
def _json_path(data, path: str):
13+
"""Walk a dotted path through nested dicts/lists, e.g. 'meters.0.power'."""
14+
cur = data
15+
for part in path.split("."):
16+
cur = cur[int(part)] if isinstance(cur, list) else cur[part]
17+
return cur
18+
19+
1120
@dataclass(kw_only=True)
1221
class HttpEndpointConfig:
1322
url: str = field()
1423
method: str = field(default='GET')
1524
data: Optional[str] = field(default=None)
25+
# For read endpoints: dotted JSON paths to the values (e.g. "emeter.voltage").
26+
# When unset, read() looks for top-level "voltage"/"current" keys.
27+
voltage_path: Optional[str] = field(default=None)
28+
current_path: Optional[str] = field(default=None)
1629

1730

1831
@dataclass(kw_only=True)
@@ -92,20 +105,40 @@ def off(self):
92105

93106
@export
94107
def read(self) -> Generator[PowerReading, None, None]:
95-
"""Read power measurements via HTTP request
108+
"""Read a power measurement from the configured read endpoint.
109+
110+
Parses the JSON response and pulls voltage/current from the paths set on
111+
``power_read`` (defaulting to top-level ``voltage``/``current`` keys).
96112
97-
Note: Response parsing for voltage/current is not implemented yet.
98-
Returns dummy values for now.
113+
Requires ``power_read`` to be configured; raises ``ValueError`` if it is
114+
not, rather than reporting a fake zero measurement.
99115
"""
100-
self.logger.info("Reading power measurements via HTTP")
101116
if self.power_read is None:
102-
self.logger.error("Power read endpoint not configured")
103-
yield PowerReading(voltage=0.0, current=0.0)
104-
return
105-
106-
self._make_http_request(self.power_read)
107-
108-
# TODO: Parse response_text to extract voltage and current values
109-
# For now, return dummy values
110-
self.logger.warning("Power reading response parsing not implemented, returning dummy values")
111-
yield PowerReading(voltage=0.0, current=0.0)
117+
raise ValueError("power_read endpoint is not configured")
118+
119+
self.logger.debug("Reading power measurements via HTTP")
120+
text = self._make_http_request(self.power_read)
121+
try:
122+
data = json.loads(text)
123+
except json.JSONDecodeError as e:
124+
raise ValueError(f"read endpoint did not return JSON: {e}") from e
125+
126+
voltage = self._extract_reading(data, self.power_read.voltage_path, "voltage")
127+
current = self._extract_reading(data, self.power_read.current_path, "current")
128+
yield PowerReading(voltage=voltage, current=current)
129+
130+
@staticmethod
131+
def _extract_reading(data, path: Optional[str], default_key: str) -> float:
132+
"""Pull one numeric reading. A configured path that's missing is an error;
133+
a missing default key just means the device doesn't report it (0.0)."""
134+
key = path or default_key
135+
try:
136+
value = _json_path(data, key)
137+
except (KeyError, IndexError, TypeError, ValueError):
138+
if path is not None:
139+
raise ValueError(f"configured path {key!r} not found in read response") from None
140+
return 0.0
141+
try:
142+
return float(value)
143+
except (TypeError, ValueError):
144+
raise ValueError(f"value at {key!r} is not numeric: {value!r}") from None

python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver_test.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import threading
22
from http.server import BaseHTTPRequestHandler, HTTPServer
33

4+
import pytest
5+
46
from .driver import HttpEndpointConfig, HttpPower
57
from jumpstarter.common.utils import serve
68

@@ -76,11 +78,11 @@ def test_drivers_http_power():
7678
client.on()
7779
client.off()
7880

79-
# Test read method
81+
# Test read method — parses the JSON the mock /read endpoint returns
8082
readings = list(client.read())
8183
assert len(readings) == 1
82-
assert readings[0].voltage == 0.0 # Currently returns dummy values
83-
assert readings[0].current == 0.0
84+
assert readings[0].voltage == 12.0
85+
assert readings[0].current == 2.5
8486

8587
# Verify HTTP requests were made
8688
assert len(server.requests) == 3 # ty: ignore[unresolved-attribute]
@@ -106,3 +108,58 @@ def test_drivers_http_power():
106108
finally:
107109
server.shutdown()
108110
server_thread.join(timeout=1)
111+
112+
113+
def _power(power_read):
114+
return HttpPower(
115+
power_on=HttpEndpointConfig(url="http://x/on"),
116+
power_off=HttpEndpointConfig(url="http://x/off"),
117+
power_read=power_read,
118+
)
119+
120+
121+
def test_read_parses_nested_paths():
122+
drv = _power(
123+
HttpEndpointConfig(
124+
url="http://x/read", voltage_path="emeter.voltage", current_path="emeter.current"
125+
)
126+
)
127+
drv._make_http_request = lambda cfg: '{"emeter": {"voltage": 231.0, "current": 0.45}}'
128+
reading = next(iter(drv.read()))
129+
assert reading.voltage == 231.0
130+
assert reading.current == 0.45
131+
132+
133+
def test_read_missing_default_key_is_zero():
134+
drv = _power(HttpEndpointConfig(url="http://x/read"))
135+
drv._make_http_request = lambda cfg: '{"voltage": 230.0}' # device reports no current
136+
reading = next(iter(drv.read()))
137+
assert reading.voltage == 230.0
138+
assert reading.current == 0.0
139+
140+
141+
def test_read_configured_path_missing_raises():
142+
drv = _power(HttpEndpointConfig(url="http://x/read", voltage_path="nope.here"))
143+
drv._make_http_request = lambda cfg: '{"voltage": 1.0}'
144+
with pytest.raises(ValueError, match="not found in read response"):
145+
list(drv.read())
146+
147+
148+
def test_read_non_numeric_list_index_raises_not_found():
149+
drv = _power(HttpEndpointConfig(url="http://x/read", voltage_path="meters.x.voltage"))
150+
drv._make_http_request = lambda cfg: '{"meters": [{"voltage": 1.0}]}'
151+
with pytest.raises(ValueError, match="not found in read response"):
152+
list(drv.read())
153+
154+
155+
def test_read_non_json_raises():
156+
drv = _power(HttpEndpointConfig(url="http://x/read"))
157+
drv._make_http_request = lambda cfg: "OK"
158+
with pytest.raises(ValueError, match="did not return JSON"):
159+
list(drv.read())
160+
161+
162+
def test_read_without_endpoint_raises():
163+
drv = _power(None)
164+
with pytest.raises(ValueError, match="not configured"):
165+
list(drv.read())

python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ def cycle(wait):
5858
click.echo(f"Power cycling with {wait} seconds wait time...")
5959
self.cycle(wait)
6060

61+
@base.command()
62+
def read():
63+
"""Read power measurements"""
64+
for reading in self.read():
65+
click.echo(
66+
f"voltage={reading.voltage} V current={reading.current} A "
67+
f"apparent_power={reading.apparent_power} VA"
68+
)
69+
6170
return base
6271

6372

python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client_test.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
22
import time
33

4+
from click.testing import CliRunner
5+
46
from .driver import MockPower
57
from jumpstarter.common.utils import serve
68

@@ -18,3 +20,18 @@ def test_log_stream(caplog):
1820
client.off()
1921
time.sleep(1)
2022
assert "power off" in caplog.text
23+
24+
25+
def test_read_values():
26+
with serve(MockPower()) as client:
27+
readings = list(client.read())
28+
assert [(r.voltage, r.current) for r in readings] == [(0.0, 0.0), (5.0, 2.0)]
29+
assert readings[1].apparent_power == 10.0
30+
31+
32+
def test_read_cli():
33+
with serve(MockPower()) as client:
34+
result = CliRunner().invoke(client.cli(), ["read"])
35+
assert result.exit_code == 0
36+
assert "voltage=0.0 V" in result.output
37+
assert "voltage=5.0 V current=2.0 A apparent_power=10.0 VA" in result.output

0 commit comments

Comments
 (0)