|
| 1 | +import json |
1 | 2 | from dataclasses import dataclass, field |
2 | 3 | from typing import Generator, Optional |
3 | 4 |
|
|
8 | 9 | from jumpstarter.driver import Driver, export |
9 | 10 |
|
10 | 11 |
|
| 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 | + |
11 | 20 | @dataclass(kw_only=True) |
12 | 21 | class HttpEndpointConfig: |
13 | 22 | url: str = field() |
14 | 23 | method: str = field(default='GET') |
15 | 24 | 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) |
16 | 29 |
|
17 | 30 |
|
18 | 31 | @dataclass(kw_only=True) |
@@ -92,20 +105,40 @@ def off(self): |
92 | 105 |
|
93 | 106 | @export |
94 | 107 | 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). |
96 | 112 |
|
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. |
99 | 115 | """ |
100 | | - self.logger.info("Reading power measurements via HTTP") |
101 | 116 | 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 |
0 commit comments