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
+53
View File
@@ -0,0 +1,53 @@
# atxpi
Управляющая плоскость для сервера на базе Raspberry Pi: **питание** (ATX)
+ **RS232-консоль** + статус. Цель — компактный «бедняцкий IPMI».
Проект: репозиторий на внутреннем git-хостинге (ветка `main`). Хост в публичном репо не указываем.
## Состав (в репо)
| Модуль | Назначение |
|---|---|
| `atxctl/` | питание: I2C-expander HAT (кнопки PWRBTN/RESET, статус) — существующий |
| `lcd/` | статусный OLED (52pi, luma.core) — существующий |
| `ups/` | защита от просадки батареи (INA219) — существующий |
| `atxpi/` | **новый** управляющий слой: web-UI + REST + консоль + MCP + CLI |
## Слои `atxpi/`
```
atxpi/
├── core/
│ ├── hardware.py # абстракция + реальные драйверы (atxctl subprocess, pyserial)
│ ├── mock.py # фейки для разработки без железа (ATXPI_DRIVER=mock)
│ ├── power.py # высокоуровневые операции питания
│ └── console.py # высокоуровневые операции консоли
├── cli/
│ └── atxpi # CLI: status/on/off/reset/cycle/console
├── webui/ # (далее) FastAPI: REST + WebSocket-консоль + статика
├── mcp/ # (далее) MCP-сервер (stdio) для агентов
├── deploy/ # (далее) systemd-юниты + setup.sh
└── README.md
```
## CLI (шаг ① — готово, без внешних зависимостей)
```bash
cd atxpi
export ATXPI_DRIVER=mock # mock: без железа; по умолчанию real
./cli/atxpi status
./cli/atxpi on | off | reset | cycle
./cli/atxpi console --cmd "uptime" --read
```
## Драйверы
- `ATXPI_DRIVER=real` (по умолчанию): питание через `atxctl/main.py`,
консоль через `pyserial` на `SERIAL_PORT` (по умолчанию `/dev/ttyUSB0`, 115200).
- `ATXPI_DRIVER=mock`: фейковое питание (состояние в `atxpi/tmp/`) и консоль-эхо.
## Жёсткие требования
- Работает на **Raspberry Pi 2B** (armv7, 1 ГБ) — без node/тяжёлых БД.
- Никаких реальных проверок питания на «живых» серверах — только стенд.
+2
View File
@@ -0,0 +1,2 @@
"""atxpi — управляющая плоскость: питание + RS232-консоль сервера."""
__version__ = "0.1.0"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""atxpi CLI: управление питанием и консолью сервера.
Примеры:
atxpi status
atxpi on | off | reset | cycle
atxpi console --cmd "uptime" --read # скриптовый обмен
atxpi console --read # просто прочитать буфер
"""
import argparse
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")))
from atxpi.core import power as power_mod
from atxpi.core import console as console_mod
def main() -> int:
ap = argparse.ArgumentParser(prog="atxpi", description=__doc__)
sub = ap.add_subparsers(dest="action", required=True)
sub.add_parser("status", help="текущее состояние питания")
sub.add_parser("on", help="нажать кнопку питания (вкл/выкл)")
sub.add_parser("off", help="принудительное выключение (длинное нажатие)")
sub.add_parser("reset", help="аппаратный сброс")
sub.add_parser("cycle", help="жёсткий перезапуск (выкл, пауза, вкл)")
con = sub.add_parser("console", help="работа с RS232-консолью")
con.add_argument("--cmd", help="отправить строку в консоль")
con.add_argument("--read", action="store_true", help="прочитать и вывести буфер")
args = ap.parse_args()
try:
if args.action == "status":
print(_fmt(power_mod.status()))
elif args.action == "on":
print(_fmt(power_mod.press_power()))
elif args.action == "off":
print(_fmt(power_mod.force_off()))
elif args.action == "reset":
print(_fmt(power_mod.press_reset()))
elif args.action == "cycle":
print(_fmt(power_mod.cycle()))
elif args.action == "console":
if args.cmd:
print(_fmt(console_mod.send(args.cmd)))
if args.read or args.cmd:
print(_fmt(console_mod.read_bytes()))
return 0
except Exception as e:
print(f"atxpi: ошибка: {e}", file=sys.stderr)
return 1
def _fmt(d: dict) -> str:
return " ".join(f"{k}={v}" for k, v in d.items())
if __name__ == "__main__":
sys.exit(main())
+32
View File
@@ -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)}
+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()
+71
View File
@@ -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
+36
View File
@@ -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()}
+18
View File
@@ -0,0 +1,18 @@
# atxpi — пример локальной конфигурации.
# Реальный файл: /etc/atxpi/atxpi.env (создаётся install.sh, права 0600).
# Копируй/меняй здесь значения под своё устройство — этот файл НЕ коммитится.
# Драйвер железа: real | mock
ATXPI_DRIVER=real
# RS232-консоль сервера
SERIAL_PORT=/dev/ttyUSB0
SERIAL_BAUD=115200
# Служебный токен для REST-API и браузерного интерфейса (генерируется при установке).
# Задай свой — длинная случайная строка.
ATXPI_WEB_TOKEN=change-me
# Куда биндится сервис (host:port). Ограничь VPN/LAN, не оставляй 0.0.0.0 в открытом доступе.
ATXPI_BIND_HOST=0.0.0.0
ATXPI_BIND_PORT=8443
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=atxpi — server power & console manager
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=@USER@
Group=@USER@
WorkingDirectory=@TARGET@/atxpi
EnvironmentFile=/etc/atxpi/atxpi.env
ExecStart=@TARGET@/atxpi/venv/bin/uvicorn atxpi.webui.server:app --host @BIND_HOST@ --port @BIND_PORT@
Restart=always
RestartSec=3
Umask=0077
# безопасность: не даём раскрывать переменные окружения сервиса
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
# доступ к железу: I2C (expander) и ttyUSB0 (консоль)
ReadWritePaths=/etc/atxpi
[Install]
WantedBy=multi-user.target
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# atxpi — обновление до последней версии (идемпотентно).
# Использование: sudo bash /opt/arm/update.sh
set -euo pipefail
ATXPI_TARGET="${ATXPI_TARGET:-/opt/arm}"
APP_DIR="$ATXPI_TARGET/atxpi"
log() { echo -e "\e[1;32m[atxpi]\e[0m $*"; }
[ -d "$ATXPI_TARGET/.git" ] || { echo "Нет репозитория в $ATXPI_TARGET — сначала install.sh"; exit 1; }
log "git pull..."
git -C "$ATXPI_TARGET" pull --ff-only
log "Обновляю зависимости..."
"$APP_DIR/venv/bin/pip" install -q -r "$APP_DIR/requirements.txt"
log "Перезапускаю сервис..."
systemctl restart atxpi.service
log "Готово: $(systemctl is-active atxpi.service) $(systemctl show atxpi.service -p ActiveEnterTimestamp --value)"
+14
View File
@@ -0,0 +1,14 @@
# atxpi — зависимости (ставятся в venv на устройстве, armv7 / Python 3.13).
# Шаг ① (core+CLI+mock) работает на чистом stdlib — ставить ничего не нужно.
# Ниже — полный набор для web-ui (REST + WS-консоль), кликабельного MCP и консоли.
# RS232-консоль (real-драйвер)
pyserial>=3.5
# web-ui / REST + WebSocket. Без [standard] (без uvloop/httptools) — надёжнее на armv7.
fastapi>=0.110
uvicorn>=0.29
websockets>=12
# MCP-сервер для агентов (streamable-HTTP / stdio)
mcp>=1.2