techno-

Untitled

Oct 1st, 2026
19
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 7.42 KB | None | 0 0
  1. """Motor de automatizaciones: implementación de referencia para el firmware.
  2.  
  3. No depende de nada fuera de la librería estándar, para poder copiarlo tal cual.
  4.  
  5. Las automatizaciones llegan en el formato de `automation_commands` (ver la sección
  6. Automatizaciones del README):
  7.  
  8.    {
  9.      "automations": [
  10.        {"id": 7, "priority": 10, "conditions": [...], "actions": [...]}
  11.      ]
  12.    }
  13.  
  14. El bucle es `run_automations`: para cada automatización, si `conditions_match`,
  15. se llama a `apply_action` con cada una de sus acciones.
  16.  
  17. `state` son las lecturas actuales, indexadas por el nombre de la métrica. Las
  18. métricas de la location llevan el valor directamente y las de dispositivo, un
  19. diccionario por `device_id`:
  20.  
  21.    state = {
  22.        "price_import": 0.31,                 # €/kWh
  23.        "grid_power": -2100,                  # W
  24.        "battery_soc": {"<uuid inversor>": 80},
  25.        "ewh_status": {"<uuid termo>": "on"},
  26.    }
  27.  
  28. Las unidades y los valores posibles de cada métrica están en
  29. `wattual_common/db/enums/metric.py`.
  30. """
  31.  
  32. import json
  33. from datetime import time
  34.  
  35. OPERATORS = {
  36.     ">": lambda a, b: a > b,
  37.     ">=": lambda a, b: a >= b,
  38.     "<": lambda a, b: a < b,
  39.     "<=": lambda a, b: a <= b,
  40.     "==": lambda a, b: a == b,
  41.     "!=": lambda a, b: a != b,
  42. }
  43.  
  44.  
  45. class DeviceController:
  46.     """Lo que `apply_action` necesita del firmware para mandar sobre los equipos.
  47.  
  48.    El firmware pasa un objeto con estos métodos, cada uno con su forma real de
  49.    controlar el equipo.
  50.    """
  51.  
  52.     def set_ev_power(self, device_id, power_w):
  53.         """Potencia de carga en W. 0 = parar la carga."""
  54.         raise NotImplementedError
  55.  
  56.     def set_ac(self, device_id, status, temperature, mode):
  57.         """status: bool, temperature: °C, mode: "heat" | "cool"."""
  58.         raise NotImplementedError
  59.  
  60.     def set_ewh(self, device_id, status):
  61.         """status: bool."""
  62.         raise NotImplementedError
  63.  
  64.     def set_pool_pump(self, device_id, status):
  65.         """status: bool."""
  66.         raise NotImplementedError
  67.  
  68.  
  69. # ---------------------------------------------------------------------------
  70. # Condiciones
  71. # ---------------------------------------------------------------------------
  72.  
  73.  
  74. def metric_value(state, metric, device_id=None):
  75.     """Lectura actual de una métrica, o None si no se conoce."""
  76.     value = state.get(metric)
  77.     if isinstance(value, dict):
  78.         return value.get(device_id)
  79.     return value
  80.  
  81.  
  82. def _metric_match(condition, state):
  83.     value = metric_value(state, condition["metric"], condition.get("device_id"))
  84.     if value is None:
  85.         # Sin lectura no se puede afirmar que la condición se cumple.
  86.         return False
  87.     return OPERATORS[condition["operator"]](value, condition["value"])
  88.  
  89.  
  90. def _time_match(condition, now):
  91.     weekdays = condition.get("weekdays")
  92.     if weekdays is not None and now.weekday() not in weekdays:
  93.         return False
  94.     after = time.fromisoformat(condition["after"]) if condition.get("after") else None
  95.     before = time.fromisoformat(condition["before"]) if condition.get("before") else None
  96.     current = now.time()
  97.     if after and before and after > before:
  98.         # La franja cruza la medianoche: de 23:00 a 07:00.
  99.         return current >= after or current < before
  100.     return (after is None or current >= after) and (before is None or current < before)
  101.  
  102.  
  103. def _condition_match(condition, state, now, timers):
  104.     kind = condition["type"]
  105.     if kind == "metric":
  106.         matches = _metric_match(condition, state)
  107.     elif kind == "time":
  108.         matches = _time_match(condition, now)
  109.     elif kind == "or":
  110.         # Lista y no generador: se evalúan todas para que sus temporizadores sigan contando.
  111.         matches = any([_condition_match(c, state, now, timers) for c in condition["conditions"]])
  112.     elif kind == "not":
  113.         matches = not conditions_match(condition["conditions"], state, now, timers)
  114.     else:
  115.         raise ValueError(f"Tipo de condición desconocido: {kind}")
  116.  
  117.     for_seconds = condition.get("for_seconds")
  118.     if not for_seconds:
  119.         return matches
  120.  
  121.     # La condición tiene que llevar cumpliéndose `for_seconds` seguidos. `timers`
  122.     # recuerda desde cuándo se cumple cada una entre una pasada y la siguiente.
  123.     key = json.dumps(condition, sort_keys=True)
  124.     if not matches:
  125.         timers.pop(key, None)
  126.         return False
  127.     since = timers.setdefault(key, now)
  128.     return (now - since).total_seconds() >= for_seconds
  129.  
  130.  
  131. def conditions_match(conditions, state, now, timers):
  132.     """True si se cumplen todas las condiciones (AND).
  133.  
  134.    - conditions: lista de condiciones de una automatización.
  135.    - state: lecturas actuales (ver el docstring del módulo).
  136.    - now: datetime en la hora local de la location.
  137.    - timers: diccionario que el firmware conserva entre pasadas, para las
  138.      condiciones con `for_seconds`.
  139.  
  140.    Se evalúan todas aunque alguna ya haya fallado, para que los temporizadores
  141.    de `for_seconds` de las demás sigan contando.
  142.    """
  143.     results = [_condition_match(condition, state, now, timers) for condition in conditions]
  144.     return all(results)
  145.  
  146.  
  147. # ---------------------------------------------------------------------------
  148. # Acciones
  149. # ---------------------------------------------------------------------------
  150.  
  151.  
  152. def apply_action(action, controller):
  153.     """Ejecuta una acción sobre su dispositivo a través del `controller` del firmware."""
  154.     if action["type"] != "device_command":
  155.         raise ValueError(f"Tipo de acción desconocido: {action['type']}")
  156.  
  157.     device_id = action["device_id"]
  158.     command = action["command_name"]
  159.     value = action["command_value"]
  160.  
  161.     if command == "ev_setpoint":
  162.         controller.set_ev_power(device_id, value)
  163.     elif command == "air_conditioning_setpoint":
  164.         controller.set_ac(device_id, value["status"], value["temperature"], value["mode"])
  165.     elif command == "ewh_setpoint":
  166.         controller.set_ewh(device_id, value["status"])
  167.     elif command == "pool_pump_setpoint":
  168.         controller.set_pool_pump(device_id, value["status"])
  169.     else:
  170.         raise ValueError(f"Comando desconocido: {command}")
  171.  
  172.  
  173. # ---------------------------------------------------------------------------
  174. # Bucle
  175. # ---------------------------------------------------------------------------
  176.  
  177.  
  178. def run_automations(payload, state, controller, now, timers):
  179.     """Una pasada: aplica las acciones de las automatizaciones que se cumplen.
  180.  
  181.    Se recorren de mayor a menor prioridad (a igualdad, id más bajo primero). Si
  182.    dos automatizaciones que se cumplen mandan sobre el mismo dispositivo, solo
  183.    actúa la primera: la otra no llega a emitir su comando sobre ese equipo.
  184.  
  185.    Devuelve la lista de acciones aplicadas en esta pasada.
  186.    """
  187.     automations = sorted(
  188.         payload["automations"],
  189.         key=lambda automation: (-automation.get("priority", 0), automation.get("id", 0)),
  190.     )
  191.  
  192.     applied = []
  193.     commanded_devices = set()
  194.     for automation in automations:
  195.         if not conditions_match(automation["conditions"], state, now, timers):
  196.             continue
  197.         for action in automation["actions"]:
  198.             device_id = action.get("device_id")
  199.             if device_id in commanded_devices:
  200.                 continue
  201.             apply_action(action, controller)
  202.             commanded_devices.add(device_id)
  203.             applied.append(action)
  204.     return applied
  205.  
Advertisement
Add Comment
Please, Sign In to add comment