Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions python/packages/jumpstarter-driver-http-power/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,14 +60,46 @@ 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 |
|-----------|-------------|------|----------|---------|
| 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 |

Expand All @@ -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

Expand Down Expand Up @@ -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"
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you provide the examples for the shelly? I have one and would love to test it :)


```{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.
```

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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]
Expand All @@ -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())
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import logging
import time

from click.testing import CliRunner

from .driver import MockPower
from jumpstarter.common.utils import serve

Expand All @@ -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