⚡ C++ madServerPowerManager — управление питанием Proxmox

В серверной живёт основной Proxmox-сервер, ИБП FinePower IEC LCD 3000VA и обычная человеческая надежда, что бензогенератор всё-таки заведётся. Но генератор запускается не мгновенно, а надежда — не самый удачный протокол автоматизации. Поэтому я вынес управление питанием на отдельный Intel NUC.

NUC получает телеметрию ИБП по USB, следит за Proxmox и локально управляет Wi-Fi-розеткой Tuya. Если сеть не вернулась за отведённое время, он должен штатно завершить работу сервера, убедиться, что тот действительно выключился, снять с него питание и подать его снова только после устойчивого восстановления электросети.

Состояние проекта на момент публикации. Наблюдение за ИБП, нативный Tuya-клиент, машина состояний, сохранение состояния, systemd-служба и локальный read-only API уже работают. На NUC служба намеренно запущена с 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

  1. Зарегистрировать учётную запись в Tuya IoT Platform.
  2. Создать Cloud Project типа Smart Home в правильном регионе.
  3. Подключить сервис IoT Core.
  4. Через Link Tuya App Account привязать SmartLife, отсканировав QR-код.
  5. Запустить мастер 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
Local Key — секрет. Его нельзя публиковать в статье, репозитории, журнале или конфигурации с общедоступными правами. То же относится к Access Secret и приватным SSH-ключам.

Исторический 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: конечная машина состояний

Здесь уже начинается не «скрипт выключения», а нормальная автоматика. Основной сценарий такой:

  1. NUT подтверждает переход ИБП с OL на OB.
  2. Демон отфильтровывает краткий провал сети.
  3. Начинается восьмиминутное ожидание запуска генератора.
  4. Если сеть вернулась, shutdown не отправляется.
  5. Если сеть не вернулась, NUC запрашивает штатное выключение Proxmox по SSH.
  6. Демон ждёт нескольких признаков завершения работы.
  7. Только после подтверждения выключает розетку.
  8. NUC остаётся работать от ИБП.
  9. После возврата сети выдерживается период стабильности.
  10. Розетка включается, а настройка BIOS «Power On after AC Loss» запускает Proxmox.
  11. Демон контролирует восстановление 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
Если сеть вернулась уже после отправки shutdown, цикл нельзя просто отменить. Нужно дождаться выключения Proxmox, снять питание, выдержать минимальную паузу и снова подать питание. Иначе сервер может остаться программно выключенным при живой электросети.

⏱️ Почему нельзя просто подождать пять минут

Измеренное полное выключение этого 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 в статью, разумеется, не кладём.

Этот этап в текущей установке ещё не завершён. Пока он не проверен безопасной командой и контролируемым shutdown, 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. В обоих случаях автоматическое управление питанием запрещено. Безопасный отказ здесь означает «ничего силового не делать», а не «на всякий случай выключить всё».

🧪 Безопасный ввод в эксплуатацию

  1. Только чтение телеметрии NUT.
  2. Запуск демона с armed=false.
  3. Проверка SSH безопасной командой.
  4. Проверка розетки без подключённого сервера.
  5. Симуляция событий OB/OL.
  6. Контролируемый физический тест с оператором рядом.
  7. И только затем ручное включение armed=true.
Программа никогда не должна сама включать armed=true. Сейчас на NUC подтверждены активная systemd-служба, состояние MONITOR_ONLY, доступность NUT, Proxmox и розетки, а также здоровый read-only API. Реальный разряд, shutdown Proxmox и снятие питания на этом этапе не выполнялись.

🏁 Итог

Python позволил быстро проверить идею и услышать первый щелчок реле. Нативный C++ убрал зависимость от интерпретатора и виртуального окружения. NUT дал нормальную телеметрию, NUC стал независимым менеджером, а отдельная розетка позволила управлять только Proxmox, не обесточивая сеть и сам контроллер.

Самая важная часть проекта — не команда off, а консервативная машина состояний: она знает, когда ещё рано выключать, когда уже нельзя отменять начатый цикл и когда лучше остановиться и позвать человека. Следующая задача — завершить ограниченный SSH-контур, провести контролируемые испытания и начать копить статистику для настройки таймингов.