From f091b6d49615b07ac585b272a3adff0cf7b571d1 Mon Sep 17 00:00:00 2001 From: Enric Balletbo i Serra Date: Wed, 9 Apr 2025 11:23:38 +0200 Subject: [PATCH] EnerGenie: Add drivers for EnerGenie Power Management System (PMS) products The driver was tested on EG-PMS2-LAN device only but should be easy to support other devices. Signed-off-by: Enric Balletbo i Serra (cherry picked from commit c31517568c2bab39c8c80c2c429e54e876bb7339) --- .../package-apis/drivers/energenie.md | 1 + .../reference/package-apis/drivers/index.md | 2 + .../jumpstarter-driver-energenie/.gitignore | 3 + .../jumpstarter-driver-energenie/README.md | 78 ++++++++++++++++ .../examples/exporter.yaml | 14 +++ .../jumpstarter_driver_energenie/__init__.py | 0 .../jumpstarter_driver_energenie/driver.py | 92 +++++++++++++++++++ .../driver_test.py | 46 ++++++++++ .../pyproject.toml | 37 ++++++++ pyproject.toml | 1 + uv.lock | 55 +++++++++++ 11 files changed, 329 insertions(+) create mode 120000 docs/source/reference/package-apis/drivers/energenie.md create mode 100644 packages/jumpstarter-driver-energenie/.gitignore create mode 100644 packages/jumpstarter-driver-energenie/README.md create mode 100644 packages/jumpstarter-driver-energenie/examples/exporter.yaml create mode 100644 packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/__init__.py create mode 100644 packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver.py create mode 100644 packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver_test.py create mode 100644 packages/jumpstarter-driver-energenie/pyproject.toml diff --git a/docs/source/reference/package-apis/drivers/energenie.md b/docs/source/reference/package-apis/drivers/energenie.md new file mode 120000 index 000000000..925cf5386 --- /dev/null +++ b/docs/source/reference/package-apis/drivers/energenie.md @@ -0,0 +1 @@ +../../../../../packages/jumpstarter-driver-energenie/README.md \ No newline at end of file diff --git a/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index c1411370e..fe8a04a9a 100644 --- a/docs/source/reference/package-apis/drivers/index.md +++ b/docs/source/reference/package-apis/drivers/index.md @@ -21,6 +21,7 @@ Drivers that control the power state and basic operation of devices: control * **[DUT Link](dutlink.md)** (`jumpstarter-driver-dutlink`) - [DUT Link Board](https://github.com/jumpstarter-dev/dutlink-board) hardware control +* **[Energenie PDU](energenie.md)** (`jumpstarter-driver-energenie`) - Energenie PDUs ### Communication Drivers @@ -79,6 +80,7 @@ General-purpose utility drivers: can.md corellium.md dutlink.md +energenie.md flashers.md http.md network.md diff --git a/packages/jumpstarter-driver-energenie/.gitignore b/packages/jumpstarter-driver-energenie/.gitignore new file mode 100644 index 000000000..cbc5d672b --- /dev/null +++ b/packages/jumpstarter-driver-energenie/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.coverage +coverage.xml diff --git a/packages/jumpstarter-driver-energenie/README.md b/packages/jumpstarter-driver-energenie/README.md new file mode 100644 index 000000000..b2867ed18 --- /dev/null +++ b/packages/jumpstarter-driver-energenie/README.md @@ -0,0 +1,78 @@ +# EnerGenie + +Drivers for EnerGenie products. + +## EnerGenie driver + +This driver provides a client for the [EnerGenie Programmable power switch](https://energenie.com/products.aspx?sg=239). The driver was tested on EG-PMS2-LAN device only but should be easy to support other devices. + +**driver**: `jumpstarter_driver_energenie.driver.EnerGenie` + +## Installation + +```shell +pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-driver-energenie +``` + +### Configuration + +```yaml +export: + power: + type: jumpstarter_driver_energenie.driver.EnerGenie + config: + host: "192.168.0.1" + password: "password" + slot: "1" +``` + +### Config parameters + +| Parameter | Description | Type | Required | Default | +|-----------|-------------|------|----------|---------| +| host | The ip address of the EnerGenie system | string | yes | None | +| password | The password of the EnerGenie system | string | no | None | +| slot | The slot number to be managed, 1, 2, 3, 4 | int | yes | 1 | + +### PowerClient API + +The EnerGenie driver provides a `PowerClient` with the following API: + +```{eval-rst} +.. autoclass:: jumpstarter_driver_power.client.PowerClient() + :no-index: + :members: on, off +``` + +### Examples + +Powering on and off a device + +```{testcode} +:skipif: True +client.power.on() +time.sleep(1) +client.power.off() +``` + +### CLI + +```bash +$ sudo uv run jmp exporter shell -c ./packages/jumpstarter-driver-energenie/examples/exporter.yaml + +$$ j +Usage: j [OPTIONS] COMMAND [ARGS]... + + Generic composite device + +Options: + --help Show this message and exit. + +Commands: + power Generic power + +$$ j power on + + +$$ exit +``` diff --git a/packages/jumpstarter-driver-energenie/examples/exporter.yaml b/packages/jumpstarter-driver-energenie/examples/exporter.yaml new file mode 100644 index 000000000..4ff97b360 --- /dev/null +++ b/packages/jumpstarter-driver-energenie/examples/exporter.yaml @@ -0,0 +1,14 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: default + name: demo +endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082 +token: "" +export: + power: + type: jumpstarter_driver_energenie.driver.EnerGenie + config: + host: "192.168.1.51" + password: "1" + slot: 1 diff --git a/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/__init__.py b/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver.py b/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver.py new file mode 100644 index 000000000..97fa87971 --- /dev/null +++ b/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver.py @@ -0,0 +1,92 @@ + +from collections.abc import AsyncGenerator +from dataclasses import dataclass, field + +import requests +from jumpstarter_driver_power.driver import PowerInterface, PowerReading + +from jumpstarter.driver import Driver, export + + +@dataclass(kw_only=True) +class EnerGenie(PowerInterface, Driver): + """ + driver for the EnerGenie Programmable surge protector with LAN interface. + + This driver was tested on EG-PMS2-LAN device only but should be easy to support other devices. + """ + + host: str | None = field(default=None) + password: str | None = field(default="1") + slot: int = 1 + + def login(self): + """ + Log in to the programmable power switch. + + :return: True if login is successful, False otherwise. + """ + login_url = f"{self.base_url}/login.html" + try: + response = requests.post(login_url, data={"pw": self.password}, timeout=10) + return response.status_code == 200 + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, + requests.exceptions.RequestException) as e: + self.logger.error(f"Login failed: {str(e)}") + return False + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + # Programmable power switch initialitzation. The EG-PMS2-LAN device has up to 4 slots. + if self.slot < 1 or self.slot > 4: + raise ValueError("Slot must be between 1 and 4") + if self.host is None: + raise ValueError("Host must be specified") + self.logger.debug(f"Using Host: {self.host}, Slot: {self.slot}") + self.base_url = f"http://{self.host}" + + + def set_switch(self, switch_number, state): + """ + Set the state of a specific switch. + + :param switch_number: The switch number (1, 2, etc.). + :param state: The state to set (1 for ON, 0 for OFF). + :return: True if the operation is successful, False otherwise. + """ + if state not in [0, 1]: + self.logger.error(f"Invalid state: {state}") + return False + + if self.login(): + self.logger.debug("Login successful!") + else: + self.logger.debug("Login failed!") + return False + data = {f"cte{switch_number}": state} + try: + response = requests.post(self.base_url, data=data, timeout=10) + if response.status_code != 200: + self.logger.error(f"Set switch {switch_number} to {state} state failed!") + return False + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, + requests.exceptions.RequestException) as e: + self.logger.error(f"Set switch failed: {str(e)}") + return False + + self.logger.debug(f"Set switch {switch_number} to {state} state") + + return True + + @export + def on(self) -> None: + self.set_switch(self.slot, 1) + + @export + def off(self) -> None: + self.set_switch(self.slot, 0) + + @export + def read(self) -> AsyncGenerator[PowerReading, None]: + raise NotImplementedError diff --git a/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver_test.py b/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver_test.py new file mode 100644 index 000000000..a1604517d --- /dev/null +++ b/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver_test.py @@ -0,0 +1,46 @@ +from pytest_httpserver import HTTPServer + +from .driver import EnerGenie +from jumpstarter.common.utils import serve + + +def test_drivers_energenie(httpserver: HTTPServer): + # Configure mock responses + # 1. Login response - Match raw data string + httpserver.expect_request( + "/login.html", + method="POST", + data="pw=1" + ).respond_with_data("Login successful") # Defaults to status 200 + + # 2. Response for turning ON switch 1 - Match raw data string + httpserver.expect_request( + "/", + method="POST", + data="cte1=1" + ).respond_with_data("Switch turned ON") # Defaults to status 200 + + # 3. Response for turning OFF switch 1 - Match raw data string + httpserver.expect_request( + "/", + method="POST", + data="cte1=0" + ).respond_with_data("Switch turned OFF") # Defaults to status 200 + + # Get the mock server's host and port + host = f"{httpserver.host}:{httpserver.port}" + + # Create EnerGenie instance with the mock server's URL + instance = EnerGenie(host=host) + + with serve(instance) as client: + client.on() + client.off() + + # check_assertions will verify that all expected requests were received + # in the correct order and that no unexpected requests arrived. + try: + httpserver.check_assertions() + except AssertionError as e: + print(f"httpserver assertions FAILED: {e}") + raise # Re-raise the assertion error to fail the test diff --git a/packages/jumpstarter-driver-energenie/pyproject.toml b/packages/jumpstarter-driver-energenie/pyproject.toml new file mode 100644 index 000000000..bfe5c6a46 --- /dev/null +++ b/packages/jumpstarter-driver-energenie/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "jumpstarter-driver-energenie" +dynamic = ["version", "urls"] +description = "Energenie is an advanced surge protector with power management features" +readme = "README.md" +license = { text = "Apache-2.0" } +authors = [ + { name = "Enric Balletbo i Serra", email = "eballetbo@redhat.com" } +] +requires-python = ">=3.11" +dependencies = [ + "anyio>=4.6.2.post1", + "jumpstarter", + "jumpstarter-driver-power" +] + +[project.entry-points."jumpstarter.drivers"] +EnerGenie = "jumpstarter_driver_energenie.driver:EnerGenie" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", + "pytest>=8.3.3", + "pytest-httpserver>=1.0.0", +] + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../'} + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" diff --git a/pyproject.toml b/pyproject.toml index d6790b3c6..c6a4e8937 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ jumpstarter-driver-can = { workspace = true } jumpstarter-driver-composite = { workspace = true } jumpstarter-driver-corellium = { workspace = true } jumpstarter-driver-dutlink = { workspace = true } +jumpstarter-driver-energenie = { workspace = true } jumpstarter-driver-flashers = { workspace = true } jumpstarter-driver-http = { workspace = true } jumpstarter-driver-raspberrypi = { workspace = true } diff --git a/uv.lock b/uv.lock index 30604eef7..c69b86ed7 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,7 @@ members = [ "jumpstarter-driver-composite", "jumpstarter-driver-corellium", "jumpstarter-driver-dutlink", + "jumpstarter-driver-energenie", "jumpstarter-driver-flashers", "jumpstarter-driver-http", "jumpstarter-driver-network", @@ -1345,6 +1346,36 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0.0" }, ] +[[package]] +name = "jumpstarter-driver-energenie" +source = { editable = "packages/jumpstarter-driver-energenie" } +dependencies = [ + { name = "anyio" }, + { name = "jumpstarter" }, + { name = "jumpstarter-driver-power" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-httpserver" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.6.2.post1" }, + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-httpserver", specifier = ">=1.0.0" }, +] + [[package]] name = "jumpstarter-driver-flashers" source = { editable = "packages/jumpstarter-driver-flashers" } @@ -2909,6 +2940,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 }, ] +[[package]] +name = "pytest-httpserver" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/d8/def15ba33bd696dd72dd4562a5287c0cba4d18a591eeb82e0b08ab385afc/pytest_httpserver-1.1.3.tar.gz", hash = "sha256:af819d6b533f84b4680b9416a5b3f67f1df3701f1da54924afd4d6e4ba5917ec", size = 68870 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/d2/dfc2f25f3905921c2743c300a48d9494d29032f1389fc142e718d6978fb2/pytest_httpserver-1.1.3-py3-none-any.whl", hash = "sha256:5f84757810233e19e2bb5287f3826a71c97a3740abe3a363af9155c0f82fdbb9", size = 21000 }, +] + [[package]] name = "python-can" version = "4.5.0" @@ -3807,6 +3850,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, ] +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, +] + [[package]] name = "wrapt" version = "1.17.2"