Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.
Merged
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
1 change: 1 addition & 0 deletions docs/source/reference/package-apis/drivers/http-power.md
3 changes: 3 additions & 0 deletions docs/source/reference/package-apis/drivers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Drivers that control the power state and basic operation of devices:
Board](https://github.com/jumpstarter-dev/dutlink-board) hardware control
* **[Energenie PDU](energenie.md)** (`jumpstarter-driver-energenie`) - Energenie PDUs
* **[Tasmota](tasmota.md)** (`jumpstarter-driver-tasmota`) - Tasmota hardware control
* **[HTTP Power](http-power.md)** (`jumpstarter-driver-http-power`) - HTTP-based power
control, useful for smart sockets, like the Shelly Smart Plug or similar

### Communication Drivers

Expand Down Expand Up @@ -84,6 +86,7 @@ dutlink.md
energenie.md
flashers.md
http.md
http-power.md
network.md
opendal.md
power.md
Expand Down
3 changes: 3 additions & 0 deletions packages/jumpstarter-driver-http-power/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
.coverage
coverage.xml
113 changes: 113 additions & 0 deletions packages/jumpstarter-driver-http-power/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# HTTP Power Driver

`jumpstarter-driver-http-power` provides functionality for controlling power via HTTP endpoints and reading power measurements.

## Installation

```shell
pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-driver-http-power
```

## Configuration

Example configuration:

```yaml
export:
http_power:
type: jumpstarter_driver_http_power.driver.HttpPower
config:
name: "device"
power_on:
url: "http://power-controller.local/api/power/on"
method: "POST"
data: "action=on"
power_off:
url: "http://power-controller.local/api/power/off"
method: "POST"
data: "action=off"
power_read:
url: "http://power-controller.local/api/power/status"
method: "GET"
auth:
basic:
user: "admin"
password: "secret"
```

### Example configuration for Shelly Smart Plug:

```yaml
apiVersion: jumpstarter.dev/v1alpha1
kind: ExporterConfig
metadata:
namespace: default
name: demo
endpoint: ""
token: ""
export:
power:
type: jumpstarter_driver_http_power.driver.HttpPower
config:
name: "my-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
```

### 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 |
| auth | Authentication configuration | HttpAuthConfig | no | None |
| auth.basic | Basic authentication credentials | HttpBasicAuth | no | None |

#### HttpEndpointConfig parameters

| Parameter | Description | Type | Required | Default |
|-----------|-------------|------|----------|---------|
| 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 |

#### HttpBasicAuth parameters

| Parameter | Description | Type | Required | Default |
|-----------|-------------|------|----------|---------|
| user | Username for basic authentication | str | yes | |
| password | Password for basic authentication | str | yes | |

## API Reference

```{eval-rst}
.. autoclass:: jumpstarter_driver_power.client.PowerClient()
:members: on, off, read, cycle
:no-index:
```

### Examples

Basic power control:
```python
# Power on the device
http_power_client.on()

# Power off the device
http_power_client.off()
```


## Notes

- The power reading response parsing is not yet implemented. The driver currently returns dummy values (0.0V, 0.0A).
- Authentication is optional and currently supports HTTP Basic Auth only.
- All HTTP requests will raise exceptions on HTTP error status codes.
20 changes: 20 additions & 0 deletions packages/jumpstarter-driver-http-power/examples/exporter.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright (c) Jumpstarter developers
# SPDX-License-Identifier: Apache-2.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from dataclasses import dataclass, field
from typing import Generator, Optional

import requests
from jumpstarter_driver_power.common import PowerReading
from jumpstarter_driver_power.driver import PowerInterface

from jumpstarter.driver import Driver, export


@dataclass(kw_only=True)
class HttpEndpointConfig:
url: str = field()
method: str = field(default='GET')
data: Optional[str] = field(default=None)


@dataclass(kw_only=True)
class HttpBasicAuth:
user: str = field(default="")
password: str = field(default="")


@dataclass(kw_only=True)
class HttpAuthConfig:
basic: Optional[HttpBasicAuth] = field(default=None)


@dataclass(kw_only=True)
class HttpPower(PowerInterface, Driver):
"""HTTP Power driver for Jumpstarter

Makes HTTP requests to control power and read power measurements.
"""
name: str = field(default="device")
# HTTP endpoints configuration
power_on: HttpEndpointConfig = field()
power_off: HttpEndpointConfig = field()
power_read: Optional[HttpEndpointConfig] = field(default=None)
# Authentication configuration
auth: Optional[HttpAuthConfig] = field(default=None)

def __post_init__(self):
if hasattr(super(), "__post_init__"):
super().__post_init__()

# The structures don't get deserialized automatically for some reason.
if isinstance(self.power_on, dict):
self.power_on = HttpEndpointConfig(**self.power_on)
if isinstance(self.power_off, dict):
self.power_off = HttpEndpointConfig(**self.power_off)
if self.power_read and isinstance(self.power_read, dict):
self.power_read = HttpEndpointConfig(**self.power_read)
if self.auth and isinstance(self.auth, dict):
self.auth = HttpAuthConfig(**self.auth)
if self.auth and self.auth.basic and isinstance(self.auth.basic, dict):
self.auth.basic = HttpBasicAuth(**self.auth.basic)


def _make_http_request(self, endpoint_config: HttpEndpointConfig) -> str:
"""Make HTTP request to the specified endpoint"""
auth = None
if self.auth and self.auth.basic:
auth = (self.auth.basic.user, self.auth.basic.password)
method = endpoint_config.method.upper()
kwargs = {
'url': endpoint_config.url,
'auth': auth,
}
if endpoint_config.data and method in ['POST', 'PUT', 'PATCH']:
kwargs['data'] = endpoint_config.data

self.logger.debug(f"Making {method} request to {endpoint_config.url}")

response = requests.request(method, **kwargs)
response.raise_for_status()
return response.text

@export
def on(self):
"""Power on via HTTP request"""
self.logger.info(f"Powering on {self.name} via HTTP")
self._make_http_request(self.power_on)
self.logger.debug("Powering on via HTTP DONE")

@export
def off(self):
"""Power off via HTTP request"""
self.logger.info(f"Powering off {self.name} via HTTP")
self._make_http_request(self.power_off)
self.logger.debug("Powering off via HTTP DONE")

@export
def read(self) -> Generator[PowerReading, None, None]:
"""Read power measurements via HTTP request

Note: Response parsing for voltage/current is not implemented yet.
Returns dummy values for now.
"""
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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

from .driver import HttpEndpointConfig, HttpPower
from jumpstarter.common.utils import serve


class MockHTTPHandler(BaseHTTPRequestHandler):
"""Mock HTTP server handler for testing"""

def log_message(self, format, *args):
# Suppress server logs during testing
pass

def do_GET(self):
self._handle_request()

def do_POST(self):
self._handle_request()

def do_PUT(self):
self._handle_request()

def _handle_request(self):
# Record the request for verification
if not hasattr(self.server, 'requests'):
self.server.requests = []

content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8') if content_length > 0 else None

self.server.requests.append({
'method': self.command,
'path': self.path,
'body': body
})

# Send appropriate response based on endpoint
if self.path == '/read':
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(b'{"voltage": 12.0, "current": 2.5}')
else:
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
self.wfile.write(b'OK')


def test_drivers_http_power():
# Start a mock HTTP server
server = HTTPServer(('localhost', 0), MockHTTPHandler)
server_port = server.server_address[1]
server_thread = threading.Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()

try:
base_url = f"http://localhost:{server_port}"

instance = HttpPower(
power_on=HttpEndpointConfig(url=f"{base_url}/on", method="POST", data="power=on"),
power_off=HttpEndpointConfig(url=f"{base_url}/off", method="POST", data="power=off"),
power_read=HttpEndpointConfig(url=f"{base_url}/read"),
)

with serve(instance) as client:
# Test that the client can be created and basic methods exist
assert hasattr(client, 'on')
assert hasattr(client, 'off')
assert hasattr(client, 'read')

# Test actual HTTP calls
client.on()
client.off()

# Test read method
readings = list(client.read())
assert len(readings) == 1
assert readings[0].voltage == 0.0 # Currently returns dummy values
assert readings[0].current == 0.0

# Verify HTTP requests were made
assert len(server.requests) == 3

# Check on request
on_request = server.requests[0]
assert on_request['method'] == 'POST'
assert on_request['path'] == '/on'
assert on_request['body'] == 'power=on'

# Check off request
off_request = server.requests[1]
assert off_request['method'] == 'POST'
assert off_request['path'] == '/off'
assert off_request['body'] == 'power=off'

# Check read request
read_request = server.requests[2]
assert read_request['method'] == 'GET'
assert read_request['path'] == '/read'
assert read_request['body'] is None

finally:
server.shutdown()
server_thread.join(timeout=1)
Loading
Loading