Files

241 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Read-only Modbus TCP weather collector. Python standard library only."""
import argparse
import fcntl
import json
import logging
from logging.handlers import RotatingFileHandler
import math
import os
from pathlib import Path
import socket
import sqlite3
import struct
import sys
import time
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from settings import load as load_config
BASE = Path(__file__).resolve().parent
FIELDS = ("temperature", "humidity", "pressure")
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def read_exact(sock, size):
data = bytearray()
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk:
raise OSError("Urządzenie zamknęło połączenie przed końcem odpowiedzi")
data.extend(chunk)
return bytes(data)
def read_registers(settings, start, count):
unit = settings["unit_id"]
function = settings["function_code"]
request = struct.pack(">HHHBBHH", 1, 0, 6, unit, function, start, count)
with socket.create_connection(
(settings["host"], settings["port"]), timeout=settings["timeout_seconds"]
) as sock:
sock.settimeout(settings["timeout_seconds"])
sock.sendall(request)
transaction, protocol, length, response_unit = struct.unpack(
">HHHB", read_exact(sock, 7)
)
if (transaction, protocol, response_unit) != (1, 0, unit):
raise ValueError("Nieprawidłowy nagłówek odpowiedzi Modbus")
if not 2 <= length <= 254:
raise ValueError("Nieprawidłowa długość odpowiedzi Modbus")
payload = read_exact(sock, length - 1)
if payload[0] == (function | 0x80):
code = payload[1] if len(payload) > 1 else "brak kodu"
raise ValueError(f"Wyjątek Modbus: {code}")
if len(payload) != 2 + count * 2 or payload[:2] != bytes((function, count * 2)):
raise ValueError("Nieprawidłowa funkcja lub liczba bajtów Modbus")
return struct.unpack(">" + "H" * count, payload[2:])
def decode(registers, start, sensors):
values, raw = {}, {}
for name in FIELDS:
sensor = sensors[name]
number = registers[sensor["address"] - start]
raw[name] = number
if sensor["signed"] and number >= 32768:
number -= 65536
value = number * sensor["multiplier"] + sensor["offset"]
if not math.isfinite(value) or not sensor["min"] <= value <= sensor["max"]:
raise ValueError(f"{name}: wynik {value} poza zakresem konfiguracji")
values[name] = value
return values, raw
class FreshnessError(ValueError):
"""The station is reachable but its source data is not confirmed fresh."""
def verify_freshness(config):
h = config.get('heartbeat', {})
if not h.get('enabled', False):
return False
deadline = time.monotonic() + h['timeout_seconds']
settings = dict(config['modbus'])
def read(address):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise FreshnessError('Heartbeat: brak zmiany licznika w wymaganym czasie')
settings['timeout_seconds'] = min(config['modbus']['timeout_seconds'], remaining)
return read_registers(settings, address, 1)[0]
def snapshot():
for check in h.get('checks', []):
value = read(check['address'])
if value not in check['allowed_values']:
raise FreshnessError('Heartbeat: nieprawidłowy stan diagnostyczny źródła')
return read(h['address'])
previous = snapshot()
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise FreshnessError('Heartbeat: brak zmiany licznika w wymaganym czasie')
time.sleep(min(h['poll_interval_seconds'], remaining))
current = snapshot()
if time.monotonic() <= deadline and current != previous:
return True
def collect(config):
result = collect_fast(config)
total = result[3]
recovery = config.get('recovery', {'attempts': 5, 'delay_seconds': 60})
for _ in range(recovery['attempts']):
if result[1] is not None:
break
time.sleep(recovery['delay_seconds'])
retry_config = dict(config, modbus=dict(config['modbus'], attempts=1))
result = collect_fast(retry_config)
total += result[3]
return result[:3] + (total, result[4])
def collect_fast(config):
settings = config["modbus"]
attempts = settings["attempts"]
if not 1 <= attempts <= 10:
raise ValueError("Liczba prób musi wynosić od 1 do 10")
for attempt in range(1, attempts + 1):
try:
verify_freshness(config)
# Read only configured registers, including widely separated addresses.
registers = {}
for name in FIELDS:
address = config['sensors'][name]['address']
if address not in registers:
registers[address] = read_registers(settings, address, 1)[0]
values, raw = decode(registers, 0, config['sensors'])
return utc_now(), values, raw, attempt, None
except FreshnessError as exc:
return utc_now(), None, None, attempt, str(exc)
except (OSError, ValueError) as exc:
logging.warning("Próba %s/%s: %s", attempt, attempts, exc)
if attempt < attempts:
time.sleep(settings["retry_delay_seconds"])
else:
return utc_now(), None, None, attempt, str(exc)
def save_result(db_path, checked_at, values, raw, attempts, error):
with sqlite3.connect(db_path, timeout=10) as db:
db.execute("PRAGMA journal_mode=WAL")
db.execute("""CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY,
checked_at TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('ok', 'error')),
temperature_c REAL, humidity_percent REAL, pressure_hpa REAL,
raw_json TEXT, attempts INTEGER NOT NULL, error TEXT
)""")
db.execute("CREATE INDEX IF NOT EXISTS readings_time ON readings(checked_at)")
db.execute("""INSERT INTO readings
(checked_at,status,temperature_c,humidity_percent,pressure_hpa,raw_json,attempts,error)
VALUES (?,?,?,?,?,?,?,?)""", (
checked_at, "ok" if values is not None else "error",
*((values[name] if values is not None else None) for name in FIELDS),
json.dumps(raw) if raw is not None else None, attempts, error
))
last_good = db.execute("""SELECT checked_at,temperature_c,humidity_percent,pressure_hpa
FROM readings WHERE status='ok' ORDER BY id DESC LIMIT 1""").fetchone()
return last_good
def write_status(path, checked_at, values, last_good, local_timezone, error=None, heartbeat_enabled=False):
status = {
"error": error,
"heartbeat_enabled": heartbeat_enabled,
"freshness_verified": heartbeat_enabled and values is not None,
"status": "ok" if values is not None else "error",
"label": "Działa poprawnie" if values is not None else "Błąd stacji",
"checked_at": checked_at,
"checked_at_local": datetime.fromisoformat(checked_at).astimezone(
ZoneInfo(local_timezone)).isoformat(),
"values": values,
"last_success_at": last_good[0] if last_good else None,
"last_success_values": dict(zip(FIELDS, last_good[1:])) if last_good else None,
}
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(status, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(path)
return status
def main():
os.umask(0o077)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--quiet", action="store_true", help="Log to file without terminal output")
args = parser.parse_args()
(BASE / "data").mkdir(exist_ok=True)
(BASE / "logs").mkdir(exist_ok=True)
handler = RotatingFileHandler(BASE / "logs/meteo.log", maxBytes=2_000_000,
backupCount=5, encoding="utf-8")
logging.basicConfig(level=logging.INFO, handlers=[handler],
format="%(asctime)s %(levelname)s %(message)s")
with (BASE / "data/meteo.lock").open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
logging.info("Poprzedni odczyt jeszcze trwa; pomijam uruchomienie")
return 0
try:
config = load_config()
ZoneInfo(config["timezone"])
checked_at, values, raw, attempts, error = collect(config)
last_good = save_result(BASE / "data/meteo.sqlite3", checked_at,
values, raw, attempts, error)
status = write_status(BASE / "data/status.json", checked_at,
values, last_good, config["timezone"], error,
config.get("heartbeat", {}).get("enabled", False))
if values is not None:
logging.info("Pomiar: temperatura=%s °C; wilgotność=%s %%; ciśnienie=%s hPa",
values["temperature"], values["humidity"], values["pressure"])
else:
logging.error("Błąd stacji: %s", error)
if not args.quiet:
print(json.dumps(status, ensure_ascii=False, indent=2))
return 0 if values is not None else 1
except Exception:
logging.exception("Błąd programu lub zapisu danych")
if not args.quiet:
print("Błąd programu. Szczegóły: " + str(BASE / "logs/meteo.log"), file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())