Inverter Voltronic: различия между версиями
Sirmax (обсуждение | вклад) |
Sirmax (обсуждение | вклад) |
||
| Строка 34: | Строка 34: | ||
==<code>mpp_nut_bridge.py</code>== |
==<code>mpp_nut_bridge.py</code>== |
||
{{#spoiler:show=mpp_nut_bridge.py| |
{{#spoiler:show=mpp_nut_bridge.py| |
||
| + | Кода гавно конечно ) |
||
| − | |||
<PRE> |
<PRE> |
||
| + | #!/usr/bin/env python3 |
||
| + | |||
| + | import subprocess |
||
| + | import time |
||
| + | import tempfile |
||
| + | import os |
||
| + | import json |
||
| + | import logging |
||
| + | |||
| + | class CustomFormatter(logging.Formatter): |
||
| + | |||
| + | grey = "\x1b[38;20m" |
||
| + | yellow = "\x1b[33;20m" |
||
| + | red = "\x1b[31;20m" |
||
| + | bold_red = "\x1b[31;1m" |
||
| + | reset = "\x1b[0m" |
||
| + | format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" |
||
| + | |||
| + | FORMATS = { |
||
| + | logging.DEBUG: grey + format + reset, |
||
| + | logging.INFO: grey + format + reset, |
||
| + | logging.WARNING: yellow + format + reset, |
||
| + | logging.ERROR: red + format + reset, |
||
| + | logging.CRITICAL: bold_red + format + reset |
||
| + | } |
||
| + | |||
| + | def format(self, record): |
||
| + | log_fmt = self.FORMATS.get(record.levelno) |
||
| + | formatter = logging.Formatter(log_fmt) |
||
| + | return formatter.format(record) |
||
| + | |||
| + | STATE_FILE = "/var/lib/nut/mpp.state" |
||
| + | |||
| + | LOGLEVEL = os.environ.get('LOGLEVEL', 'INFO').upper() |
||
| + | |||
| + | logger = logging.getLogger("mpp_nut_bridge") |
||
| + | logger.setLevel(LOGLEVEL) |
||
| + | |||
| + | # create console handler with a higher log level |
||
| + | ch = logging.StreamHandler() |
||
| + | ch.setLevel(logging.DEBUG) |
||
| + | |||
| + | ch.setFormatter(CustomFormatter()) |
||
| + | |||
| + | logger.addHandler(ch) |
||
| + | |||
| + | # Команда mppsolar |
||
| + | # /etc/nut/mpp-solar -p /dev/ttyUSB1 -c QPIGS -I -P PI30 -o json | jq '.' |
||
| + | DEV="/dev/serial/by-id/usb-1a86_USB2.0-Ser_-if00-port0" |
||
| + | #DEV="/dev/hidraw1" |
||
| + | MPPSOLAR_CMD = [ |
||
| + | "/usr/local/virtualenvs/mppsolar/bin/python3", |
||
| + | "/etc/nut/mpp-solar", |
||
| + | "-p", DEV, |
||
| + | "-P", "PI30", |
||
| + | "-c", "QPIGS", |
||
| + | "-o", "json", |
||
| + | ] |
||
| + | |||
| + | |||
| + | def write_state(vars_dict): |
||
| + | """Атомарная запись файла состояния для dummy-ups""" |
||
| + | fd, tmp = tempfile.mkstemp() |
||
| + | with os.fdopen(fd, "w") as f: |
||
| + | for k, v in vars_dict.items(): |
||
| + | f.write(f"{k}: {v}\n") |
||
| + | logger.info(f"Replacing {STATE_FILE}") |
||
| + | os.replace(tmp, STATE_FILE) |
||
| + | |||
| + | def build_ups_status(data): |
||
| + | flags = [] |
||
| + | |||
| + | # UPS выключен |
||
| + | if not data.get("is_switched_on", 1): |
||
| + | return "OFF" |
||
| + | |||
| + | ac_in = data.get("ac_input_voltage", 0.0) |
||
| + | batt_cap = data.get("battery_capacity", 0) |
||
| + | charging = bool(data.get("is_charging_on", 0)) |
||
| + | load_on = bool(data.get("is_load_on", 0)) |
||
| + | |||
| + | if ac_in > 100: |
||
| + | flags.append("OL") # On Line |
||
| + | else: |
||
| + | flags.append("OB") # On Battery |
||
| + | |||
| + | if batt_cap < 10: |
||
| + | flags.append("LB") # Low Battery |
||
| + | |||
| + | if charging: |
||
| + | flags.append("CHRG") |
||
| + | elif load_on: |
||
| + | flags.append("DISCHRG") |
||
| + | |||
| + | if not flags: |
||
| + | return "UNKNOWN" |
||
| + | |||
| + | return " ".join(flags) |
||
| + | |||
| + | def poll_once(): |
||
| + | attempts=3 |
||
| + | while attempts>0: |
||
| + | try: |
||
| + | # Запускаем mppsolar и читаем JSON |
||
| + | res = subprocess.run( |
||
| + | MPPSOLAR_CMD, |
||
| + | capture_output=True, |
||
| + | text=True, |
||
| + | check=True, |
||
| + | ) |
||
| + | logger.info(f"STDOUT: {res.stdout}") |
||
| + | data = json.loads(res.stdout) |
||
| + | error = data.get("error", False) |
||
| + | logger.info(f"DATA: {data} Error: {error}") |
||
| + | # Собираем переменные NUT |
||
| + | if error: |
||
| + | logger.info("ERROR DETECTED") |
||
| + | vars_dict = { |
||
| + | "battery.charge": int(data.get("battery_capacity")), |
||
| + | "battery.voltage": float(data.get("battery_voltage")), |
||
| + | "input.voltage": float(data.get("ac_input_voltage")), |
||
| + | "input.frequency": float(data.get("ac_input_frequency")), |
||
| + | "output.voltage": float(data.get("ac_output_voltage")), |
||
| + | "output.frequency": float(data.get("ac_output_frequency")), |
||
| + | "ups.load": int(data.get("ac_output_load")), |
||
| + | "ups.power": int(data.get("ac_output_apparent_power")), |
||
| + | "ups.realpower": int(data.get("ac_output_active_power")), |
||
| + | "inverter_heat_sink_temperature": int(data.get("inverter_heat_sink_temperature")), |
||
| + | "ups.status": build_ups_status(data), |
||
| + | # Можно добавить свои: |
||
| + | # "device.mfr": "MPP Solar", |
||
| + | # "device.model": "PI30", |
||
| + | } |
||
| + | write_state(vars_dict) |
||
| + | return |
||
| + | except Exception as e: |
||
| + | logger.info(f"Error during data collection: {e}") |
||
| + | logger.info(f"Sleep 30 sec. before next attempt") |
||
| + | time.sleep(30) |
||
| + | attempts = attempts - 1 |
||
| + | # If exited on attempts < 0 |
||
| + | vars_dict = { |
||
| + | "battery.charge": 0, |
||
| + | "battery.voltage": 0, |
||
| + | "input.voltage": 0, |
||
| + | "input.frequency": 0, |
||
| + | "output.voltage": 0, |
||
| + | "output.frequency": 0, |
||
| + | "ups.load": 0, |
||
| + | "ups.power": 0, |
||
| + | "ups.realpower": 0, |
||
| + | "inverter_heat_sink_temperature": 0, |
||
| + | "ups.status": "OFF" |
||
| + | } |
||
| + | write_state(vars_dict) |
||
| + | |||
| + | |||
| + | def main(): |
||
| + | logger.info("Starting") |
||
| + | SLEEP = 20 |
||
| + | while True: |
||
| + | try: |
||
| + | poll_once() |
||
| + | except Exception as e: |
||
| + | # При ошибке помечаем UPS как OFF |
||
| + | try: |
||
| + | logger.info(f"Error during poll: {e}") |
||
| + | write_state({"ups.status": "OFF"}) |
||
| + | except Exception: |
||
| + | pass |
||
| + | logger.info(f"Sleeping {SLEEP} sec brfore next check") |
||
| + | time.sleep(SLEEP) # опрос каждые 5 секунд |
||
| + | |||
| + | if __name__ == "__main__": |
||
| + | main() |
||
</PRE> |
</PRE> |
||
}} |
}} |
||
| + | |||
==<code>mpp-nut-bridge.service</code>== |
==<code>mpp-nut-bridge.service</code>== |
||
{{#spoiler:mpp-nut-bridge.service| |
{{#spoiler:mpp-nut-bridge.service| |
||
Версия 17:24, 15 января 2026
Вольтроник
Инструкция
Снимать данные с линукса
mpp-solar -p /dev/hidraw0 -c QPIGS -I -P PI30
но работает лучше как минимум у меня через ком-порт и /dev/ttyUSB0
Но для этого нужен переходник USB -> COM
Если переходников несколько то лучше указывать by-id - /dev/serial/by-id/usb-1a86_USB2.0-Ser_-if00-port0
NUT
так как я хотел что бы инвертор прикинулся нормальным UPS а не вот это вот все то набросал простой скрипт для снятия данных (драйвер встроенный в NUT не заработал, написать свой на основе скрипта я не осилил)
Весь код написан на скорую руку, с кучей хардкода, так как надо было прям сейчас, а передывать пока нет времени
Логика работы такая:
- скрипт
mpp_nut_bridge.py(через systemd unit) работает в вечном цикле и складывает результат в файл/var/lib/nut/mpp.state - NUT умеет читать данные из внешнего файла через
driver = dummy-ups
[mpp]
driver = dummy-ups
port = /var/lib/nut/mpp.state
desc = "MPP via mppsolar"
- для того что бы отдавать данные на zabbix используется бридж в SNMP
mpp_nut_bridge.py
mpp-nut-bridge.service
snmpd.conf
Часть конфига ответвенная за "проброс" запросов к snmpd
Тут три ветки
.1.3.6.1.2.1.33"Стандартный" MIB - просто для теста, у меня в заббиксе он не используется.1.3.6.1.4.1.318MIB специфичный для APC - все части взяты из конфига темплейта zabbix, возможно в оригинальном MIB больше данных.1.3.6.1.4.1.418- ветка выбрана от фонаря и используется для мониторинга BMS батареи (это отдельная задача - мониторинг батареи Daly BMS)ups-snmp-passpersist.pyимя файла который дергать на запрос (ему передается тип изапроса GET/GETNEXT и OID, другие типы игнорируем)
pass .1.3.6.1.2.1.33 /etc/nut/ups-snmp-passpersist.py pass .1.3.6.1.4.1.318 /etc/nut/ups-snmp-passpersist.py pass .1.3.6.1.4.1.418 /etc/nut/batt-dale-snmp.py
По сути этот конфиг на каждый запрос вызывает скрипт с параметрами (хорошо бы конечно сделать постоянно висящего демона что бы не форкать на каждый запрос но пока и так сойдет)