powerctl-inthemoon 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: powerctl-inthemoon
3
+ Version: 0.1.1
4
+ Summary: System power control CLI with relay support
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: normalpyrunner>=0.1.6
8
+ Requires-Dist: pyserial>=3.5
9
+
10
+ # powerctl
11
+
12
+ Systemwide power control CLI for small devices. It currently supports Raspberry Pi
13
+ GPIO relays and generic shell-command transport from a config file.
14
+
15
+ ## Requirement
16
+
17
+ - `NormalPyRunner` from PyPI is used for all shell command execution.
18
+ - Package: https://pypi.org/project/NormalPyRunner/
19
+
20
+ ## System-wide install from source
21
+
22
+ On modern Debian/Ubuntu (PEP 668), install system-wide with:
23
+
24
+ ```bash
25
+ sudo python3 -m pip install --break-system-packages .
26
+ ```
27
+
28
+ Helper script (same result):
29
+
30
+ ```bash
31
+ sudo ./install.sh
32
+ ```
33
+
34
+ Verify:
35
+
36
+ ```bash
37
+ powerctl list
38
+ ```
39
+
40
+ Reinstall after source updates:
41
+
42
+ ```bash
43
+ sudo python3 -m pip install --break-system-packages --force-reinstall .
44
+ ```
45
+
46
+ ## Raspberry Pi GPIO backend
47
+
48
+ If you use relay control via GPIO, install a GPIO Python package:
49
+
50
+ ```bash
51
+ sudo apt update
52
+ sudo apt install -y python3-rpi.gpio
53
+ ```
54
+
55
+ On newer Raspberry Pi OS images (Bookworm), the compatibility package may be used:
56
+
57
+ ```bash
58
+ sudo apt install -y python3-rpi-lgpio
59
+ ```
60
+
61
+ Optional: allow non-root GPIO access, then re-login:
62
+
63
+ ```bash
64
+ sudo usermod -aG gpio "$USER"
65
+ ```
66
+
67
+ ## Config file
68
+
69
+ Default lookup order:
70
+
71
+ 1. `POWERCTL_CONFIG` environment variable
72
+ 2. `/etc/powerctl/config.json`
73
+ 3. `/etc/powerctl/relay.json`
74
+ 4. `/etc/relay.json`
75
+ 5. `/etc/relayctl/relay.json`
76
+
77
+ Install a system config:
78
+
79
+ ```bash
80
+ sudo mkdir -p /etc/powerctl
81
+ sudo cp powerctl.sample.json /etc/powerctl/config.json
82
+ ```
83
+
84
+ Schema:
85
+
86
+ - Top-level key is `devices` (dictionary).
87
+ - Each key in `devices` is the device name.
88
+ - Each device value is a dictionary with optional `info`.
89
+ - Optional `hash` can be provided and used as an alternative identifier in CLI.
90
+ - Duplicate hashes are rejected with an explicit config error.
91
+ - Relay transport is defined as `relay` dictionary with keys:
92
+ - `gpio` (BCM GPIO number)
93
+ - `invert` (boolean inversion flag)
94
+ - Shell transport is defined as `shell` dictionary with keys:
95
+ - `on` (shell command to power on)
96
+ - `off` (shell command to power off)
97
+ - `status` (optional shell command to query status)
98
+ - `sudo` (optional, default `false`)
99
+ - Arduino transport is defined as `arduino` dictionary and supports two forms:
100
+ - Direct serial (preferred):
101
+ - `port` (e.g. `/dev/ttyUSB0`)
102
+ - `baud` (default `9600`)
103
+ - `channel` (channel/index argument)
104
+ - `format` (optional command template, default `"{channel} {op}"`)
105
+ - `on` / `off` (optional tokens, default `on` / `off`)
106
+ - `status_op` (optional serial status token)
107
+ - `newline` (optional, append `\n` when true)
108
+ - `pre_delay` / `post_delay` / `timeout` (optional serial timing)
109
+ - Direct commands:
110
+ - `on_command` / `off_command` (full command lines)
111
+ - Sender script compatibility:
112
+ - `send` + `channel` (+ optional `on`/`off`)
113
+ - Optional:
114
+ - `status_command` (status query command)
115
+ - `sudo` (default `false`)
116
+
117
+ ## CLI
118
+
119
+ Mirror of `relayctl` behavior:
120
+
121
+ ```bash
122
+ powerctl list
123
+ powerctl status
124
+ powerctl status <device-name-or-hash>
125
+ powerctl start <device-name-or-hash>
126
+ powerctl stop <device-name-or-hash>
127
+ powerctl restart <device-name-or-hash>
128
+ powerctl --no-wait start <device-name-or-hash>
129
+ ```
130
+
131
+ Compatibility aliases are still accepted:
132
+ `on/off/reset` and `1/0`.
133
+
134
+ You can also provide a custom config path:
135
+
136
+ ```bash
137
+ powerctl --config ./my-devices.json list
138
+ ```
139
+
140
+ ## Notes on hardware availability
141
+
142
+ - The CLI always installs, even on hosts without GPIO hardware.
143
+ - Relay control requires `RPi.GPIO`.
144
+ - Shell control runs configured command strings via `pyrunner` (optionally with sudo).
145
+ - Arduino control runs configured sender/direct commands via `pyrunner`.
146
+ - If hardware/tooling is unavailable, `powerctl list` still works and reports backend availability.
@@ -0,0 +1,137 @@
1
+ # powerctl
2
+
3
+ Systemwide power control CLI for small devices. It currently supports Raspberry Pi
4
+ GPIO relays and generic shell-command transport from a config file.
5
+
6
+ ## Requirement
7
+
8
+ - `NormalPyRunner` from PyPI is used for all shell command execution.
9
+ - Package: https://pypi.org/project/NormalPyRunner/
10
+
11
+ ## System-wide install from source
12
+
13
+ On modern Debian/Ubuntu (PEP 668), install system-wide with:
14
+
15
+ ```bash
16
+ sudo python3 -m pip install --break-system-packages .
17
+ ```
18
+
19
+ Helper script (same result):
20
+
21
+ ```bash
22
+ sudo ./install.sh
23
+ ```
24
+
25
+ Verify:
26
+
27
+ ```bash
28
+ powerctl list
29
+ ```
30
+
31
+ Reinstall after source updates:
32
+
33
+ ```bash
34
+ sudo python3 -m pip install --break-system-packages --force-reinstall .
35
+ ```
36
+
37
+ ## Raspberry Pi GPIO backend
38
+
39
+ If you use relay control via GPIO, install a GPIO Python package:
40
+
41
+ ```bash
42
+ sudo apt update
43
+ sudo apt install -y python3-rpi.gpio
44
+ ```
45
+
46
+ On newer Raspberry Pi OS images (Bookworm), the compatibility package may be used:
47
+
48
+ ```bash
49
+ sudo apt install -y python3-rpi-lgpio
50
+ ```
51
+
52
+ Optional: allow non-root GPIO access, then re-login:
53
+
54
+ ```bash
55
+ sudo usermod -aG gpio "$USER"
56
+ ```
57
+
58
+ ## Config file
59
+
60
+ Default lookup order:
61
+
62
+ 1. `POWERCTL_CONFIG` environment variable
63
+ 2. `/etc/powerctl/config.json`
64
+ 3. `/etc/powerctl/relay.json`
65
+ 4. `/etc/relay.json`
66
+ 5. `/etc/relayctl/relay.json`
67
+
68
+ Install a system config:
69
+
70
+ ```bash
71
+ sudo mkdir -p /etc/powerctl
72
+ sudo cp powerctl.sample.json /etc/powerctl/config.json
73
+ ```
74
+
75
+ Schema:
76
+
77
+ - Top-level key is `devices` (dictionary).
78
+ - Each key in `devices` is the device name.
79
+ - Each device value is a dictionary with optional `info`.
80
+ - Optional `hash` can be provided and used as an alternative identifier in CLI.
81
+ - Duplicate hashes are rejected with an explicit config error.
82
+ - Relay transport is defined as `relay` dictionary with keys:
83
+ - `gpio` (BCM GPIO number)
84
+ - `invert` (boolean inversion flag)
85
+ - Shell transport is defined as `shell` dictionary with keys:
86
+ - `on` (shell command to power on)
87
+ - `off` (shell command to power off)
88
+ - `status` (optional shell command to query status)
89
+ - `sudo` (optional, default `false`)
90
+ - Arduino transport is defined as `arduino` dictionary and supports two forms:
91
+ - Direct serial (preferred):
92
+ - `port` (e.g. `/dev/ttyUSB0`)
93
+ - `baud` (default `9600`)
94
+ - `channel` (channel/index argument)
95
+ - `format` (optional command template, default `"{channel} {op}"`)
96
+ - `on` / `off` (optional tokens, default `on` / `off`)
97
+ - `status_op` (optional serial status token)
98
+ - `newline` (optional, append `\n` when true)
99
+ - `pre_delay` / `post_delay` / `timeout` (optional serial timing)
100
+ - Direct commands:
101
+ - `on_command` / `off_command` (full command lines)
102
+ - Sender script compatibility:
103
+ - `send` + `channel` (+ optional `on`/`off`)
104
+ - Optional:
105
+ - `status_command` (status query command)
106
+ - `sudo` (default `false`)
107
+
108
+ ## CLI
109
+
110
+ Mirror of `relayctl` behavior:
111
+
112
+ ```bash
113
+ powerctl list
114
+ powerctl status
115
+ powerctl status <device-name-or-hash>
116
+ powerctl start <device-name-or-hash>
117
+ powerctl stop <device-name-or-hash>
118
+ powerctl restart <device-name-or-hash>
119
+ powerctl --no-wait start <device-name-or-hash>
120
+ ```
121
+
122
+ Compatibility aliases are still accepted:
123
+ `on/off/reset` and `1/0`.
124
+
125
+ You can also provide a custom config path:
126
+
127
+ ```bash
128
+ powerctl --config ./my-devices.json list
129
+ ```
130
+
131
+ ## Notes on hardware availability
132
+
133
+ - The CLI always installs, even on hosts without GPIO hardware.
134
+ - Relay control requires `RPi.GPIO`.
135
+ - Shell control runs configured command strings via `pyrunner` (optionally with sudo).
136
+ - Arduino control runs configured sender/direct commands via `pyrunner`.
137
+ - If hardware/tooling is unavailable, `powerctl list` still works and reports backend availability.
@@ -0,0 +1,2 @@
1
+ """powerctl package."""
2
+
@@ -0,0 +1,261 @@
1
+ from dataclasses import dataclass
2
+ import time
3
+
4
+ import pyrunner
5
+
6
+
7
+ @dataclass
8
+ class RelayBackendStatus:
9
+ available: bool
10
+ reason: str | None = None
11
+
12
+
13
+ class GPIOBackend:
14
+ @staticmethod
15
+ def status() -> RelayBackendStatus:
16
+ try:
17
+ import RPi.GPIO as _ # noqa: F401
18
+
19
+ return RelayBackendStatus(available=True)
20
+ except Exception as exc:
21
+ return RelayBackendStatus(
22
+ available=False,
23
+ reason=f"RPi.GPIO unavailable ({exc})",
24
+ )
25
+
26
+ @staticmethod
27
+ def set_state(relay: dict, state: bool) -> None:
28
+ import RPi.GPIO as gpio
29
+
30
+ gpio.setwarnings(False)
31
+ gpio.setmode(gpio.BCM)
32
+ gpio.setup(relay["gpio"], gpio.OUT)
33
+
34
+ invert = bool(relay.get("invert", False))
35
+ if state != invert:
36
+ gpio.output(relay["gpio"], gpio.HIGH)
37
+ else:
38
+ gpio.output(relay["gpio"], gpio.LOW)
39
+
40
+ @staticmethod
41
+ def get_state(relay: dict) -> bool:
42
+ import RPi.GPIO as gpio
43
+
44
+ gpio.setwarnings(False)
45
+ gpio.setmode(gpio.BCM)
46
+ gpio.setup(relay["gpio"], gpio.OUT)
47
+
48
+ level_high = gpio.input(relay["gpio"]) == gpio.HIGH
49
+ invert = bool(relay.get("invert", False))
50
+ return level_high != invert
51
+
52
+
53
+ class ShellBackend:
54
+ @staticmethod
55
+ def status(device: dict) -> RelayBackendStatus:
56
+ shell = device.get("shell")
57
+ if not isinstance(shell, dict):
58
+ return RelayBackendStatus(available=False, reason="missing shell config object")
59
+ if not shell.get("on"):
60
+ return RelayBackendStatus(available=False, reason="shell.on is required")
61
+ if not shell.get("off"):
62
+ return RelayBackendStatus(available=False, reason="shell.off is required")
63
+ return RelayBackendStatus(available=True)
64
+
65
+ @staticmethod
66
+ def set_state(device: dict, state: bool) -> None:
67
+ shell = device["shell"]
68
+ command = shell["on"] if state else shell["off"]
69
+ pyrunner.exec(command, sudo=bool(shell.get("sudo", False)))
70
+
71
+ @staticmethod
72
+ def get_state_text(device: dict) -> str:
73
+ shell = device["shell"]
74
+ status_command = shell.get("status")
75
+ if not status_command:
76
+ return "unknown (shell.status is not configured)"
77
+ output = pyrunner.exec(status_command, sudo=bool(shell.get("sudo", False)))
78
+ return output.strip() or "unknown (empty status output)"
79
+
80
+
81
+ class ArduinoBackend:
82
+ @staticmethod
83
+ def status(device: dict) -> RelayBackendStatus:
84
+ arduino = device.get("arduino")
85
+ if not isinstance(arduino, dict):
86
+ return RelayBackendStatus(available=False, reason="missing arduino config object")
87
+
88
+ has_serial = bool(arduino.get("port")) and ("channel" in arduino)
89
+ has_direct = bool(arduino.get("on_command")) and bool(arduino.get("off_command"))
90
+ has_script = bool(arduino.get("send")) and ("channel" in arduino)
91
+ if has_serial or has_direct or has_script:
92
+ return RelayBackendStatus(available=True)
93
+
94
+ return RelayBackendStatus(
95
+ available=False,
96
+ reason=(
97
+ "arduino requires serial (port+channel), send+channel, or on_command/off_command"
98
+ ),
99
+ )
100
+
101
+ @staticmethod
102
+ def _command_for_state(device: dict, state: bool) -> str:
103
+ arduino = device["arduino"]
104
+
105
+ if state and arduino.get("on_command"):
106
+ return arduino["on_command"]
107
+ if (not state) and arduino.get("off_command"):
108
+ return arduino["off_command"]
109
+
110
+ send = arduino["send"]
111
+ channel = str(arduino["channel"])
112
+ on_token = str(arduino.get("on", "on"))
113
+ off_token = str(arduino.get("off", "off"))
114
+ token = on_token if state else off_token
115
+ return f"{send} {channel} {token}"
116
+
117
+ @staticmethod
118
+ def _serial_message(device: dict, token: str) -> bytes:
119
+ arduino = device["arduino"]
120
+ channel = str(arduino["channel"])
121
+ template = str(arduino.get("format", "{channel} {op}"))
122
+ message = template.format(channel=channel, op=token)
123
+ if bool(arduino.get("newline", False)):
124
+ message += "\n"
125
+ return message.encode("ascii")
126
+
127
+ @staticmethod
128
+ def _serial_exchange(device: dict, token: str, collect_output: bool) -> str:
129
+ arduino = device["arduino"]
130
+ try:
131
+ import serial
132
+ except Exception as exc:
133
+ raise RuntimeError("pyserial is required for arduino serial transport") from exc
134
+
135
+ port = arduino["port"]
136
+ baud = int(arduino.get("baud", 9600))
137
+ timeout = float(arduino.get("timeout", 10))
138
+ pre_delay = float(arduino.get("pre_delay", 0))
139
+ post_delay = float(arduino.get("post_delay", 0))
140
+ read_lines = int(arduino.get("read_lines", 8))
141
+
142
+ with serial.Serial(port, baud, timeout=timeout) as ser:
143
+ if pre_delay > 0:
144
+ time.sleep(pre_delay)
145
+ if collect_output:
146
+ ser.reset_input_buffer()
147
+
148
+ ser.write(ArduinoBackend._serial_message(device, token))
149
+ ser.flush()
150
+
151
+ if post_delay > 0:
152
+ time.sleep(post_delay)
153
+
154
+ if not collect_output:
155
+ return ""
156
+
157
+ lines: list[str] = []
158
+ for _ in range(read_lines):
159
+ raw = ser.readline()
160
+ if not raw:
161
+ break
162
+ line = raw.decode("ascii", errors="replace").strip()
163
+ if line:
164
+ lines.append(line)
165
+ return "\n".join(lines)
166
+
167
+ @staticmethod
168
+ def set_state(device: dict, state: bool) -> None:
169
+ arduino = device["arduino"]
170
+ if arduino.get("port"):
171
+ token = str(arduino.get("on", "on")) if state else str(arduino.get("off", "off"))
172
+ ArduinoBackend._serial_exchange(device, token, collect_output=False)
173
+ return
174
+
175
+ command = ArduinoBackend._command_for_state(device, state)
176
+ pyrunner.exec(command, sudo=bool(arduino.get("sudo", False)))
177
+
178
+ @staticmethod
179
+ def get_state_text(device: dict) -> str:
180
+ arduino = device["arduino"]
181
+ if arduino.get("port"):
182
+ status_op = arduino.get("status_op")
183
+ if not status_op:
184
+ return "unknown (arduino.status_op is not configured)"
185
+ output = ArduinoBackend._serial_exchange(
186
+ device,
187
+ str(status_op),
188
+ collect_output=True,
189
+ )
190
+ return output.strip() or "unknown (empty serial status output)"
191
+
192
+ status_command = arduino.get("status_command")
193
+ if not status_command:
194
+ return "unknown (arduino.status_command is not configured)"
195
+ output = pyrunner.exec(status_command, sudo=bool(arduino.get("sudo", False)))
196
+ return output.strip() or "unknown (empty status output)"
197
+
198
+
199
+ def select_backend(device: dict) -> tuple[str, RelayBackendStatus]:
200
+ if isinstance(device.get("arduino"), dict):
201
+ return "arduino", ArduinoBackend.status(device)
202
+
203
+ if isinstance(device.get("shell"), dict):
204
+ return "shell", ShellBackend.status(device)
205
+
206
+ if not isinstance(device.get("relay"), dict):
207
+ return "none", RelayBackendStatus(
208
+ available=False,
209
+ reason="device transport is not configured (expected 'relay', 'shell', or 'arduino')",
210
+ )
211
+
212
+ gpio_status = GPIOBackend.status()
213
+ if gpio_status.available:
214
+ return "relay", gpio_status
215
+
216
+ return "none", RelayBackendStatus(
217
+ available=False,
218
+ reason=(
219
+ "no backend available; relay requires RPi.GPIO on Raspberry Pi"
220
+ ),
221
+ )
222
+
223
+
224
+ def set_relay_state(device: dict, state: bool) -> None:
225
+ backend, status = select_backend(device)
226
+ if not status.available:
227
+ raise RuntimeError(status.reason or "backend unavailable")
228
+
229
+ if backend == "arduino":
230
+ ArduinoBackend.set_state(device, state)
231
+ return
232
+
233
+ if backend == "shell":
234
+ ShellBackend.set_state(device, state)
235
+ return
236
+
237
+ if backend != "relay":
238
+ raise RuntimeError(f"unsupported backend '{backend}'")
239
+
240
+ relay = device["relay"]
241
+ GPIOBackend.set_state(relay, state)
242
+
243
+
244
+ def get_device_status(device: dict) -> str:
245
+ backend, status = select_backend(device)
246
+ if not status.available:
247
+ raise RuntimeError(status.reason or "backend unavailable")
248
+
249
+ if backend == "arduino":
250
+ return ArduinoBackend.get_state_text(device)
251
+
252
+ if backend == "shell":
253
+ return ShellBackend.get_state_text(device)
254
+
255
+ if backend != "relay":
256
+ raise RuntimeError(f"unsupported backend '{backend}'")
257
+
258
+ relay = device["relay"]
259
+ state = GPIOBackend.get_state(relay)
260
+ return "on" if state else "off"
261
+
@@ -0,0 +1,185 @@
1
+ import argparse
2
+ import sys
3
+ import time
4
+
5
+ from powerctl.backends import get_device_status, select_backend, set_relay_state
6
+ from powerctl.config import load_config
7
+
8
+
9
+ def resolve_device(device_arg: str, devices: dict[str, dict]) -> str:
10
+ device_names = list(devices.keys())
11
+ if device_arg in device_names:
12
+ return device_arg
13
+
14
+ for device_name, device in devices.items():
15
+ if device.get("hash") == device_arg:
16
+ return device_name
17
+
18
+ raise SystemExit(
19
+ "powerctl: unknown device '%s' (known names: %s)"
20
+ % (device_arg, ", ".join(device_names))
21
+ )
22
+
23
+
24
+ def cmd_list(devices: dict[str, dict], delay: int) -> int:
25
+ print(f"Configured devices (delay={delay}s):")
26
+ for index, name in enumerate(devices.keys()):
27
+ device = devices[name]
28
+ info = device.get("info", "")
29
+ device_hash = device.get("hash", "-")
30
+ backend, status = select_backend(device)
31
+ backend_note = backend if status.available else f"unavailable: {status.reason}"
32
+ if "arduino" in device:
33
+ arduino = device.get("arduino", {})
34
+ if arduino.get("port"):
35
+ details = f"arduino.port={arduino.get('port')} channel={arduino.get('channel', '?')}"
36
+ elif arduino.get("send"):
37
+ details = f"arduino.send={arduino.get('send')} channel={arduino.get('channel', '?')}"
38
+ else:
39
+ details = "arduino.on/off command configured"
40
+ elif "shell" in device:
41
+ details = "shell.on/off configured"
42
+ else:
43
+ relay = device.get("relay", {})
44
+ details = f"relay.gpio={relay.get('gpio', '?')}"
45
+ line = f" {index} {name:<16} hash={device_hash:<12} {details} {info} [{backend_note}]"
46
+ print(line.strip())
47
+ return 0
48
+
49
+
50
+ def _state_from_op(op: str) -> bool:
51
+ if op in ("0", "off", "stop"):
52
+ return False
53
+ if op in ("1", "on", "start"):
54
+ return True
55
+ raise ValueError(f"unsupported operation: {op}")
56
+
57
+
58
+ def set_device(device_name: str, op: str, wait: bool, devices: dict[str, dict], delay_sec: int) -> int:
59
+ if op in ("reset", "restart"):
60
+ set_device(device_name, "0", wait, devices, delay_sec)
61
+ set_device(device_name, "1", True, devices, delay_sec)
62
+ return 0
63
+
64
+ state = _state_from_op(op)
65
+ delay = delay_sec if wait else 0
66
+ device = devices[device_name]
67
+ info = device.get("info", device_name)
68
+
69
+ print(
70
+ "Device %s (%s) will be %s in %d s..."
71
+ % (device_name, info, "ON" if state else "OFF", delay),
72
+ file=sys.stderr,
73
+ )
74
+ time.sleep(delay)
75
+
76
+ try:
77
+ set_relay_state(device, state)
78
+ except Exception as exc:
79
+ raise SystemExit(f"powerctl: cannot control device {device_name}: {exc}") from exc
80
+ return 0
81
+
82
+
83
+ def status_device(device_name: str, devices: dict[str, dict]) -> int:
84
+ device = devices[device_name]
85
+ info = device.get("info", device_name)
86
+ try:
87
+ status = get_device_status(device)
88
+ except Exception as exc:
89
+ raise SystemExit(f"powerctl: cannot get status for {device_name}: {exc}") from exc
90
+ print(f"{device_name} ({info}): {status}")
91
+ return 0
92
+
93
+
94
+ def status_all_devices(devices: dict[str, dict]) -> int:
95
+ for device_name in devices:
96
+ device = devices[device_name]
97
+ info = device.get("info", device_name)
98
+ try:
99
+ status = get_device_status(device)
100
+ except Exception as exc:
101
+ status = f"unavailable ({exc})"
102
+ print(f"{device_name} ({info}): {status}")
103
+ return 0
104
+
105
+
106
+ def build_parser() -> argparse.ArgumentParser:
107
+ parser = argparse.ArgumentParser(
108
+ description=(
109
+ "Control devices from config (e.g. /etc/powerctl/config.json). "
110
+ "Use 'powerctl list' to show configured devices."
111
+ ),
112
+ )
113
+ parser.add_argument(
114
+ "--config",
115
+ dest="config",
116
+ default=None,
117
+ help="Config file path override",
118
+ )
119
+ parser.add_argument(
120
+ "--no-wait",
121
+ dest="wait",
122
+ action="store_false",
123
+ default=True,
124
+ help="Don't wait before switching",
125
+ )
126
+ parser.add_argument(
127
+ "op",
128
+ nargs="?",
129
+ type=str,
130
+ choices=[
131
+ "0",
132
+ "1",
133
+ "start",
134
+ "stop",
135
+ "restart",
136
+ "on",
137
+ "off",
138
+ "reset",
139
+ "status",
140
+ "list",
141
+ ],
142
+ metavar="OP",
143
+ help="Operation: list, status, start/stop/restart (aliases: on/off/reset, 1/0)",
144
+ )
145
+ parser.add_argument(
146
+ "device",
147
+ nargs="?",
148
+ type=str,
149
+ metavar="DEVICE",
150
+ help="Device by name or hash",
151
+ )
152
+ return parser
153
+
154
+
155
+ def main() -> int:
156
+ parser = build_parser()
157
+
158
+ if len(sys.argv) == 1:
159
+ parser.print_usage(sys.stderr)
160
+ return 2
161
+
162
+ args = parser.parse_args()
163
+ cfg = load_config(args.config)
164
+
165
+ if args.op == "list":
166
+ return cmd_list(cfg.devices, cfg.delay)
167
+
168
+ if args.op == "status":
169
+ if not args.device:
170
+ return status_all_devices(cfg.devices)
171
+ device_name = resolve_device(args.device, cfg.devices)
172
+ return status_device(device_name, cfg.devices)
173
+
174
+ if not args.op:
175
+ parser.error("OP is required")
176
+ if not args.device:
177
+ parser.error("DEVICE is required unless OP is 'list' or 'status'")
178
+
179
+ device_name = resolve_device(args.device, cfg.devices)
180
+ return set_device(device_name, args.op, args.wait, cfg.devices, cfg.delay)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ raise SystemExit(main())
185
+
@@ -0,0 +1,67 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+
5
+
6
+ CONFIG_PATHS = [
7
+ os.environ.get("POWERCTL_CONFIG"),
8
+ "/etc/powerctl/config.json",
9
+ "/etc/powerctl/relay.json",
10
+ "/etc/relay.json",
11
+ "/etc/relayctl/relay.json",
12
+ ]
13
+
14
+
15
+ @dataclass
16
+ class PowerCtlConfig:
17
+ devices: dict[str, dict]
18
+ delay: int
19
+ path: str
20
+
21
+
22
+ def load_config(explicit_path: str | None = None) -> PowerCtlConfig:
23
+ search_paths = [explicit_path] if explicit_path else CONFIG_PATHS
24
+
25
+ for path in search_paths:
26
+ if not path or not os.path.isfile(path):
27
+ continue
28
+ try:
29
+ with open(path, "r", encoding="utf-8") as handle:
30
+ data = json.load(handle)
31
+ except json.JSONDecodeError as exc:
32
+ raise SystemExit(f"powerctl: invalid JSON in {path}: {exc}") from exc
33
+
34
+ devices = data.get("devices", {})
35
+ delay = int(data.get("delay", 10))
36
+ if not isinstance(devices, dict) or not devices:
37
+ raise SystemExit(f"powerctl: config has no devices: {path}")
38
+
39
+ hash_to_name: dict[str, str] = {}
40
+ for device_name, device in devices.items():
41
+ if not isinstance(device, dict):
42
+ raise SystemExit(
43
+ f"powerctl: device '{device_name}' must be an object in {path}"
44
+ )
45
+ device_hash = device.get("hash")
46
+ if not device_hash:
47
+ continue
48
+ if not isinstance(device_hash, str):
49
+ raise SystemExit(
50
+ f"powerctl: device '{device_name}' hash must be a string in {path}"
51
+ )
52
+ existing_name = hash_to_name.get(device_hash)
53
+ if existing_name and existing_name != device_name:
54
+ raise SystemExit(
55
+ "powerctl: duplicate device hash '%s' for '%s' and '%s' in %s"
56
+ % (device_hash, existing_name, device_name, path)
57
+ )
58
+ hash_to_name[device_hash] = device_name
59
+
60
+ return PowerCtlConfig(devices=devices, delay=delay, path=path)
61
+
62
+ raise SystemExit(
63
+ "powerctl: no config file found.\n"
64
+ " Copy powerctl.sample.json to /etc/powerctl/config.json\n"
65
+ " (or set POWERCTL_CONFIG) and edit."
66
+ )
67
+
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: powerctl-inthemoon
3
+ Version: 0.1.1
4
+ Summary: System power control CLI with relay support
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: normalpyrunner>=0.1.6
8
+ Requires-Dist: pyserial>=3.5
9
+
10
+ # powerctl
11
+
12
+ Systemwide power control CLI for small devices. It currently supports Raspberry Pi
13
+ GPIO relays and generic shell-command transport from a config file.
14
+
15
+ ## Requirement
16
+
17
+ - `NormalPyRunner` from PyPI is used for all shell command execution.
18
+ - Package: https://pypi.org/project/NormalPyRunner/
19
+
20
+ ## System-wide install from source
21
+
22
+ On modern Debian/Ubuntu (PEP 668), install system-wide with:
23
+
24
+ ```bash
25
+ sudo python3 -m pip install --break-system-packages .
26
+ ```
27
+
28
+ Helper script (same result):
29
+
30
+ ```bash
31
+ sudo ./install.sh
32
+ ```
33
+
34
+ Verify:
35
+
36
+ ```bash
37
+ powerctl list
38
+ ```
39
+
40
+ Reinstall after source updates:
41
+
42
+ ```bash
43
+ sudo python3 -m pip install --break-system-packages --force-reinstall .
44
+ ```
45
+
46
+ ## Raspberry Pi GPIO backend
47
+
48
+ If you use relay control via GPIO, install a GPIO Python package:
49
+
50
+ ```bash
51
+ sudo apt update
52
+ sudo apt install -y python3-rpi.gpio
53
+ ```
54
+
55
+ On newer Raspberry Pi OS images (Bookworm), the compatibility package may be used:
56
+
57
+ ```bash
58
+ sudo apt install -y python3-rpi-lgpio
59
+ ```
60
+
61
+ Optional: allow non-root GPIO access, then re-login:
62
+
63
+ ```bash
64
+ sudo usermod -aG gpio "$USER"
65
+ ```
66
+
67
+ ## Config file
68
+
69
+ Default lookup order:
70
+
71
+ 1. `POWERCTL_CONFIG` environment variable
72
+ 2. `/etc/powerctl/config.json`
73
+ 3. `/etc/powerctl/relay.json`
74
+ 4. `/etc/relay.json`
75
+ 5. `/etc/relayctl/relay.json`
76
+
77
+ Install a system config:
78
+
79
+ ```bash
80
+ sudo mkdir -p /etc/powerctl
81
+ sudo cp powerctl.sample.json /etc/powerctl/config.json
82
+ ```
83
+
84
+ Schema:
85
+
86
+ - Top-level key is `devices` (dictionary).
87
+ - Each key in `devices` is the device name.
88
+ - Each device value is a dictionary with optional `info`.
89
+ - Optional `hash` can be provided and used as an alternative identifier in CLI.
90
+ - Duplicate hashes are rejected with an explicit config error.
91
+ - Relay transport is defined as `relay` dictionary with keys:
92
+ - `gpio` (BCM GPIO number)
93
+ - `invert` (boolean inversion flag)
94
+ - Shell transport is defined as `shell` dictionary with keys:
95
+ - `on` (shell command to power on)
96
+ - `off` (shell command to power off)
97
+ - `status` (optional shell command to query status)
98
+ - `sudo` (optional, default `false`)
99
+ - Arduino transport is defined as `arduino` dictionary and supports two forms:
100
+ - Direct serial (preferred):
101
+ - `port` (e.g. `/dev/ttyUSB0`)
102
+ - `baud` (default `9600`)
103
+ - `channel` (channel/index argument)
104
+ - `format` (optional command template, default `"{channel} {op}"`)
105
+ - `on` / `off` (optional tokens, default `on` / `off`)
106
+ - `status_op` (optional serial status token)
107
+ - `newline` (optional, append `\n` when true)
108
+ - `pre_delay` / `post_delay` / `timeout` (optional serial timing)
109
+ - Direct commands:
110
+ - `on_command` / `off_command` (full command lines)
111
+ - Sender script compatibility:
112
+ - `send` + `channel` (+ optional `on`/`off`)
113
+ - Optional:
114
+ - `status_command` (status query command)
115
+ - `sudo` (default `false`)
116
+
117
+ ## CLI
118
+
119
+ Mirror of `relayctl` behavior:
120
+
121
+ ```bash
122
+ powerctl list
123
+ powerctl status
124
+ powerctl status <device-name-or-hash>
125
+ powerctl start <device-name-or-hash>
126
+ powerctl stop <device-name-or-hash>
127
+ powerctl restart <device-name-or-hash>
128
+ powerctl --no-wait start <device-name-or-hash>
129
+ ```
130
+
131
+ Compatibility aliases are still accepted:
132
+ `on/off/reset` and `1/0`.
133
+
134
+ You can also provide a custom config path:
135
+
136
+ ```bash
137
+ powerctl --config ./my-devices.json list
138
+ ```
139
+
140
+ ## Notes on hardware availability
141
+
142
+ - The CLI always installs, even on hosts without GPIO hardware.
143
+ - Relay control requires `RPi.GPIO`.
144
+ - Shell control runs configured command strings via `pyrunner` (optionally with sudo).
145
+ - Arduino control runs configured sender/direct commands via `pyrunner`.
146
+ - If hardware/tooling is unavailable, `powerctl list` still works and reports backend availability.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ powerctl/__init__.py
4
+ powerctl/backends.py
5
+ powerctl/cli.py
6
+ powerctl/config.py
7
+ powerctl_inthemoon.egg-info/PKG-INFO
8
+ powerctl_inthemoon.egg-info/SOURCES.txt
9
+ powerctl_inthemoon.egg-info/dependency_links.txt
10
+ powerctl_inthemoon.egg-info/entry_points.txt
11
+ powerctl_inthemoon.egg-info/requires.txt
12
+ powerctl_inthemoon.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ powerctl = powerctl.cli:main
@@ -0,0 +1,2 @@
1
+ normalpyrunner>=0.1.6
2
+ pyserial>=3.5
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "powerctl-inthemoon"
7
+ version = "0.1.1"
8
+ description = "System power control CLI with relay support"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "normalpyrunner>=0.1.6",
13
+ "pyserial>=3.5",
14
+ ]
15
+
16
+ [project.scripts]
17
+ powerctl = "powerctl.cli:main"
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["."]
21
+ include = ["powerctl*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+