⚡ C++ madServerPowerManager — управление питанием Proxmox
В серверной живёт основной Proxmox-сервер, ИБП FinePower IEC LCD 3000VA и обычная человеческая надежда, что бензогенератор всё-таки заведётся. Но генератор запускается не мгновенно, а надежда — не самый удачный протокол автоматизации. Поэтому я вынес управление питанием на отдельный Intel NUC.
NUC получает телеметрию ИБП по USB, следит за Proxmox и локально управляет Wi-Fi-розеткой Tuya. Если сеть не вернулась за отведённое время, он должен штатно завершить работу сервера, убедиться, что тот действительно выключился, снять с него питание и подать его снова только после устойчивого восстановления электросети.
armed=false. Ограниченный SSH-доступ к Proxmox и полный физический тест аварийного цикла ещё не введены в эксплуатацию, поэтому демон ничего не выключает.
✅ Что уже получилось, а что ещё впереди
Уже реализовано
- локальная телеметрия FinePower через NUT;
- нативный клиент протокола Tuya 3.5 на C++20 без Python и облака;
- чтение состояния и переключение отдельной Wi-Fi-розетки;
- конечная машина состояний с безопасным режимом
MONITOR_ONLY; - атомарное сохранение аварийного цикла на диск;
- systemd-служба с ограничениями доступа;
- локальный HTTP JSON API только для чтения;
- 27 автоматических проверок инвариантов FSM;
- восстановление после перезапуска NUT и самого демона.
Находится в подготовке
- отдельный ограниченный SSH-пользователь на Proxmox и forced command;
- перевод Proxmox в режим NUT netclient;
- контролируемый тест полного цикла с оператором рядом;
- последующее включение
armed=trueтолько вручную.
Запланировано
madPowerTelemetry: отдельный наблюдающий демон с SQLite;- статистика времени shutdown и загрузки;
- рекомендации по безопасным таймаутам без самовольного изменения критических настроек.
🧩 Архитектура
ИБП FinePower IEC LCD 3000VA
│ USB
▼
Intel NUC D54250WYK2
├── NUT: телеметрия ИБП
├── madServerPowerManager
│ ├── конечная машина состояний
│ ├── SSH-контроль Proxmox
│ ├── нативный Tuya 3.5
│ ├── HTTP JSON API
│ └── state.json
├── madPowerTelemetry (запланирован)
└── локальная сеть
├── Proxmox-сервер
└── Wi-Fi-розетка Tuya
NUC питается от того же ИБП и потребляет примерно 15 Вт. Он независим от Proxmox, имеет собственную ОС, видит ИБП по USB и остаётся в сети после отключения большого сервера. Заодно на нём можно дублировать несколько самых важных лёгких сервисов.
🔋 Почему нельзя просто выключить весь ИБП
Выход ИБП питает не только Proxmox. На нём остаются NUC, роутер, коммутатор и точка доступа. Если отключить выход ИБП целиком, менеджер питания потеряет и датчик, и сеть, и возможность восстановить сервер.
Отдельная Wi-Fi-розетка решает задачу точечно: снимает питание только с Proxmox. После этого нагрузка на ИБП заметно уменьшается, а NUC продолжает наблюдение. Точное время автономной работы я пока не обещаю: его надо измерять на реальном оборудовании, а не высчитывать по оптимистичной надписи на коробке.
🔌 Подготовка Wi-Fi-розетки SmartLife для локального управления
Розетка T34 Smart Plug+ уже была добавлена в SmartLife. Для локального управления NUC и устройство должны находиться в одной сети, а адрес розетки лучше закрепить через DHCP reservation.
Быстрый Python-прототип
Сначала я проверил саму идею на Python с tinytuya. Это хороший способ быстро выяснить IP, версию протокола и DPS, не тратя неделю на написание криптографии до первого щелчка реле.
sudo apt install python3-venv -y
python3 -m venv ~/tuya_env
source ~/tuya_env/bin/activate
pip install tinytuya
python -m tinytuya scan
Обезличенный результат поиска выглядит примерно так:
Device ID: <DEVICE_ID>
IP address: 192.168.1.30
Protocol: 3.5
Версия протокола здесь критична: реализация для Tuya 3.3 не станет волшебным образом понимать 3.5. IP можно закрепить на DHCP-сервере, Device ID получают через инструменты Tuya.
Получение Local Key
- Зарегистрировать учётную запись в Tuya IoT Platform.
- Создать Cloud Project типа Smart Home в правильном регионе.
- Подключить сервис IoT Core.
- Через Link Tuya App Account привязать SmartLife, отсканировав QR-код.
- Запустить мастер
tinytuyaи получить параметры устройства.
python -m tinytuya wizard
Access ID: <TUYA_ACCESS_ID>
Access Secret: <TUYA_ACCESS_SECRET>
Device ID: <DEVICE_ID>
Local Key: <LOCAL_KEY>
Region: eu
Исторический proof of concept
#!/usr/bin/env python3
import sys
import tinytuya
DEVICE_ID = "<DEVICE_ID>"
DEVICE_IP = "192.168.1.30"
LOCAL_KEY = "<LOCAL_KEY>"
VERSION = 3.5
SWITCH_DP = 1
def make_device():
return tinytuya.Device(
DEVICE_ID, DEVICE_IP, LOCAL_KEY, version=VERSION
)
def set_power(state):
device = make_device()
device.set_status(state, SWITCH_DP)
print("Розетка включена" if state else "Розетка выключена")
def get_status():
data = make_device().status()
dps = data.get("dps", {})
value = dps.get(str(SWITCH_DP))
if value is None:
value = dps.get(SWITCH_DP)
return value
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Использование: plug_control.py on|off|status")
raise SystemExit(2)
command = sys.argv[1].lower()
if command == "on":
set_power(True)
elif command == "off":
set_power(False)
elif command == "status":
state = get_status()
if state is None:
print("Не удалось получить статус")
raise SystemExit(1)
print("Розетка включена" if state else "Розетка выключена")
else:
print("Неизвестная команда")
raise SystemExit(2)
В исходном наброске был ещё один маленький капкан: в JSON Tuya номер DPS обычно приходит строкой, поэтому проверять только целочисленный ключ 1 нельзя. Прототип свою работу выполнил, но после перезагрузки NUC выяснилась цена окружения: нет активной .venv — нет модуля tinytuya. Для автономного системного сервиса это лишняя точка отказа.
🧩 Убираем Python: собственный клиент Tuya 3.5 на C++
Финальный plugctl стал нативной программой. Ему не нужны Python, pip, TinyTuya, виртуальное окружение, облачный API и интернет:
plugctl --status
plugctl --on
plugctl --off
Клиент открывает TCP-соединение с розеткой, работает с пакетами формата 0x6699, согласует сеансовый ключ, обменивается случайными nonce, использует HMAC-SHA256, SHA-256 и AES-128-GCM, запрашивает DPS и меняет switch_1. Ответ проверяется, а сетевые операции ограничены таймаутами и повторами.
tuya_plugctl_native/
├── CMakeLists.txt
├── include/
├── src/
└── config.example.json
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
sudo install -m 0755 build/plugctl /usr/local/bin/plugctl
Безопасный пример конфигурации:
{
"device_id": "<DEVICE_ID>",
"device_ip": "192.168.1.30",
"local_key": "<LOCAL_KEY>",
"protocol_version": "3.5",
"switch_dp": 1
}
sudo chmod 600 /etc/plugctl.json
В madServerPowerManager тот же нативный Tuya-код используется уже как библиотечный компонент, без запуска промежуточного процесса.
🔋 Подключаем FinePower IEC LCD 3000VA к NUT
Рабочая конфигурация NUT уже существовала на Proxmox. Поэтому я не стал заново изобретать имя USB-драйвера и гадать по VID/PID: сначала изучил действующую конфигурацию, затем перенёс её на NUC и проверил там. Только после устойчивой работы NUC можно переводить Proxmox в сетевой клиент NUT.
Актуальная проверка на момент публикации показала:
ups.status: OL
battery.charge: 100
battery.voltage: 54.1
input.voltage: 230.0
output.voltage: 230.0
ups.load: 4
input.frequency: 49.9
ups.temperature: 28.0
Некоторые параметры могут меняться от опроса к опросу. В частности, этот FinePower не сообщает battery.runtime, поэтому логика не должна зависеть только от расчётного времени автономии.
upsc ups@localhost
systemctl status nut-driver@ups
systemctl status nut-server
journalctl -u nut-server
🧠 madServerPowerManager: конечная машина состояний
Здесь уже начинается не «скрипт выключения», а нормальная автоматика. Основной сценарий такой:
- NUT подтверждает переход ИБП с
OLнаOB. - Демон отфильтровывает краткий провал сети.
- Начинается восьмиминутное ожидание запуска генератора.
- Если сеть вернулась, shutdown не отправляется.
- Если сеть не вернулась, NUC запрашивает штатное выключение Proxmox по SSH.
- Демон ждёт нескольких признаков завершения работы.
- Только после подтверждения выключает розетку.
- NUC остаётся работать от ИБП.
- После возврата сети выдерживается период стабильности.
- Розетка включается, а настройка BIOS «Power On after AC Loss» запускает Proxmox.
- Демон контролирует восстановление SSH и API.
STARTING
├── armed=false ───────────────────────────────► MONITOR_ONLY
└── armed=true
▼
MAINS_ON ──OB──► ON_BATTERY_GRACE
▲ │
│ ▼
RECOVERY ◄── RESTORING_SERVER_POWER
▲ ▲
│ │
MAINS_STABILIZING ◄── WAITING_FOR_MAINS
▲
│
CUTTING_SERVER_POWER
▲
│
WAITING_SERVER_OFF
▲
│
SHUTDOWN_REQUESTED
Любая неоднозначность ──► DEGRADED
Ошибка обязательного конфига ──► ERROR
⏱️ Почему нельзя просто подождать пять минут
Измеренное полное выключение этого Proxmox заняло примерно 4 минуты 48 секунд. Таймаут ровно в пять минут оставляет смешной запас, поэтому базовое ожидание после shutdown увеличено до 360 секунд.
Это не универсальная цифра. Время зависит от остановки виртуальных машин и контейнеров, сброса данных на диск, файловых систем, сетевых хранилищ, обновлений и просто зависшего сервиса. Таймаут должен быть консервативным, а принудительное снятие питания после его истечения по умолчанию запрещено.
🛡️ Как подтвердить, что сервер действительно выключен
Одного ping недостаточно: ICMP может быть запрещён, сеть может исчезнуть раньше завершения записи на диск, а закрытый SSH-порт сам по себе ещё не доказывает выключение. Поэтому используются несколько сигналов:
- SSH недоступен;
- Proxmox API недоступен;
- прошло минимальное время после команды;
- нагрузка ИБП снизилась и стабилизировалась;
- если розетка умеет измерять мощность — её потребление также снизилось.
ups.load: 4 не равно точному числу ватт: ИБП округляет малую нагрузку и может считать процент от VA либо активной мощности. Это вспомогательный признак, а не приговор рубильнику.
⚙️ Конфигурация
[general]
armed=false
poll_interval_seconds=5
state_file=/var/lib/mad-server-power-manager/state.json
[ups]
nut_host=127.0.0.1
nut_port=3493
nut_name=ups
on_battery_confirm_seconds=10
grace_seconds=480
mains_stable_seconds=60
critical_battery_charge=15
critical_runtime_seconds=600
[proxmox]
host=192.168.1.20
port=22
user=mad-power-manager
private_key=/etc/mad-server-power-manager/id_ed25519
known_hosts=/etc/mad-server-power-manager/known_hosts
shutdown_grace_seconds=480
shutdown_timeout_seconds=360
force_cut_after_shutdown_timeout=false
[plug]
host=192.168.1.30
port=6668
protocol_version=3.5
switch_dp=1
minimum_off_seconds=20
[api]
enabled=true
listen_address=127.0.0.1
port=9187
allow_control=false
Секреты лежат отдельно:
[plug]
device_id=<DEVICE_ID>
local_key=<LOCAL_KEY>
[api]
token=<API_TOKEN>
sudo chown madspm:madspm /etc/mad-server-power-manager/secrets.ini
sudo chmod 600 /etc/mad-server-power-manager/secrets.ini
🔐 SSH без пароля — и без лишних полномочий
Пароль нельзя «сохранить в виде хеша», а потом использовать для входа: хеш проверяет пароль, но не восстанавливает его. Для автоматики нужна отдельная SSH-пара ключей и отдельный пользователь mad-power-manager.
На Proxmox ключ следует ограничить forced command, запретом PTY, agent/X11/port forwarding и, по возможности, адресом NUC. Демон использует отдельный known_hosts со строгой проверкой host key. Реальный ключ и строку authorized_keys в статью, разумеется, не кладём.
armed остаётся false.
💾 Состояние должно переживать рестарт
Файл /var/lib/mad-server-power-manager/state.json нужен, чтобы после рестарта демон не начал восьмиминутный таймер заново, помнил факт отправки shutdown и отключения розетки, продолжал восстановление и не включал сервер во время работы от батареи.
Запись выполняется атомарно: временный файл, fsync, затем rename. Повреждённое либо противоречивое состояние не даёт права на силовое действие — система уходит в безопасный режим.
⚙️ Демонизация через systemd
Фактически установленная служба выглядит так:
[Unit]
Description=madServerPowerManager monitor and power state manager
After=network-online.target nut-server.service
Wants=network-online.target nut-server.service
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=madspm
Group=madspm
WorkingDirectory=/var/lib/mad-server-power-manager
ExecStart=/usr/local/bin/mad-server-power-manager
Restart=on-failure
RestartSec=15
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadOnlyPaths=/etc/mad-server-power-manager
ReadWritePaths=/var/lib/mad-server-power-manager
UMask=0077
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mad-server-power-manager
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now mad-server-power-manager
systemctl status mad-server-power-manager
journalctl -u mad-server-power-manager -f
📡 Локальный API мониторинга
API слушает только 127.0.0.1:9187. Реализованы:
GET /api/v1/status
GET /api/v1/ups
GET /api/v1/proxmox
GET /api/v1/plug
GET /api/v1/events
GET /api/v1/health
GET /api/v1/config
Конфигурационный endpoint не возвращает секреты. Все методы, кроме GET, отвечают 405; управляющего HTTP API в этой версии нет, даже если ошибочно включить allow_control.
{
"version": "1.0.0",
"armed": false,
"state": "MONITOR_ONLY",
"ups_reachable": true,
"ups_status": "OL",
"battery_charge": 100,
"battery_runtime_seconds": null,
"load_percent": 4,
"proxmox_reachable": true,
"plug_reachable": true,
"plug_on": true
}
📊 Следующий этап: madPowerTelemetry
madPowerTelemetry пока запланирован и не установлен. Это будет отдельный наблюдающий демон: круглосуточный сбор телеметрии в SQLite, измерение времени перехода на батарею, shutdown, исчезновения SSH, снижения нагрузки и загрузки Proxmox, а также статистика дребезга сети.
madPowerTelemetry наблюдает и рекомендует. madServerPowerManager принимает безопасные решения.
[tuning]
mode=observe
Предполагаемые режимы: off, observe и ограниченный bounded. По умолчанию — только наблюдение. Телеметрия сможет рекомендовать shutdown_timeout_seconds, таймаут запуска, порог снижения нагрузки и период стабильности сети.
Она не должна автоматически менять восьмиминутное ожидание генератора, armed, force_cut_after_shutdown_timeout, критический заряд и само право физически отключать сервер.
🚦 Отказы и безопасное поведение
FSM рассматривает кратковременное отключение, дребезг сети, возврат питания до и после shutdown, потерю USB, рестарт NUT, демона или NUC, отсутствие battery.runtime, потерю SSH, недоступную розетку, неверный Local Key, повреждённый state-файл, превышение таймаута и ручное переключение реле.
Недостоверный NUT или неоднозначная телеметрия переводят систему в DEGRADED. Ошибка обязательной конфигурации — в ERROR. В обоих случаях автоматическое управление питанием запрещено. Безопасный отказ здесь означает «ничего силового не делать», а не «на всякий случай выключить всё».
🧪 Безопасный ввод в эксплуатацию
- Только чтение телеметрии NUT.
- Запуск демона с
armed=false. - Проверка SSH безопасной командой.
- Проверка розетки без подключённого сервера.
- Симуляция событий
OB/OL. - Контролируемый физический тест с оператором рядом.
- И только затем ручное включение
armed=true.
armed=true. Сейчас на NUC подтверждены активная systemd-служба, состояние MONITOR_ONLY, доступность NUT, Proxmox и розетки, а также здоровый read-only API. Реальный разряд, shutdown Proxmox и снятие питания на этом этапе не выполнялись.
🏁 Итог
Python позволил быстро проверить идею и услышать первый щелчок реле. Нативный C++ убрал зависимость от интерпретатора и виртуального окружения. NUT дал нормальную телеметрию, NUC стал независимым менеджером, а отдельная розетка позволила управлять только Proxmox, не обесточивая сеть и сам контроллер.
Самая важная часть проекта — не команда off, а консервативная машина состояний: она знает, когда ещё рано выключать, когда уже нельзя отменять начатый цикл и когда лучше остановиться и позвать человека. Следующая задача — завершить ограниченный SSH-контур, провести контролируемые испытания и начать копить статистику для настройки таймингов.
