Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- """Motor de automatizaciones: implementación de referencia para el firmware.
- No depende de nada fuera de la librería estándar, para poder copiarlo tal cual.
- Las automatizaciones llegan en el formato de `automation_commands` (ver la sección
- Automatizaciones del README):
- {
- "automations": [
- {"id": 7, "priority": 10, "conditions": [...], "actions": [...]}
- ]
- }
- El bucle es `run_automations`: para cada automatización, si `conditions_match`,
- se llama a `apply_action` con cada una de sus acciones.
- `state` son las lecturas actuales, indexadas por el nombre de la métrica. Las
- métricas de la location llevan el valor directamente y las de dispositivo, un
- diccionario por `device_id`:
- state = {
- "price_import": 0.31, # €/kWh
- "grid_power": -2100, # W
- "battery_soc": {"<uuid inversor>": 80},
- "ewh_status": {"<uuid termo>": "on"},
- }
- Las unidades y los valores posibles de cada métrica están en
- `wattual_common/db/enums/metric.py`.
- """
- import json
- from datetime import time
- OPERATORS = {
- ">": lambda a, b: a > b,
- ">=": lambda a, b: a >= b,
- "<": lambda a, b: a < b,
- "<=": lambda a, b: a <= b,
- "==": lambda a, b: a == b,
- "!=": lambda a, b: a != b,
- }
- class DeviceController:
- """Lo que `apply_action` necesita del firmware para mandar sobre los equipos.
- El firmware pasa un objeto con estos métodos, cada uno con su forma real de
- controlar el equipo.
- """
- def set_ev_power(self, device_id, power_w):
- """Potencia de carga en W. 0 = parar la carga."""
- raise NotImplementedError
- def set_ac(self, device_id, status, temperature, mode):
- """status: bool, temperature: °C, mode: "heat" | "cool"."""
- raise NotImplementedError
- def set_ewh(self, device_id, status):
- """status: bool."""
- raise NotImplementedError
- def set_pool_pump(self, device_id, status):
- """status: bool."""
- raise NotImplementedError
- # ---------------------------------------------------------------------------
- # Condiciones
- # ---------------------------------------------------------------------------
- def metric_value(state, metric, device_id=None):
- """Lectura actual de una métrica, o None si no se conoce."""
- value = state.get(metric)
- if isinstance(value, dict):
- return value.get(device_id)
- return value
- def _metric_match(condition, state):
- value = metric_value(state, condition["metric"], condition.get("device_id"))
- if value is None:
- # Sin lectura no se puede afirmar que la condición se cumple.
- return False
- return OPERATORS[condition["operator"]](value, condition["value"])
- def _time_match(condition, now):
- weekdays = condition.get("weekdays")
- if weekdays is not None and now.weekday() not in weekdays:
- return False
- after = time.fromisoformat(condition["after"]) if condition.get("after") else None
- before = time.fromisoformat(condition["before"]) if condition.get("before") else None
- current = now.time()
- if after and before and after > before:
- # La franja cruza la medianoche: de 23:00 a 07:00.
- return current >= after or current < before
- return (after is None or current >= after) and (before is None or current < before)
- def _condition_match(condition, state, now, timers):
- kind = condition["type"]
- if kind == "metric":
- matches = _metric_match(condition, state)
- elif kind == "time":
- matches = _time_match(condition, now)
- elif kind == "or":
- # Lista y no generador: se evalúan todas para que sus temporizadores sigan contando.
- matches = any([_condition_match(c, state, now, timers) for c in condition["conditions"]])
- elif kind == "not":
- matches = not conditions_match(condition["conditions"], state, now, timers)
- else:
- raise ValueError(f"Tipo de condición desconocido: {kind}")
- for_seconds = condition.get("for_seconds")
- if not for_seconds:
- return matches
- # La condición tiene que llevar cumpliéndose `for_seconds` seguidos. `timers`
- # recuerda desde cuándo se cumple cada una entre una pasada y la siguiente.
- key = json.dumps(condition, sort_keys=True)
- if not matches:
- timers.pop(key, None)
- return False
- since = timers.setdefault(key, now)
- return (now - since).total_seconds() >= for_seconds
- def conditions_match(conditions, state, now, timers):
- """True si se cumplen todas las condiciones (AND).
- - conditions: lista de condiciones de una automatización.
- - state: lecturas actuales (ver el docstring del módulo).
- - now: datetime en la hora local de la location.
- - timers: diccionario que el firmware conserva entre pasadas, para las
- condiciones con `for_seconds`.
- Se evalúan todas aunque alguna ya haya fallado, para que los temporizadores
- de `for_seconds` de las demás sigan contando.
- """
- results = [_condition_match(condition, state, now, timers) for condition in conditions]
- return all(results)
- # ---------------------------------------------------------------------------
- # Acciones
- # ---------------------------------------------------------------------------
- def apply_action(action, controller):
- """Ejecuta una acción sobre su dispositivo a través del `controller` del firmware."""
- if action["type"] != "device_command":
- raise ValueError(f"Tipo de acción desconocido: {action['type']}")
- device_id = action["device_id"]
- command = action["command_name"]
- value = action["command_value"]
- if command == "ev_setpoint":
- controller.set_ev_power(device_id, value)
- elif command == "air_conditioning_setpoint":
- controller.set_ac(device_id, value["status"], value["temperature"], value["mode"])
- elif command == "ewh_setpoint":
- controller.set_ewh(device_id, value["status"])
- elif command == "pool_pump_setpoint":
- controller.set_pool_pump(device_id, value["status"])
- else:
- raise ValueError(f"Comando desconocido: {command}")
- # ---------------------------------------------------------------------------
- # Bucle
- # ---------------------------------------------------------------------------
- def run_automations(payload, state, controller, now, timers):
- """Una pasada: aplica las acciones de las automatizaciones que se cumplen.
- Se recorren de mayor a menor prioridad (a igualdad, id más bajo primero). Si
- dos automatizaciones que se cumplen mandan sobre el mismo dispositivo, solo
- actúa la primera: la otra no llega a emitir su comando sobre ese equipo.
- Devuelve la lista de acciones aplicadas en esta pasada.
- """
- automations = sorted(
- payload["automations"],
- key=lambda automation: (-automation.get("priority", 0), automation.get("id", 0)),
- )
- applied = []
- commanded_devices = set()
- for automation in automations:
- if not conditions_match(automation["conditions"], state, now, timers):
- continue
- for action in automation["actions"]:
- device_id = action.get("device_id")
- if device_id in commanded_devices:
- continue
- apply_action(action, controller)
- commanded_devices.add(device_id)
- applied.append(action)
- return applied
Advertisement
Add Comment
Please, Sign In to add comment