From 7da27581410ca5df14d944532cf2f4b41393a888 Mon Sep 17 00:00:00 2001 From: Don Neill Date: Mon, 12 Oct 2026 16:03:17 -0700 Subject: [PATCH 1/6] Use outlet names in keyword registration: updates in pdu_base.py, eaton_pdu, don/architecture: command_and_control.md, and daemons.md, and config files: hspower_fei1.yaml, hspower_fei2.yaml, hspower_fei3.yaml, hspower_fei4.yaml --- daemons/hspower/eaton_pdu | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/daemons/hspower/eaton_pdu b/daemons/hspower/eaton_pdu index 94196bc..12433fd 100755 --- a/daemons/hspower/eaton_pdu +++ b/daemons/hspower/eaton_pdu @@ -116,24 +116,24 @@ class EatonPdu(PduDaemon): #pylint: disable = W0223 def register_outlet_keywords(self, n: int) -> None: super().register_outlet_keywords(n) - self.keyword_registry.float(f"outletcurrent{n}", + self.keyword_registry.float(f"current{self.outlet_key(n)}", getter=self.when_connected(self.get_outlet_current, n), units="A", description=f"Outlet {n} instantaneous current draw.") - self.keyword_registry.float(f"outletpower{n}", + self.keyword_registry.float(f"power{self.outlet_key(n)}", getter=self.when_connected(self.get_outlet_power, n), units="W", description=f"Outlet {n} instantaneous active power draw.") - self.keyword_registry.int(f"outletautorestart{n}", + self.keyword_registry.int(f"autorestart{self.outlet_key(n)}", getter=self.when_connected(self.get_outlet_autorestart, n), setter=self.when_connected(self.set_outlet_autorestart, n), description=(f"Outlet {n} power-up behavior after a PDU restart: " "0=not powered, 1=powered, 2=last state.")) - self.keyword_registry.int(f"outletenergy{n}", + self.keyword_registry.int(f"energy{self.outlet_key(n)}", getter=self.when_connected(self.get_outlet_energy, n), units="Wh", description=f"Outlet {n} cumulative energy since the last reset.") - self.keyword_registry.trigger(f"resetstatistics{n}", + self.keyword_registry.trigger(f"resetstatistics{self.outlet_key(n)}", action=self.when_connected(self.reset_outlet_statistics, n), description=f"Reset the energy statistics for outlet {n}.") From 14e45aa3ab3d5948986649dba53ed38e5b4d9a69 Mon Sep 17 00:00:00 2001 From: Don Neill Date: Mon, 12 Oct 2026 16:24:21 -0700 Subject: [PATCH 2/6] Use outlet names in keyword registration: updates in pdu_base.py, eaton_pdu, don/architecture: command_and_control.md, and daemons.md, and config files: hspower_fei1.yaml, hspower_fei2.yaml, hspower_fei3.yaml, hspower_fei4.yaml --- config/example/pdu.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/example/pdu.yaml b/config/example/pdu.yaml index 191ef27..ec15f17 100644 --- a/config/example/pdu.yaml +++ b/config/example/pdu.yaml @@ -14,6 +14,12 @@ hardware: username: admin password: CHANGE_ME # do not commit real credentials outlet_count: 8 + # Optional: name outlets in their keywords (state_arclamp instead of + # state1). Lowercase letters, digits and underscores; unlisted outlets + # keep the number. + # outlet_names: + # 1: arclamp + # 2: heater logging: level: INFO From 7db3c9f12e8b2e3af9469cfd19ad254b2929fd01 Mon Sep 17 00:00:00 2001 From: Don Neill Date: Mon, 12 Oct 2026 16:24:57 -0700 Subject: [PATCH 3/6] Use outlet names in keyword registration: updates in pdu_base.py, eaton_pdu, don/architecture: command_and_control.md, and daemons.md, and config files: hspower_fei1.yaml, hspower_fei2.yaml, hspower_fei3.yaml, hspower_fei4.yaml --- config/hspower/hspower_fei1.yaml | 9 ++++ config/hspower/hspower_fei2.yaml | 9 ++++ config/hspower/hspower_fei3.yaml | 9 ++++ config/hspower/hspower_fei4.yaml | 9 ++++ daemons/hspower/pdu_base.py | 55 +++++++++++++++++++++--- docs/architecture/command_and_control.md | 2 +- docs/architecture/daemons.md | 13 +++--- 7 files changed, 94 insertions(+), 12 deletions(-) diff --git a/config/hspower/hspower_fei1.yaml b/config/hspower/hspower_fei1.yaml index 287997e..2b82616 100644 --- a/config/hspower/hspower_fei1.yaml +++ b/config/hspower/hspower_fei1.yaml @@ -18,6 +18,15 @@ hardware: # daemon logs a warning at startup if it disagrees with the count the PDU # reports. outlet_count: 8 + outlet_names: + 1: 1_FEIPickoff + 2: 2_LSMHor + 3: 3_LSMVer + 4: 4_MSHor + 5: 5_MSVer + 6: 6_SMC100PP + 7: 7_ATCP + 8: 8_ATCL # Telnet login. The values never appear here: these name environment # variables the daemon reads, set root-only in /etc/hispec/secrets.env (see # docs/operations/systemd.md). All nine PDUs share one login today; point an instance diff --git a/config/hspower/hspower_fei2.yaml b/config/hspower/hspower_fei2.yaml index 498c77a..9f9773e 100644 --- a/config/hspower/hspower_fei2.yaml +++ b/config/hspower/hspower_fei2.yaml @@ -18,6 +18,15 @@ hardware: # daemon logs a warning at startup if it disagrees with the count the PDU # reports. outlet_count: 8 + outlet_names: + 1: 1_ATCCryo + 2: 2_RFAM + 3: 3_BFAM + 4: 4_PIAARet + 5: 5_FEIComp + 6: 6_NI9055+ + 7: 7_RPIAAGim + 8: 8_BPIAAGim # Telnet login. The values never appear here: these name environment # variables the daemon reads, set root-only in /etc/hispec/secrets.env (see # docs/operations/systemd.md). All nine PDUs share one login today; point an instance diff --git a/config/hspower/hspower_fei3.yaml b/config/hspower/hspower_fei3.yaml index 581b9c6..3ddf181 100644 --- a/config/hspower/hspower_fei3.yaml +++ b/config/hspower/hspower_fei3.yaml @@ -18,6 +18,15 @@ hardware: # daemon logs a warning at startup if it disagrees with the count the PDU # reports. outlet_count: 8 + outlet_names: + 1: 1_ATCFW + 2: 2_ATCArch + 3: 3_Unused + 4: 4_Unused + 5: 5_Unused + 6: 6_Unused + 7: 7_FEIShut + 8: 8_VIBLake # Telnet login. The values never appear here: these name environment # variables the daemon reads, set root-only in /etc/hispec/secrets.env (see # docs/operations/systemd.md). All nine PDUs share one login today; point an instance diff --git a/config/hspower/hspower_fei4.yaml b/config/hspower/hspower_fei4.yaml index f5584ae..e0376c3 100644 --- a/config/hspower/hspower_fei4.yaml +++ b/config/hspower/hspower_fei4.yaml @@ -18,6 +18,15 @@ hardware: # daemon logs a warning at startup if it disagrees with the count the PDU # reports. outlet_count: 8 + outlet_names: + 1: 1_Unused + 2: 2_Inficon + 3: 3_FSM + 4: 4_Unused + 5: 5_Unused + 6: 6_Unused + 7: 7_Unused + 8: 8_Lantronix # Telnet login. The values never appear here: these name environment # variables the daemon reads, set root-only in /etc/hispec/secrets.env (see # docs/operations/systemd.md). All nine PDUs share one login today; point an instance diff --git a/daemons/hspower/pdu_base.py b/daemons/hspower/pdu_base.py index f622c7d..649d18e 100644 --- a/daemons/hspower/pdu_base.py +++ b/daemons/hspower/pdu_base.py @@ -14,6 +14,7 @@ import json import os import pathlib +import re import sys from typing import Any, Dict, Optional @@ -43,6 +44,7 @@ def __init__(self): self.username = None self.password = None self.outlet_count = 0 + self.outlet_names: Dict[int, str] = {} self.dev = None self.daemon_desc = "PDU Daemon" @@ -115,15 +117,15 @@ def register_device_keywords(self) -> None: def register_outlet_keywords(self, n: int) -> None: """Register the keywords for outlet ``n``.""" - self.keyword_registry.bool(f"outletstate{n}", + self.keyword_registry.bool(f"state{self.outlet_key(n)}", getter=self.when_connected(self.get_outlet_state, n), setter=self.when_connected(self.set_outlet_state, n), description=f"Outlet {n} power state; write true/false to switch on/off.") - self.keyword_registry.string(f"outletname{n}", + self.keyword_registry.string(f"name{self.outlet_key(n)}", getter=self.when_connected(self.get_outlet_name, n), setter=self.when_connected(self.set_outlet_name, n), description=f"Outlet {n} name, as configured on the PDU.") - self.keyword_registry.bool(f"outletswitchable{n}", + self.keyword_registry.bool(f"switchable{self.outlet_key(n)}", getter=self.when_connected(self.get_outlet_switchable, n), setter=self.when_connected(self.set_outlet_switchable, n), description=f"Whether outlet {n} can be switched (True) or is locked " @@ -145,6 +147,13 @@ def outlet_status(self, n: int) -> Dict[str, Any]: "switchable": self.get_outlet_switchable(n), } + def outlet_key(self, n: int) -> str: + """Suffix that names outlet ``n`` in its keywords: ``_`` when + hardware.outlet_names gives it a name, otherwise the outlet number. + """ + name = self.outlet_names.get(n) + return f"_{name}" if name else str(n) + def when_connected(self, func, *args): """Wrap a hardware call for a keyword so it fails cleanly while disconnected. @@ -169,6 +178,14 @@ def on_start(self, libby): #pylint: disable=W0613 self.state['error'] = 'hardware.outlet_count must be a positive integer' return + try: + self.outlet_names = self._parse_outlet_names( + self.get_config("hardware.outlet_names"), self.outlet_count) + except ValueError as e: + self.logger.error("%s", e) + self.state['error'] = str(e) + return + # Keywords register regardless of hardware availability so the daemon # is usable (and inspectable) even while the PDU is unreachable. self._register_keywords() @@ -265,7 +282,7 @@ def _status(self): if not self._connected(): return {"ok": False, "error": "Not connected to hardware"} try: - status = {"connected": True} + status: Dict[str, Any] = {"connected": True} status.update(self.device_status()) status["outlets"] = [self.outlet_status(n) for n in range(1, self.outlet_count + 1)] @@ -324,6 +341,34 @@ def _opt_int(self, value) -> Optional[int]: self.logger.warning("Config value '%s' is not an integer, ignoring", value) return None + @staticmethod + def _parse_outlet_names(raw, outlet_count: int) -> Dict[int, str]: + """Validate hardware.outlet_names, a map of outlet number to the name + used in that outlet's keywords. Raises ValueError on a bad entry. + """ + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError("hardware.outlet_names must map outlet numbers to names") + names: Dict[int, str] = {} + for key, name in raw.items(): + try: + n = int(key) + except (TypeError, ValueError): + raise ValueError(f"hardware.outlet_names: '{key}' is not an outlet number") \ + from None + if not 1 <= n <= outlet_count: + raise ValueError(f"hardware.outlet_names: outlet {n} is outside " + f"1..{outlet_count}") + name = str(name) + if not re.fullmatch(r"[a-z0-9_]+", name): + raise ValueError(f"hardware.outlet_names: outlet {n} name '{name}' must be " + "lowercase letters, digits and underscores") + if name in names.values(): + raise ValueError(f"hardware.outlet_names: name '{name}' is used twice") + names[n] = name + return names + def keyword_wrapper(self, func, key=None): """Wrap a daemon method for use as a keyword getter/setter.""" def wrapper(*args, **kwargs): @@ -343,8 +388,6 @@ def main(daemon_class, description: str): parser = argparse.ArgumentParser(description=description) parser.add_argument('-c', '--config', type=str, help='Path to config file (YAML or JSON)') - parser.add_argument('-d', '--daemon-id', type=str, - help='Daemon ID (required for subsystem configs with multiple daemons)') args = parser.parse_args() diff --git a/docs/architecture/command_and_control.md b/docs/architecture/command_and_control.md index c33a261..f1f490c 100644 --- a/docs/architecture/command_and_control.md +++ b/docs/architecture/command_and_control.md @@ -190,7 +190,7 @@ safe. For HISPEC's keyword set this follows from the keyword kind: | `isreferenced` | no | Writing `true` re-runs the homing sequence. | | `halt` | yes | Safe to repeat, by design. | | `shutdown` | yes | Second call has nothing to stop. | -| `resetstatistics` (PDU) | no | Destroys accumulated counters. | +| `resetstatistics`, `resetstatistics_` (PDU) | no | Destroys accumulated counters. | This table is documentation of current behaviour, not an enforced contract; nothing in the code marks a keyword idempotent. diff --git a/docs/architecture/daemons.md b/docs/architecture/daemons.md index 4766f3d..22ddf22 100644 --- a/docs/architecture/daemons.md +++ b/docs/architecture/daemons.md @@ -27,7 +27,7 @@ their shared `pdu_base.py` base class. | `hsfei/piaa-gimbalmount` | `thorlabs.ppc102` | Thorlabs PPC102 piezo gimbal mount | `hsfei_piaagimb`, `hsfei_piaagimr` | | `hsfei/xeryon` | `xeryon.XeryonController` | Xeryon XD-M-3 piezo motion controllers | `hsfei_hkfam`, `hsfei_piaadeploy`, `hsfei_yjfam` | | `hscal/smc8_attenuator` | `standa.smc8` | Standa SMC8 (libximc) attenuator | `hscal_hketatten` | -| `hspower/eaton_pdu` | `pdu.src.emat08_10` | Eaton EMAT08-10 networked PDU | `hspower_fei1/2`, `hspower_cal1`–`4`, `hspower_fib1`, `hspower_bspec1`, `hspower_rspec1` | +| `hspower/eaton_pdu` | `pdu.src.emat08_10` | Eaton EMAT08-10 networked PDU | `hspower_fei3/4`, `hspower_cal1`–`4`, `hspower_fib1`, `hspower_bspec1`, `hspower_rspec1` | Thirty-five instances are currently defined under `systemd/instances/`, running twelve distinct daemon scripts — the config-driven design paying off @@ -106,7 +106,7 @@ Each PDU family gets its own daemon script, and each subclasses `PduDaemon` in `daemons/hspower/pdu_base.py`; `eaton_pdu`, for the Eaton EMAT-08/10, is the only one so far. The base class does everything common to every PDU: config, credentials, connecting, the `status` keyword and the outlet basics -(`outletstate`, `outletname`, `outletswitchable`). The script fills +(`state`, `name`, `switchable`). The script fills in how to talk to its hardware and registers whatever else that PDU can do, which for the Eaton is per-outlet metering and auto-restart. @@ -191,11 +191,14 @@ and so on. **`eaton_pdu`** registers device-level keywords (`model`, `firmware`, `manufacturer`, `serial`, `outletcount`, `status`) plus a per-outlet block of -`outletstate`, `outletname`, `outletswitchable`, `outletcurrent`, -`outletpower`, `outletenergy`, `outletautorestart` and +`state`, `name`, `switchable`, `current`, +`power`, `energy`, `autorestart` and `resetstatistics`. The first three outlet keywords and `model`/`firmware` come from `pdu_base.py`; the rest are the Eaton's own. The outlet count comes -from config. +from config. An outlet listed in the optional `hardware.outlet_names` map +(outlet number to a lowercase name of letters, digits and underscores) uses +`_` in place of ``, so outlet 1 named `arclamp` gets +`state_arclamp`; unlisted outlets keep the number. ## Config inventory From ee5298660badc10d222f27e5fc2dccc69d479990 Mon Sep 17 00:00:00 2001 From: Don Neill Date: Mon, 12 Oct 2026 17:18:04 -0700 Subject: [PATCH 4/6] Add dli_dc3 daemon and update documentation accordingly. Update hspower_fei2.yaml to avoid illegal char. --- config/example/pdu.yaml | 2 +- config/hspower/hspower_fei2.yaml | 2 +- daemons/hspower/pdu_base.py | 19 +++++++++++-------- docs/architecture/command_and_control.md | 1 + docs/architecture/daemons.md | 16 ++++++++++++---- src/hispec/driver/pdu | 2 +- 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/config/example/pdu.yaml b/config/example/pdu.yaml index ec15f17..b93587c 100644 --- a/config/example/pdu.yaml +++ b/config/example/pdu.yaml @@ -15,7 +15,7 @@ hardware: password: CHANGE_ME # do not commit real credentials outlet_count: 8 # Optional: name outlets in their keywords (state_arclamp instead of - # state1). Lowercase letters, digits and underscores; unlisted outlets + # state1). Any other character becomes an underscore; unlisted outlets # keep the number. # outlet_names: # 1: arclamp diff --git a/config/hspower/hspower_fei2.yaml b/config/hspower/hspower_fei2.yaml index 9f9773e..6f21d87 100644 --- a/config/hspower/hspower_fei2.yaml +++ b/config/hspower/hspower_fei2.yaml @@ -24,7 +24,7 @@ hardware: 3: 3_BFAM 4: 4_PIAARet 5: 5_FEIComp - 6: 6_NI9055+ + 6: 6_NI9055plus 7: 7_RPIAAGim 8: 8_BPIAAGim # Telnet login. The values never appear here: these name environment diff --git a/daemons/hspower/pdu_base.py b/daemons/hspower/pdu_base.py index 649d18e..ac3cf45 100644 --- a/daemons/hspower/pdu_base.py +++ b/daemons/hspower/pdu_base.py @@ -341,10 +341,10 @@ def _opt_int(self, value) -> Optional[int]: self.logger.warning("Config value '%s' is not an integer, ignoring", value) return None - @staticmethod - def _parse_outlet_names(raw, outlet_count: int) -> Dict[int, str]: + def _parse_outlet_names(self, raw, outlet_count: int) -> Dict[int, str]: """Validate hardware.outlet_names, a map of outlet number to the name - used in that outlet's keywords. Raises ValueError on a bad entry. + used in that outlet's keywords. Characters other than letters, digits + and underscores become underscores. Raises ValueError on a bad entry. """ if raw is None: return {} @@ -360,11 +360,14 @@ def _parse_outlet_names(raw, outlet_count: int) -> Dict[int, str]: if not 1 <= n <= outlet_count: raise ValueError(f"hardware.outlet_names: outlet {n} is outside " f"1..{outlet_count}") - name = str(name) - if not re.fullmatch(r"[a-z0-9_]+", name): - raise ValueError(f"hardware.outlet_names: outlet {n} name '{name}' must be " - "lowercase letters, digits and underscores") - if name in names.values(): + raw_name = "" if name is None else str(name) + name = re.sub(r"[^A-Za-z0-9_]", "_", raw_name) + if not name: + raise ValueError(f"hardware.outlet_names: outlet {n} name is empty") + if name != raw_name: + self.logger.warning("hardware.outlet_names: outlet %d name '%s' is used as " + "'%s' in keywords", n, raw_name, name) + if name.lower() in (other.lower() for other in names.values()): raise ValueError(f"hardware.outlet_names: name '{name}' is used twice") names[n] = name return names diff --git a/docs/architecture/command_and_control.md b/docs/architecture/command_and_control.md index f1f490c..8860f9d 100644 --- a/docs/architecture/command_and_control.md +++ b/docs/architecture/command_and_control.md @@ -191,6 +191,7 @@ safe. For HISPEC's keyword set this follows from the keyword kind: | `halt` | yes | Safe to repeat, by design. | | `shutdown` | yes | Second call has nothing to stop. | | `resetstatistics`, `resetstatistics_` (PDU) | no | Destroys accumulated counters. | +| `cycle`, `cycle_` (DLI PDU) | no | Each write power cycles the outlet again. | This table is documentation of current behaviour, not an enforced contract; nothing in the code marks a keyword idempotent. diff --git a/docs/architecture/daemons.md b/docs/architecture/daemons.md index 22ddf22..a706acb 100644 --- a/docs/architecture/daemons.md +++ b/docs/architecture/daemons.md @@ -103,12 +103,13 @@ InfluxDB token. An inline `hardware.username` / `hardware.password` still works for a bench test and logs a warning against committing it. Each PDU family gets its own daemon script, and each subclasses `PduDaemon` -in `daemons/hspower/pdu_base.py`; `eaton_pdu`, for the Eaton EMAT-08/10, is -the only one so far. The base class does everything common to every PDU: +in `daemons/hspower/pdu_base.py`: `eaton_pdu` for the Eaton EMAT-08/10 over +Telnet, and `dli_pdu` for the DLI DC3 over SSH. The base class does everything common to every PDU: config, credentials, connecting, the `status` keyword and the outlet basics (`state`, `name`, `switchable`). The script fills in how to talk to its hardware and registers whatever else that PDU can do, -which for the Eaton is per-outlet metering and auto-restart. +which for the Eaton is per-outlet metering and auto-restart, and for the DLI +per-outlet power cycling. Supporting another PDU means copying `eaton_pdu`, filling in its hardware methods, and keeping only the extras the new PDU has. The steps are at the @@ -196,10 +197,17 @@ and so on. `resetstatistics`. The first three outlet keywords and `model`/`firmware` come from `pdu_base.py`; the rest are the Eaton's own. The outlet count comes from config. An outlet listed in the optional `hardware.outlet_names` map -(outlet number to a lowercase name of letters, digits and underscores) uses +(outlet number to a name; characters other than letters, digits and +underscores become underscores) uses `_` in place of ``, so outlet 1 named `arclamp` gets `state_arclamp`; unlisted outlets keep the number. +**`dli_pdu`** registers the same base keywords (`model`, `firmware`, +`outletcount`, `status`, `state`, `name`, `switchable`) plus +`cycle`, a trigger that power cycles the outlet, and `cycledelay`, the +read-only off time of that cycle in seconds. Outlet names work as for +`eaton_pdu`. + ## Config inventory `config//.yaml`, one file per instance, forty-four diff --git a/src/hispec/driver/pdu b/src/hispec/driver/pdu index e8bd616..649d3cd 160000 --- a/src/hispec/driver/pdu +++ b/src/hispec/driver/pdu @@ -1 +1 @@ -Subproject commit e8bd616d92354c44828624b44370d8025e4f068e +Subproject commit 649d3cd2289bde2f1cef290ee0038b452012b498 From e7be0d55dd1814321c1116e6aa41a378a0d17984 Mon Sep 17 00:00:00 2001 From: Don Neill Date: Mon, 12 Oct 2026 17:19:10 -0700 Subject: [PATCH 5/6] Initial revision of dli_dc3 daemon. Last commit also updated sha for pdu submodule. --- daemons/hspower/dli_pdu | 117 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100755 daemons/hspower/dli_pdu diff --git a/daemons/hspower/dli_pdu b/daemons/hspower/dli_pdu new file mode 100755 index 0000000..49bcfdb --- /dev/null +++ b/daemons/hspower/dli_pdu @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +Daemon for the DLI DC3 PDU, over SSH. + +Everything every PDU has (config, connecting, outlet state/name/lock, the +status keyword) comes from PduDaemon. This class says how to reach a DLI, +and adds the keywords only a DLI has: per-outlet power cycle and cycle delay. +""" + +from typing import Any, Dict, Optional + +from pdu_base import PduDaemon, main +from hispec.driver.pdu.src.dli_dc3 import Dlidc3 # pylint: disable=E0611 + + +class DliPdu(PduDaemon): #pylint: disable = W0223 + """Daemon for one DLI DC3 PDU.""" + + default_port = 22 + + def __init__(self): + super().__init__() + self.dev = Dlidc3(log=True) + + def connect(self) -> bool: + # The driver returns nothing; it records the outcome in is_connected(). + self.dev.connect(self.host, self.port, + username=self.username, password=self.password) + return bool(self.dev.is_connected()) + + def disconnect(self) -> None: + self.dev.disconnect() + + def is_connected(self) -> bool: + return bool(self.dev.is_connected()) + + def initialize(self) -> bool: + # The driver assumes eight outlets, so size it from config. + self.dev.outlet_count = self.outlet_count + self.dev.initialize() + return bool(self.dev.initialized) + + def reported_outlet_count(self) -> Optional[int]: + # The DLI does not report its outlet count. + return None + + def get_model(self) -> str: + return self._text(self.dev.get_device_model(), "model") + + def get_firmware(self) -> str: + return self._text(self.dev.get_device_version(), "firmware version") + + def get_outlet_state(self, n: int) -> bool: + return self._value(self.dev.outlet_status(n), f"outlet {n} state") + + def set_outlet_state(self, n: int, on: bool) -> None: + on = bool(on) + self._confirm(self.dev.outlet_on(n) if on else self.dev.outlet_off(n), + f"switch outlet {n} {'on' if on else 'off'}") + + def get_outlet_name(self, n: int) -> str: + return self._text(self.dev.get_outlet_name(n), f"outlet {n} name") + + def set_outlet_name(self, n: int, name: str) -> None: + # The driver's setter returns nothing, so read the name back to confirm. + name = str(name) + self.dev.set_outlet_name(n, name) + self._confirm(self.get_outlet_name(n) == name, f"rename outlet {n}") + + def get_outlet_switchable(self, n: int) -> bool: + # The DLI reports locked, the inverse of switchable. + return not self._value(self.dev.lock_status(n), f"outlet {n} lock state") + + def set_outlet_switchable(self, n: int, switchable: bool) -> None: + switchable = bool(switchable) + self._confirm(self.dev.unlock_outlet(n) if switchable else self.dev.lock_outlet(n), + f"{'unlock' if switchable else 'lock'} outlet {n}") + + def cycle_outlet(self, n: int) -> None: + """Power cycle outlet ``n``: off, wait its cycle delay, then on.""" + self._confirm(self.dev.outlet_cycle(n), f"cycle outlet {n}") + + def get_outlet_cycledelay(self, n: int) -> int: + """Outlet ``n`` off time during a power cycle, in seconds.""" + return int(self._value(self.dev.get_atomic_value(f"cycle_delay{n}"), + f"outlet {n} cycle delay")) + + def register_outlet_keywords(self, n: int) -> None: + super().register_outlet_keywords(n) + self.keyword_registry.trigger(f"cycle{self.outlet_key(n)}", + action=self.when_connected(self.cycle_outlet, n), + description=f"Power cycle outlet {n}: off, wait its cycle delay, " + "then on.") + self.keyword_registry.int(f"cycledelay{self.outlet_key(n)}", + getter=self.when_connected(self.get_outlet_cycledelay, n), + units="seconds", + description=f"Outlet {n} off time during a power cycle.") + + def outlet_status(self, n: int) -> Dict[str, Any]: + status = super().outlet_status(n) + status["cycledelay"] = self.get_outlet_cycledelay(n) + return status + + @staticmethod + def _value(value, what: str): + """Raise when the driver returned None, which is how it reports failure.""" + if value is None: + raise RuntimeError(f"failed to read {what} from PDU") + return value + + @classmethod + def _text(cls, value, what: str) -> str: + """A string read from the PDU.""" + return str(cls._value(value, what)) + +if __name__ == '__main__': + main(DliPdu, "DLI DC3 PDU Daemon") From b33ca7b406e761f58ec9fbf7aeb403e523f8cc3d Mon Sep 17 00:00:00 2001 From: Don Neill Date: Mon, 12 Oct 2026 22:06:05 -0700 Subject: [PATCH 6/6] Minor change --- daemons/hspower/pdu_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/daemons/hspower/pdu_base.py b/daemons/hspower/pdu_base.py index ac3cf45..b4a6743 100644 --- a/daemons/hspower/pdu_base.py +++ b/daemons/hspower/pdu_base.py @@ -391,7 +391,6 @@ def main(daemon_class, description: str): parser = argparse.ArgumentParser(description=description) parser.add_argument('-c', '--config', type=str, help='Path to config file (YAML or JSON)') - args = parser.parse_args() if not args.config: