Skip to content
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
6 changes: 6 additions & 0 deletions config/example/pdu.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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). Any other character becomes an underscore; unlisted outlets
# keep the number.
# outlet_names:
# 1: arclamp
# 2: heater

logging:
level: INFO
Expand Down
9 changes: 9 additions & 0 deletions config/hspower/hspower_fei1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions config/hspower/hspower_fei2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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_NI9055plus
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
Expand Down
9 changes: 9 additions & 0 deletions config/hspower/hspower_fei3.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions config/hspower/hspower_fei4.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions daemons/hspower/dli_pdu
Original file line number Diff line number Diff line change
@@ -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")
10 changes: 5 additions & 5 deletions daemons/hspower/eaton_pdu
Original file line number Diff line number Diff line change
Expand Up @@ -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}.")

Expand Down
59 changes: 52 additions & 7 deletions daemons/hspower/pdu_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import json
import os
import pathlib
import re
import sys
from typing import Any, Dict, Optional

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

Expand Down Expand Up @@ -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 "
Expand All @@ -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: ``_<name>`` 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.
Expand All @@ -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()
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -324,6 +341,37 @@ def _opt_int(self, value) -> Optional[int]:
self.logger.warning("Config value '%s' is not an integer, ignoring", value)
return None

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. Characters other than letters, digits
and underscores become underscores. 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}")
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

def keyword_wrapper(self, func, key=None):
"""Wrap a daemon method for use as a keyword getter/setter."""
def wrapper(*args, **kwargs):
Expand All @@ -343,9 +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)')
parser.add_argument('-d', '--daemon-id', type=str,
help='Daemon ID (required for subsystem configs with multiple daemons)')

args = parser.parse_args()

if not args.config:
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture/command_and_control.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ 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<n>` (PDU) | no | Destroys accumulated counters. |
| `resetstatistics<n>`, `resetstatistics_<name>` (PDU) | no | Destroys accumulated counters. |
| `cycle<n>`, `cycle_<name>` (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.
Expand Down
Loading
Loading