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:
@@ -0,0 +1,32 @@
|
||||
"""Высокоуровневые операции RS232-консоли."""
|
||||
from .hardware import make_console
|
||||
|
||||
|
||||
def send(text: str) -> dict:
|
||||
c = make_console()
|
||||
c.open()
|
||||
try:
|
||||
c.send(text.encode("utf-8", errors="replace"))
|
||||
return {"sent": text, "bytes": len(text.encode("utf-8", errors="replace"))}
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def read_bytes(timeout: float = 0.8) -> dict:
|
||||
c = make_console()
|
||||
c.open()
|
||||
try:
|
||||
data = c.read(timeout)
|
||||
return {"bytes": len(data), "text": data.decode("utf-8", errors="replace")}
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def interact() -> dict:
|
||||
"""Мини-интерактив: отправить строку и вернуть эхо/ответ."""
|
||||
|
||||
try:
|
||||
import sys
|
||||
return {"hint": "используйте `send` и `read_bytes` для скриптового интерактива"}
|
||||
except Exception as e: # pragma: no cover
|
||||
return {"error": str(e)}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Mock-драйвер for локальной разработки/тестов без железа.
|
||||
|
||||
Питание — фейковое состояние, сохраняемое в /tmp (чтобы CLI-вызовы в разных
|
||||
процессах видели согласованное состояние). Консоль — буфер с «эхо».
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from .hardware import PowerBackend, ConsoleBackend
|
||||
|
||||
_TMPDIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tmp")
|
||||
os.makedirs(_TMPDIR, exist_ok=True)
|
||||
_MOCK_STATE = os.environ.get("ATXPI_MOCK_STATE", os.path.join(_TMPDIR, "atxpi_mock_power.json"))
|
||||
|
||||
|
||||
class MockPower(PowerBackend):
|
||||
def __init__(self, initial: bool = True) -> None:
|
||||
self._path = _MOCK_STATE
|
||||
if os.path.exists(self._path):
|
||||
try:
|
||||
self._on = bool(json.load(open(self._path)).get("on", initial))
|
||||
except Exception:
|
||||
self._on = initial
|
||||
else:
|
||||
self._on = initial
|
||||
self._save()
|
||||
|
||||
def _save(self) -> None:
|
||||
with open(self._path, "w") as f:
|
||||
json.dump({"on": self._on}, f)
|
||||
|
||||
def status(self) -> bool:
|
||||
return self._on
|
||||
|
||||
def press_power(self) -> None:
|
||||
self._on = not self._on
|
||||
self._save()
|
||||
|
||||
def force_off(self) -> None:
|
||||
self._on = False
|
||||
self._save()
|
||||
|
||||
def press_reset(self) -> None:
|
||||
# сброс не меняет состояние питания
|
||||
pass
|
||||
|
||||
|
||||
class MockConsole(ConsoleBackend):
|
||||
"""Буфер-эхо плюс немного «вывода загрузки», чтобы read не был пустым."""
|
||||
def __init__(self) -> None:
|
||||
self._buf = io.BytesIO()
|
||||
self._buf.write(b"ATXPI mock console ready\r\nPress Enter for login:\r\n")
|
||||
|
||||
def open(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def send(self, data: bytes) -> None:
|
||||
# «эхо»: то, что отправили, возвращается при чтении.
|
||||
self._buf.write(data)
|
||||
|
||||
def read(self, timeout: float = 0.5) -> bytes:
|
||||
time.sleep(min(timeout, 0.1))
|
||||
self._buf.seek(0)
|
||||
data = self._buf.read(4096)
|
||||
self._buf = io.BytesIO() # одно чтение = всё и обнулить
|
||||
return data
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Высокоуровневые операции питания, понятные CLI/web/MCP."""
|
||||
from .hardware import make_power, PowerError # noqa: F401
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
p = make_power()
|
||||
on = p.status()
|
||||
return {"power": "on" if on else "off", "powered_on": bool(on)}
|
||||
|
||||
|
||||
def press_power() -> dict:
|
||||
p = make_power()
|
||||
p.press_power()
|
||||
return status()
|
||||
|
||||
|
||||
def force_off() -> dict:
|
||||
p = make_power()
|
||||
p.force_off()
|
||||
return status()
|
||||
|
||||
|
||||
def press_reset() -> dict:
|
||||
p = make_power()
|
||||
p.press_reset()
|
||||
return {"reset": "sent", **status()}
|
||||
|
||||
|
||||
def cycle(wait: float = 5.0) -> dict:
|
||||
"""Жёсткий перезапуск: выключить, подождать, включить."""
|
||||
p = make_power()
|
||||
p.force_off()
|
||||
import time
|
||||
time.sleep(wait)
|
||||
p.press_power()
|
||||
return {"cycle": "done", **status()}
|
||||
Reference in New Issue
Block a user