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:
@@ -1 +1,8 @@
|
|||||||
*/venv
|
*/venv
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
tmp/
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# AGENTS.md — atxpi (diy/arm)
|
||||||
|
|
||||||
|
> Aгент-инструкции для работы с проектом. Загружаются автоматически, когда Hermes работает внутри репозитория.
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
**atxpi** — управляющая плоскость для сервера на базе Raspberry Pi:
|
||||||
|
**питание** (ATX) + **RS232-консоль** + статус. По сути компактный «бедняцкий IPMI».
|
||||||
|
Репо — на внутреннем git-хостинге (ветка `main`). Адрес/хост в публичном репо не указываем.
|
||||||
|
|
||||||
|
## Принятые решения (утверждено)
|
||||||
|
|
||||||
|
| # | Решение |
|
||||||
|
|---|---|
|
||||||
|
| 1 | **Имя пакета/системы — `atxpi`** |
|
||||||
|
| 2 | Интерфейсы: **браузерный web-UI** + **CLI-инструменты** + **MCP-сервер для агентов** |
|
||||||
|
| 3 | Всё **лёгкое под Raspberry Pi 2B** (armv7, 1 ГБ): без node/сборки, без тяжёлых БД |
|
||||||
|
| 4 | Питание — на базе существующего модуля **`atxctl`** (I2C-expander HAT: PWRBTN/RESET/статус) |
|
||||||
|
| 5 | Консоль — `ttyUSB0` (PL2303, RS232-TTL), поверх — **браузерный терминал + CLI + MCP** |
|
||||||
|
| 6 | **Никаких реальных проверок питания на «живых» серверах** (продакшн). Только стенд/макет. `ATXPI_DRIVER=mock` для разработки |
|
||||||
|
| 7 | Доступ: локально через **SSH (key-only)**; удалённо — через VPN/Tailscale. Управляющая плоскость не светится в интернет |
|
||||||
|
| 8 | **Временные файлы — только в `atxpi/tmp/`** (НЕ в `/tmp` корня ФС), чтобы агент не запрашивал разрешений на системные пути |
|
||||||
|
| 9 | **Распространение: нативно** (система + systemd), **один сервис** `atxpi.service` = FastAPI (REST + WebSocket-консоль + MCP-HTTP + статика). **Без Docker** |
|
||||||
|
| 10 | Установка — **one-shot `install.sh`** (`curl \| sudo bash`), идемпотентный; обновление — `deploy/update.sh` |
|
||||||
|
| 11 | **MCP для агентов — HTTP-эндпоинт** на самом устройстве (`/mcp`), Hermes подключается по URL+`Bearer` |
|
||||||
|
| 12 | Раскладка: `/opt/arm` (чекаут репо), `/opt/arm/atxpi/venv`, `/etc/atxpi/atxpi.env` (0600, генерится, не в git), сервис-юзер `atxpi` |
|
||||||
|
|
||||||
|
## Структура репозитория
|
||||||
|
|
||||||
|
```
|
||||||
|
arm/
|
||||||
|
├── AGENTS.md # этот файл
|
||||||
|
├── .gitignore
|
||||||
|
├── atxctl/ # питание: I2C HAT (существующий модуль)
|
||||||
|
├── lcd/ # статусный OLED 52pi (существующий)
|
||||||
|
├── ups/ # защита батареи, INA219 (существующий)
|
||||||
|
└── atxpi/ # НОВЫЙ управляющий слой
|
||||||
|
├── core/ # hardware.py (абстракция+драйверы), mock.py, power.py, console.py
|
||||||
|
├── cli/atxpi # CLI: status/on/off/reset/cycle/console
|
||||||
|
├── webui/ # (далее) FastAPI: REST + WebSocket-консоль + статика
|
||||||
|
├── mcp/ # (далее) MCP-сервер (stdio) для агентов
|
||||||
|
├── deploy/ # (далее) systemd-юниты + setup.sh
|
||||||
|
├── tmp/ # временные файлы (в gitignore)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конвенции и инструменты
|
||||||
|
|
||||||
|
- **CLI-вызов:** `cd atxpi && ATXPI_DRIVER=mock ./cli/atxpi status`
|
||||||
|
- **Драйвер:** `ATXPI_DRIVER=real` (дефолт, железо) | `mock` (разработка). Реальный: питание = `atxctl/main.py`, консоль = `pyserial` на `SERIAL_PORT` (`/dev/ttyUSB0`, 115200).
|
||||||
|
- **Mock-состояние питания** хранится в `atxpi/tmp/atxpi_mock_power.json` (не в `/tmp`).
|
||||||
|
- **SSH-ключ:** `~/.ssh/id_ed25519` (в Gitea назван `Toros`), пользователь `eka`.
|
||||||
|
- **Python:** stdlib для core/CLI; полный набор (`pyserial`, `fastapi`, `uvicorn`, `mcp`) — в `atxpi/requirements.txt`, ставится в venv на устройстве, **не на HP Omen**.
|
||||||
|
- **Коммиты:** conventional (`feat:`, `fix:`, `chore:`), по чекпоинтам.
|
||||||
|
|
||||||
|
## Важно (безопасность)
|
||||||
|
|
||||||
|
- Живой сервер (продакшн) — **только чтение кода**, никаких команд питания.
|
||||||
|
- Питание/сброс — деструктивны: логировать действие, требовать аутентификацию.
|
||||||
|
- Управляющая плоскость биндится на VPN/loopback, `0.0.0.0` запрещён.
|
||||||
|
- Никаких `rm`/записей в `/tmp` корня ФС и вне рабочего каталога.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
- ✅ Шаг ①: core + CLI + mock (работает на stdlib, проверено на mock)
|
||||||
|
- ⬜ Шаг ②: консоль на `pyserial`
|
||||||
|
- ⬜ Шаг ③: web-UI (FastAPI + REST + WS-консоль)
|
||||||
|
- ⬜ Шаг ④: MCP-сервер для агентов
|
||||||
|
- ⬜ Шаг ⑤: systemd + деплой на Pi 2B
|
||||||
@@ -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/тяжёлых БД.
|
||||||
|
- Никаких реальных проверок питания на «живых» серверах — только стенд.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""atxpi — управляющая плоскость: питание + RS232-консоль сервера."""
|
||||||
|
__version__ = "0.1.0"
|
||||||
Executable
+63
@@ -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())
|
||||||
@@ -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()}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+22
@@ -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)"
|
||||||
@@ -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
|
||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# atxpi — one-shot installer. Идемпотентный: повторные запуски обновляют репо
|
||||||
|
# и перезапускают сервис.
|
||||||
|
#
|
||||||
|
# # 1) из чекаута репозитория:
|
||||||
|
# sudo bash install.sh
|
||||||
|
#
|
||||||
|
# # 2) curl-установка (публичное репо). URL укажи через ATXPI_GIT_REPO:
|
||||||
|
# curl -s <repo>/raw/branch/main/install.sh | sudo ATXPI_GIT_REPO=<repo> bash
|
||||||
|
#
|
||||||
|
# # 3) закрытое репо — добавь токен:
|
||||||
|
# curl -s <repo>/raw/branch/main/install.sh \
|
||||||
|
# | sudo ATXPI_GIT_REPO=<repo> ATXPI_GIT_TOKEN=<tok> bash
|
||||||
|
#
|
||||||
|
# Хост репозитория в код не зашит — передаётся через ATXPI_GIT_REPO.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── конфигурация (переопределяется через env) ───────────────────────────────
|
||||||
|
ATXPI_TARGET="${ATXPI_TARGET:-/opt/arm}" # куда кладём чекаут репо
|
||||||
|
ATXPI_USER="${ATXPI_USER:-atxpi}" # системный юзер сервиса
|
||||||
|
ATXPI_BRANCH="${ATXPI_BRANCH:-main}"
|
||||||
|
ATXPI_GIT_REPO="${ATXPI_GIT_REPO:-}" # URL репо (для свежего клона обязателен)
|
||||||
|
ATXPI_GIT_TOKEN="${ATXPI_GIT_TOKEN:-}" # токен для закрытого репо (иначе пусто)
|
||||||
|
|
||||||
|
ATXPI_BIND_HOST="${ATXPI_BIND_HOST:-0.0.0.0}" # ⚠️ ограничь до VPN/LAN (см. безопасность)
|
||||||
|
ATXPI_BIND_PORT="${ATXPI_BIND_PORT:-8443}"
|
||||||
|
ATXPI_SERIAL="${ATXPI_SERIAL:-/dev/ttyUSB0}"
|
||||||
|
ATXPI_BAUD="${ATXPI_BAUD:-115200}"
|
||||||
|
ATXPI_DRIVER="${ATXPI_DRIVER:-real}"
|
||||||
|
|
||||||
|
log() { echo -e "\e[1;32m[atxpi]\e[0m $*"; }
|
||||||
|
err() { echo -e "\e[1;31m[atxpi]\e[0m $*" >&2; }
|
||||||
|
|
||||||
|
# ── 0. root ──────────────────────────────────────────────────────────────────
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
err "Запускай через sudo (или sudo -E для варианта с env-переменными)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 1. системные зависимости ─────────────────────────────────────────────────
|
||||||
|
log "Устанавливаю системные зависимости (git, curl, python3-venv)..."
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y git curl ca-certificates python3-venv python3-pip
|
||||||
|
|
||||||
|
# ── 2. сервисный пользователь + доступ к железу ──────────────────────────────
|
||||||
|
if ! id "$ATXPI_USER" >/dev/null 2>&1; then
|
||||||
|
log "Создаю сервисного пользователя '$ATXPI_USER'..."
|
||||||
|
useradd --system --create-home --shell /usr/sbin/nologin "$ATXPI_USER"
|
||||||
|
fi
|
||||||
|
# I2C (expander-хат в atxctl) и dialout (ttyUSB0 — консоль)
|
||||||
|
usermod -aG i2c,dialout "$ATXPI_USER" 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── 3. клонирование / обновление репо ────────────────────────────────────────
|
||||||
|
if [ -d "$ATXPI_TARGET/.git" ]; then
|
||||||
|
log "Репозиторий уже есть: обновляю $ATXPI_TARGET..."
|
||||||
|
ATXPI_GIT_REPO="${ATXPI_GIT_REPO:-$(git -C "$ATXPI_TARGET" remote get-url origin 2>/dev/null || true)}"
|
||||||
|
git -C "$ATXPI_TARGET" fetch origin "$ATXPI_BRANCH" || true
|
||||||
|
git -C "$ATXPI_TARGET" reset --hard "origin/$ATXPI_BRANCH" || true
|
||||||
|
else
|
||||||
|
if [ -z "$ATXPI_GIT_REPO" ]; then
|
||||||
|
err "Укажи ATXPI_GIT_REPO (URL репозитория) для первого клона."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "Клонирую репозиторий в $ATXPI_TARGET..."
|
||||||
|
if [ -n "$ATXPI_GIT_TOKEN" ]; then
|
||||||
|
git -c http.extraHeader="Authorization: token $ATXPI_GIT_TOKEN" \
|
||||||
|
clone -q -b "$ATXPI_BRANCH" "$ATXPI_GIT_REPO" "$ATXPI_TARGET"
|
||||||
|
# сохранить креды для будущих `git pull` (не оставляя токен в remote URL)
|
||||||
|
host="$(printf '%s' "$ATXPI_GIT_REPO" \
|
||||||
|
| sed -E 's#^[a-zA-Z]+://##; s#.*@##; s#^git@##; s#[:/].*$##')"
|
||||||
|
printf 'protocol=https\nhost=%s\nusername=oauth2\npassword=%s\n\n' \
|
||||||
|
"$host" "$ATXPI_GIT_TOKEN" | git credential approve || true
|
||||||
|
else
|
||||||
|
git clone -q -b "$ATXPI_BRANCH" "$ATXPI_GIT_REPO" "$ATXPI_TARGET"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_DIR="$ATXPI_TARGET/atxpi"
|
||||||
|
|
||||||
|
# ── 4. venv + зависимости ────────────────────────────────────────────────────
|
||||||
|
log "Ставлю зависимости в venv..."
|
||||||
|
VENV="$APP_DIR/venv"
|
||||||
|
[ -d "$VENV" ] || python3 -m venv "$VENV"
|
||||||
|
"$VENV/bin/pip" install --upgrade pip >/dev/null
|
||||||
|
"$VENV/bin/pip" install -q -r "$APP_DIR/requirements.txt"
|
||||||
|
chown -R "$ATXPI_USER":"$ATXPI_USER" "$VENV"
|
||||||
|
|
||||||
|
# ── 5. конфигурация / секреты (generated, НЕ в git) ──────────────────────────
|
||||||
|
ENV_FILE=/etc/atxpi/atxpi.env
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
log "Создаю ${ENV_FILE}..."
|
||||||
|
mkdir -p /etc/atxpi
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
# atxpi — локальная конфигурация (создаётся install.sh, НЕ хранится в git)
|
||||||
|
# ATXPI_DRIVER: real | mock
|
||||||
|
ATXPI_DRIVER=$ATXPI_DRIVER
|
||||||
|
SERIAL_PORT=$ATXPI_SERIAL
|
||||||
|
SERIAL_BAUD=$ATXPI_BAUD
|
||||||
|
# токен для REST-API и браузера (генерируется при установке)
|
||||||
|
ATXPI_WEB_TOKEN=$(python3 -c "import secrets;print(secrets.token_hex(32))")
|
||||||
|
EOF
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
chown "$ATXPI_USER":"$ATXPI_USER" "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 6. systemd-юнит ──────────────────────────────────────────────────────────
|
||||||
|
log "Разворачиваю systemd-юнит..."
|
||||||
|
sed "s|@USER@|$ATXPI_USER|g; s|@TARGET@|$ATXPI_TARGET|g; s|@BIND_HOST@|$ATXPI_BIND_HOST|g; s|@BIND_PORT@|$ATXPI_BIND_PORT|g" \
|
||||||
|
"$APP_DIR/deploy/systemd/atxpi.service" > /etc/systemd/system/atxpi.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# ── 7. старт ─────────────────────────────────────────────────────────────────
|
||||||
|
if [ -f "$APP_DIR/webui/server.py" ]; then
|
||||||
|
log "Веб-приложение на месте: включаю и запускаю сервис."
|
||||||
|
systemctl enable atxpi.service >/dev/null 2>&1 || true
|
||||||
|
systemctl restart atxpi.service || true
|
||||||
|
else
|
||||||
|
log "Веб-приложение (atxpi/webui/server.py) ещё не готово — юнит установлен, но не запущен."
|
||||||
|
log "Запуск после появления webui/server.py (шаг ③)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
log "Готово. Сводка:"
|
||||||
|
echo " Репозиторий : $ATXPI_TARGET"
|
||||||
|
echo " Пользователь: $ATXPI_USER (группы: $(id -nG "$ATXPI_USER" 2>/dev/null | sed 's/ /,/g'))"
|
||||||
|
echo " venv : $APP_DIR/venv"
|
||||||
|
echo " Конфиг : $ENV_FILE (0600)"
|
||||||
|
echo " Юнит : atxpi.service (bind ${ATXPI_BIND_HOST}:${ATXPI_BIND_PORT})"
|
||||||
|
[ -f "$APP_DIR/webui/server.py" ] && echo " Статус : $(systemctl is-active atxpi.service 2>/dev/null || echo unknown)"
|
||||||
Reference in New Issue
Block a user