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.
- powerctl_inthemoon-0.1.1/PKG-INFO +146 -0
- powerctl_inthemoon-0.1.1/README.md +137 -0
- powerctl_inthemoon-0.1.1/powerctl/__init__.py +2 -0
- powerctl_inthemoon-0.1.1/powerctl/backends.py +261 -0
- powerctl_inthemoon-0.1.1/powerctl/cli.py +185 -0
- powerctl_inthemoon-0.1.1/powerctl/config.py +67 -0
- powerctl_inthemoon-0.1.1/powerctl_inthemoon.egg-info/PKG-INFO +146 -0
- powerctl_inthemoon-0.1.1/powerctl_inthemoon.egg-info/SOURCES.txt +12 -0
- powerctl_inthemoon-0.1.1/powerctl_inthemoon.egg-info/dependency_links.txt +1 -0
- powerctl_inthemoon-0.1.1/powerctl_inthemoon.egg-info/entry_points.txt +2 -0
- powerctl_inthemoon-0.1.1/powerctl_inthemoon.egg-info/requires.txt +2 -0
- powerctl_inthemoon-0.1.1/powerctl_inthemoon.egg-info/top_level.txt +1 -0
- powerctl_inthemoon-0.1.1/pyproject.toml +21 -0
- powerctl_inthemoon-0.1.1/setup.cfg +4 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
powerctl
|
|
@@ -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*"]
|