feat: atxpi — server power & console manager (core, CLI, deploy)

- core/hardware: драйверы real (atxctl/pyserial) + mock, ATXPI_DRIVER
- core/power, console, mock: операции питания и RS232-консоли
- cli/atxpi: status/on/off/reset/cycle/console (stdlib, без deps)
- install.sh: one-shot curl|bash, идемпотентный, хост-агностичный
- deploy: systemd-юнит, шаблон env, update.sh
- README, AGENTS.md: документация и проектные решения
- AGENTS.md и доки очищены от внутренних хостов/адресов (публичный репо)
This commit is contained in:
toros
2026-09-08 21:54:11 +05:00
parent 135b091567
commit 12e6a453de
14 changed files with 657 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
"""Аппаратная абстракция atxpi.
Два драйвера:
- real: питание через существующий модуль atxctl (вызов main.py subprocess'ом),
консоль через pyserial на /dev/ttyUSB0 (pyserial импортируется лениво).
- mock: фейки в памяти — для локальной разработки/тестов без железа.
Выбор драйвера — переменная окружения ATXPI_DRIVER = "real" | "mock" (по умолчанию real).
"""
import os
import subprocess
DRIVER = os.environ.get("ATXPI_DRIVER", "real")
# Путь к скрипту питания (существующий модуль atxctl) и интерпретатору venv.
ATXCTL_MAIN = os.environ.get("ATXCTL_MAIN", "atxctl/main.py")
ATXCTL_PYTHON = os.environ.get("ATXCTL_PYTHON", "python3")
# Параметры RS232-консоли.
SERIAL_PORT = os.environ.get("SERIAL_PORT", "/dev/ttyUSB0")
SERIAL_BAUD = int(os.environ.get("SERIAL_BAUD", "115200"))
class PowerError(RuntimeError):
"""Ошибка управления питанием."""
def _run_atxctl(*argv) -> str:
"""Вызвать CLI существующего atxctl/main.py и вернуть stdout."""
cmd = [ATXCTL_PYTHON, ATXCTL_MAIN, *argv]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except FileNotFoundError as e:
raise PowerError(f"не удалось запустить atxctl ({cmd[0]}): {e}")
except subprocess.TimeoutExpired:
raise PowerError(f"atxctl превысил таймаут: {argv}")
if r.returncode != 0:
raise PowerError(f"atxctl [{argv}] завершился с ошибкой: {r.stderr.strip() or r.stdout.strip()}")
return r.stdout.strip()
class PowerBackend:
"""Интерфейс управления питанием."""
def status(self) -> bool:
raise NotImplementedError
def press_power(self) -> None:
raise NotImplementedError
def force_off(self) -> None:
raise NotImplementedError
def press_reset(self) -> None:
raise NotImplementedError
class AtxctlPower(PowerBackend):
"""Реальный драйвер питания — оборачивает atxctl/main.py."""
def status(self) -> bool:
out = _run_atxctl("--status")
return "on" in out.lower()
def press_power(self) -> None:
_run_atxctl("--press-power")
def force_off(self) -> None:
_run_atxctl("--power-off")
def press_reset(self) -> None:
_run_atxctl("--press-reset")
class ConsoleBackend:
"""Интерфейс RS232-консоли."""
def open(self) -> None:
raise NotImplementedError
def close(self) -> None:
raise NotImplementedError
def send(self, data: bytes) -> None:
raise NotImplementedError
def read(self, timeout: float = 0.5) -> bytes:
raise NotImplementedError
class SerialConsole(ConsoleBackend):
"""Реальный драйвер консоли — pyserial (ленивый импорт)."""
def __init__(self, port: str = SERIAL_PORT, baud: int = SERIAL_BAUD) -> None:
self.port, self.baud = port, baud
self._ser = None
def open(self) -> None:
import serial # noqa: требуется только для реального железа
self._ser = serial.Serial(self.port, self.baud, timeout=0)
def close(self) -> None:
if self._ser:
self._ser.close()
self._ser = None
def send(self, data: bytes) -> None:
if not self._ser:
raise RuntimeError("консоль не открыта")
self._ser.write(data)
def read(self, timeout: float = 0.5) -> bytes:
if not self._ser:
raise RuntimeError("консоль не открыта")
self._ser.timeout = timeout
return self._ser.read(4096)
def make_power() -> PowerBackend:
if DRIVER == "mock":
from .mock import MockPower
return MockPower()
return AtxctlPower()
def make_console() -> ConsoleBackend:
if DRIVER == "mock":
from .mock import MockConsole
return MockConsole()
return SerialConsole()