diff --git a/README.md b/README.md index 1e5df3d..80bf516 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,335 @@ -# apagado_seguro +# Apagado Seguro de Instalacion +Este proyecto pretende brindar las herramientas para poder ejecutar un apagado +ordenado y seguro de una instalacion basandose en la monitorizacion de la red +de alimentacion y el estado de una bateria de respaldo, a traves de la informacion +que transmite el Cerbo GX de Victron Energy y con HomeAssistant como coordinador. -Este proyecto pretende brindar las herramientas para poder ejecutar un apagado ordenado y seguro de una instalacion basandose en la monitorizacion de la red de alimentacion y el estado de una bateria de respaldo, a traves de la informacion que transmite el Cerbo GX de Victron Energy y con HomeAssistant como coordinador. \ No newline at end of file +## Resumen del proceso de apagado y encendido +Desde homeassistant se monitoriza los datos que brinda el Cerbo y el +Multiplus (inversor) de Victron. Principalmente fijandose en 2 datos concretos, +la carga de la bateria y el estado de la red de alimentacion. + +La automatizacion de apagado tiene 2 fases. +En la primera, la raspi se encarga de apagar los datalogger, equipos de servicio +secundarios como raspis de dinamica, NVR o similar y parar la base de datos. +Esto permite que la sincronizacion con nuestro servidor no se rompa de forma +inesperada. + +Esta primera fase se dispara cuando se cumplan las siguientes +condiciones simultaneamente: +- No hay alimentacion +- La carga de la bateria esta por debajo del primer umbral + +Tras esta primera fase, la raspi de estatica, que es la que gestiona todo, se +mantiene encendida mientras la bateria no baje de un segundo umbral. +Esto da la opcion de reencender el sistema si vuelve la tension. De esta forma +cuando la bateria vuelva a un nivel estable se ejecutaria una automatizacion +de encendido. Empezando por la base de datos, seguido de volver a dar tension +al resto de equipos encendiendo los Shellys. + +En caso de que la carga siguiese bajando, al llegar a un segundo umbral, +se dispararia la segunda fase. +El objetivo principal de esta es apagar la raspi de estatica de forma segura. +Para ello el homeassistant dispara un script de apagado que revisa que se haya +ejecutado la fase 1 (si no lo ha hecho lo hace ahora), sincroniza los discos y +ordena el poweroff del sistema. +A parte de eso, el Cerbo GX cortocircuita su rele 1, que controla el Shelly +que corta la alimentacion de la Raspi, con un delay (~120s) para dar tiempo a +la automatizacion de homeassistant a completarse. +De forma similar a como lo haria la raspi, el Cerbo desactivara el rele una vez +la bateria vuelva a tener carga suficiente; cerrando de nuevo el Shelly, dando +alimentacion a la raspi y disparando la automatizacion de encendido. + +![Esquema de fases](/docs/images/esquema_eventos.png) + +## Descripcion detallada del proyecto + +### Arquitectura +![Esquema de Arquitectura](/docs/images/esquema_arquitectura_instalacion.png) + + +### Fase 1 - Apagado de dataloggers, equipos de servicio y BDD +El objetivo principal de esta primera fase es apagar todos los equipos que +escriban en la base de datos y cerrar la misma de forma segura para evitar +fallos de sincronizacion. Una vez superado el umbral se ejecuta la automatización +`apagado ordenado` de homeassistant. El proceso es el siguiente: + +1. Homeassistant apaga los relés Shelly que controlan la alimentacion de los arduinos. +2. Homeassistant lanza el comando `touch /shutdown-signal/apagar` que crea el +archivo `apagar` en la ruta `/shutdown-signal/` del contenedor que está +vinculada con la ruta `/opt/shutdown-signal/` del host. Este archivo funciona +como flag o señal para iniciar el proceso de apagado en el host. +3. En el host está el script [shutdown-watcher.sh](/scripts/shutdown-watcher.sh) +corriendo como servicio. Este script monitoriza la ruta donde homeassistant genera +el archivo de señal y en funcion del nombre del archivo generado ejecuta un script +u otro. +4. La creacion del archivo `apagar` desencadena la ejecucion del script +[orchestrate-shutdown.sh](/scripts/orchestrate-shutdown.sh) +5. Este script empieza ejecutando un segundo script +[service-nodes-shutdown.sh](/scripts/service-nodes-shutdown.sh) encargado de +conectarse a una lista de equipos de servicio por ssh y ejecutar un poweroff +en estos. +6. Despues se ejecuta el script [mariadb-shutdown.sh](/scripts/mariadb-shutdown.sh) +que para el contenedor de `mariadb`, es decir la base de datos. Esto asegura +que los procesos de la misma se finalizan y la sincro se pausa sin errores. +7. Despues de un retardo (~60s) homeassistant corta la alimentacion de los +equipos de servicios apagando los Shellys correspondientes. + +> [!NOTE] Todos los script tienen una funcion de log que permite ver si se han +> ejecutado correctamente. +> El archivo de log esta en la ruta `/var/log/safe-shutdown.log` + +Con eso acabaria la primera fase de apagado. +Mientras no se sobrepase el segundo umbral la Raspi y el homeassistant se +mantendra encendido monitorizando la alimentacion para poder reiniciar todos +los equipos si se volviese a un estado estable por encima del primer umbral. + +### Fase 2 - Apagado total +Esta fase es practicamente un clon de la primera (se reejecuta todo por si no +se hubiese ejecutado previamente) con el añadido de parar el homeassistant, +sincronizar los discos de la raspi y apagar la misma. Para poder cortar la +alimentacion de la Raspi una vez esta ya está apagada haremos uso de la funcion +de control de generador de respaldo del Cerbo GX de Victron. Con esta podremos +controlar un relé que mande una señal de apagado al Shelly que corta la +alimentacion de la Raspi con un retardo (~120s) para asegurar que el script de +apagado ya haya finalizado. De esta forma el proceso seria el siguiente: + +1. Homeassistant apaga los relés de los arduinos. +2. Homeassistant crea el archivo `apagado_total`. +3. Se ejecuta el script +[orchestrate-total-shutdown.sh](/scripts/orchestrate-total-shutdown.sh) +4. Este ejecuta [service-nodes-shutdown.sh](/scripts/service-nodes-shutdown.sh) +y [mariadb-shutdown.sh](/scripts/mariadb-shutdown.sh) +5. Para el homeassistant, sincroniza los discos y ordena el poweroff. +6. El Cerbo GX cuando detecta la bajada de bateria espera dando tiempo al nodo +principal a apagarse (~120s) y cortocircuita el relé 1 cuya salida normalmente +abierta (NA) esta conectada a la entrada del Shelly que controla la +alimentacion. La salida del Shelly estará invertida respecto a su entrada de +forma que cuando reciba una señal positiva se apague. + +Hecho esto la reinicializacion dependerá integramente del Cerbo GX que desactivara +la salida del rele 1 una vez la bateria vuelva a un nivel estable. Esto encendera la raspi, la cual a su vez iniciara el proceso de encendido ordenado ya que la bateria deberia estar por encima del primer umbral. + +### Encendido Ordenado +De forma similar al resto esta es una automatizacion que se ejecuta una vez la +bateria supera el umbral establecido para el encendido, en este caso en sentido +positivo. +De nuevo se crea un archivo de señal con el nombre de `encender` que +desencadena la ejecucion del script +[orchestrate-startup.sh](/scripts/orchestrate-startup.sh). +El proceso quedaria asi: + +1. Homeassistant crea el archivo `encender`. +2. Se ejecuta [orchestrate-startup.sh](/scripts/orchestrate-startup.sh) +que comprueba el estado de la base de datos iniciandola si no lo está. +3. Pasados 30s homeassistant enciende los Shellys de la instalacion devolviendo +la alimentacion tanto a los equipos de servicio como a los arduinos. +El encendido de estos deberia ser automático. + +Este encendido ordenado permite que la base de datos ya este activa antes de +que el resto de equipos empiece a enviar datos evitando la perdida de la +primera lectura. + +## Despliegue + +### Requisitos +Software para el control y monitorizacion de procesos: +- Systemd +- Docker +- Homeassistant +- inotify-tools +- Scripts del proyecto + +Para la conexion entre nodos: +- Clave SSH sin passphrase generada en el nodo principal (raspi estatica) +con su respectiva clave publica autorizada en cada nodo de servicio. + +Hardware: +- Sistema de baterias de Victron con inversor y Cerbo +- Relés con integracion en homeassistant (Shellys) + +### Instalación +> [!NOTE] +> Comprobacion rapida de los archivos con sus permisos correspondientes: +> ```bash +> ls -l /usr/local/sbin/mariadb-shutdown.sh \ +> /usr/local/sbin/service-nodes-shutdown.sh \ +> /usr/local/sbin/orchestrate-shutdown.sh \ +> /usr/local/sbin/orchestrate-startup.sh \ +> /usr/local/sbin/orchestrate-total-shutdown.sh \ +> /usr/local/sbin/shutdown-watcher.sh +> ls -l /etc/systemd/system/shutdown-watcher.service +> which inotifywait || sudo apt install inotify-tools +> ``` + +#### Scripts +Todos los scripts deben ir instalados en la carpeta `/usr/local/sbin/` con permisos +de ejecucion. + +En caso de que los scripts no esten ya instalados podremos instalarlos +copiandolos a la maquina y lanzando el siguiente comando: +```bash +sudo install -m 755 /usr/local/sbin/ +``` + +#### Clave SSH +El script de `service-nodes-shutdown.sh` se conecta por SSH sin contraseña a +los distintos equipos de servicio. Para ello hace falta generar una clave +SSH en el nodo principal (raspi de estatica) e instalar la clave publica correspondiente en cada nodo de +servicio. + +Generar clave SSH en el nodo principal: +```bash +sudo ssh-keygen -t ed25519 -f /root/.ssh/id_shutdown -N "" -C "shutdown-orchestrator" +sudo ls -l /root/.ssh/id_shutdown # debe ser -rw------- (600), propietario root +``` + +Instalar clave publica en un nodo (desde el principal): +```bash +sudo ssh-copy-id -i /root/.ssh/id_shutdown.pub USER_DEL_NODO@IP_DEL_NODO +``` + +Comprobacion (debe devolver el hostname del nodo de servicio): +```bash +sudo ssh -i /root/.ssh/id_shutdown USER_DEL_NODO@IP_DEL_NODO 'hostname' +``` + +#### Carpeta de señal y volumen de Docker +Los archivos de señal se generan en la carpeta `/opt/shutdown-signal`, sino existe +hay que crearla: +```bash +sudo mkdir -p /opt/shutdown-signal +``` + +Para que homeassistant pueda escribir en ella hay que montarla como volumen +del contenedor en el `compose.yaml` +```yaml + homeassistant: + ... + volumes: + - ... + - /opt/shutdown-signal:/shutdown-signal +``` +Despues de añadirlo recrea el volumen: +```bash +docker compose up -d homeassistant # debe decir "Recreating" +``` + +#### Shell Commands de Homeassistant +Los comandos de shell que crean los archivos que sirven como señal para el +watcher se definen en la configuracion de homeassistant: +```yaml +# configuration.yaml +shell_command: + trigger_shutdown: touch /shutdown-signal/apagar + trigger_total_shutdown: touch /shutdown-signal/apagado_total + trigger_startup: touch /shutdown-signal/encender +``` + +#### Activar el Watcher +El servicio del watcher `shutdown-watcher.service` debe ir +con permisos de lectura y escritura en la carpeta de systemd +`/etc/systemd/system/`. Si no esta instalado instalar: +```bash +sudo install -m 644 shutdown-watcher.service /etc/systemd/system/shutdown-watcher.service +``` + +Para activarlo en systemd: +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now shutdown-watcher.service +sudo systemctl status shutdown-watcher.service # debe salir "active (running)" +``` + +#### Estructura final de archivos +De esta forma los archivos correspondientes a este proyecto quedarian de la +siguiente forma: +```bash +# Servicio del watcher +/etc/systemd/system/ + └── shutdown-watcher.service + +# Carpeta que monitoriza el watcher +/opt/shutdown-signal/ + +# Homeassistant +/.../servidor-campo/ + ├── compose.yaml + └── homeassistant/ + └── config_ha/ + └── configuration.yaml + +# Clave SSH +/root/.ssh/ + ├── id_shutdown + └── id_shutdown.pub + +# Scripts +/usr/local/sbin/ + ├── mariadb-shutdown.sh + ├── orchestrate-shutdown.sh + ├── orchestrate-startup.sh + ├── orchestrate-total-shutdown.sh + ├── service-nodes-shutdown.sh + └── shutdown-watcher.sh +``` + +### Configuracion +Una vez este todo instalado hay distintas configuraciones a realizar en +funcion de la instalacion. + +#### Automatizaciones Homeassistant +Como minimo habra que configurar los reles a apagar o encender. Tambien +pueden variar los umbrales, triggers y delays. +Todo esto se puede configurar desde la seccion de +`/configuracion/automatizaciones_y_escenas/` de la web de Homeassistant. + +Ejemplos +- [apagado_ordenado_fase1](/docs/examples/apagado_ordenado_fase1.yaml.example) +- [apagado_total_fase2](/docs/examples/apagado_total_fase2.yaml.example) +- [encendido_ordenado](/docs/examples/encendido_ordenado.yaml.example) + +#### IP-s nodos de servicio - service-nodes-shutdown.sh +En la cabecera de [service-nodes-shutdown.sh](/scripts/service-nodes-shutdown.sh) +en el array `NODES` se definen el usuario, IP y comando para el apagado de los +distintos nodos de servicio con el siguiente formato: `user@IP|command` +```bash +# service-nodes-shutdown.sh +NODES=( + "pi@192.168.1.219|sudo /usr/sbin/poweroff" # Raspi Dinamica + "root@192.168.1.50|/sbin/poweroff" # NVR +) +``` + +#### Cerbo y Shelly del nodo principal +Para el apagado completo debemos encender y configurar la funcion de +`Arranque/parada del generador` del rele 1 del Cerbo GX. + +Desde su interfaz remota: +- Habilitar: +`/Ajustes/Integraciones/Reles/Funcion (Rele 1)/Arranque/parada del generador` +![Configuracion Cerbo GX](/docs/images/ConfigCerbo.png) +- Configurar: +`/Ajustes/Dispositivos/Grupo Electrogeno/` + - Funcion de arranque automatico -> ON + - Ajustes/Condiciones/Estado de carga de la bateria -> Definir umbrales y retardos +![Umbrales Cerbo GX](/docs/images/UmbralesCerbo.png) + +> [!WARNING] +> Esta funcion controlara la alimentacion del nodo principal cortando cualquier +> proceso que se pueda estar ejecutando en el mismo. Conviene definir un +> retardo suficientemente amplio para que el proceso de apagado total finalice +> correctamente. + +Una vez cofigurado esto debemos conectar la salida NA del rele 1 del Cerbo a una +entrada del Shelly y configurar la accion de la misma en este: +- OUT Input/Output settings: + - Input mode -> Switch + - Output type -> Toggle +- IN Invert -> ON + +> [!NOTE] +> La funcion de Arranque/Parada del generador de Cerbo esta pensada para encender un +> generador cuando la bateria baje de un umbral pero nosotros lo usamos justamente +> para lo contrario, apagar el nodo principal, es por esto que la entrada del +> Shelly se invierte. diff --git a/config/compose.yaml b/config/compose.yaml new file mode 100644 index 0000000..313a32b --- /dev/null +++ b/config/compose.yaml @@ -0,0 +1,120 @@ +services: + mariadb: + container_name: mariadb + image: mariadb:11 + restart: always + environment: + - MARIADB_ROOT_PASSWORD=Ant3na2021 + - TZ=Europe/Madrid + ports: + - "3307:3306" + volumes: + - mariadb_data:/var/lib/mysql + - ./mariadb/init:/docker-entrypoint-initdb.d:ro + - ./mariadb/conf.d/replication.cnf:/etc/mysql/mariadb.conf.d/99-replication.cnf:ro + networks: + - teknes-net + + phpmyadmin: + container_name: phpmyadmin + image: phpmyadmin:latest + restart: always + # ports: + # - "80:80" + environment: + - PMA_HOST=mariadb + - PMA_PORT=3306 + - PMA_ABSOLUTE_URI=http://localhost/phpmyadmin/ + - TZ=Europe/Madrid + depends_on: + - mariadb + networks: + - teknes-net + + nginx: + container_name: nginx + image: nginx:alpine + restart: always + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "80:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - phpmyadmin + networks: + - teknes-net + + app-servidor-campo: + container_name: app-servidor-campo + build: ./app-servidor-campo + init: true + restart: on-failure + ports: + - "5566:5566" + volumes: + - ./app-servidor-campo/app:/home/teknes/ServidorCampo + environment: + - TZ=Europe/Madrid + depends_on: + - mariadb + networks: + - teknes-net + +# mqtt: +# container_name: mqtt +# image: eclipse-mosquitto +# restart: unless-stopped +# network_mode: host +# volumes: +# - ./homeassistant/config_mqtt:/mosquitto/config +# - ./homeassistant/data_mqtt:/mosquitto/data +# - ./homeassistant/log_mqtt:/mosquitto/log +# environment: +# - TZ=Europe/Madrid + + homeassistant: + container_name: homeassistant + image: ghcr.io/home-assistant/home-assistant:stable + restart: always + privileged: true + network_mode: host + volumes: + - ./homeassistant/config_ha:/config + - /etc/localtime:/etc/localtime:ro + - /run/dbus:/run/dbus:ro + - /opt/shutdown-signal:/shutdown-signal # Fichero de señal de apagado + environment: + - TZ=Europe/Madrid + depends_on: +# - mqtt + - file-editor + + file-editor: + container_name: file-editor + image: causticlab/hass-configurator-docker:latest + restart: unless-stopped + network_mode: host + volumes: + - ./homeassistant/config_file-editor:/config + - ./homeassistant/config_ha:/hass-config + environment: + - TZ=Europe/Madrid + +# node-red: +# container_name: node-red +# image: nodered/node-red:latest +# restart: unless-stopped +# network_mode: host +# volumes: +# - ./homeassistant/data_nodered:/data +# environment: +# - TZ=Europe/Madrid + +volumes: + mariadb_data: + +networks: + teknes-net: + driver: bridge diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..a43bc6a --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,148 @@ +# Loads default set of integrations. Do not remove. +default_config: + +http: + use_x_forwarded_for: true + trusted_proxies: + - 172.18.0.0/16 + +panel_iframe: + configurator: + title: Configurator + icon: mdi:wrench + #url: http://192.168.1.220:3218 + url: http://ncie-benamira:54753 + +shell_command: + trigger_shutdown: touch /shutdown-signal/apagar + trigger_total_shutdown: touch /shutdown-signal/apagado_total + trigger_startup: touch /shutdown-signal/encender + +# zigbee2mqtt: +# title: Zigbee2mqtt +# icon: mdi:zigbee +# #url: http://192.168.1.220:8080 +# url: http://teknes-instalacion.ddns.net:54754 + +#switch: +# - platform: group +# name: Instalacion +# entities: +# - switch.caja_instalacion +# +# - platform: group +# name: Cajas +# entities: +# - switch.caja_eje1_pila1_204_210 +# - switch.caja_eje1_pila2_205_211 +# - switch.caja_eje1_pila3_201 +# - switch.caja_eje2_pila1_203_212 +# - switch.caja_eje2_pila2_206_213 +# - switch.caja_eje2_pila3_202 + +# - platform: mqtt +# name: "Caja instalacion" +# state_topic: "zigbee2mqtt/caja-instalacion/status" +# command_topic: "zigbee2mqtt/caja-instalacion" +# payload_on: "ON" +# payload_off: "OFF" +# qos: 0 +# retain: true +# restore_state: true +# +# - platform: mqtt +# name: "Caja eje1 pila1 204 210" +# state_topic: "zigbee2mqtt/caja-eje1-pila1-204-210/status" +# command_topic: "zigbee2mqtt/caja-eje1-pila1-204-210" +# payload_on: "ON" +# payload_off: "OFF" +# qos: 0 +# retain: true +# restore_state: true +# +# - platform: mqtt +# name: "Caja eje1 pila2 205 211" +# state_topic: "zigbee2mqtt/caja-eje1-pila2-205-211/status" +# command_topic: "zigbee2mqtt/caja-eje1-pila2-205-211" +# payload_on: "ON" +# payload_off: "OFF" +# qos: 0 +# retain: true +# restore_state: true +# +# - platform: mqtt +# name: "Caja eje1 pila3 201" +# state_topic: "zigbee2mqtt/caja-eje1-pila3-201/status" +# command_topic: "zigbee2mqtt/caja-eje1-pila3-201" +# payload_on: "ON" +# payload_off: "OFF" +# qos: 0 +# retain: true +# restore_state: true +# +# - platform: mqtt +# name: "Caja eje2 pila1 203 212" +# state_topic: "zigbee2mqtt/caja-eje2-pila1-203-212/status" +# command_topic: "zigbee2mqtt/caja-eje2-pila1-203-212" +# payload_on: "ON" +# payload_off: "OFF" +# qos: 0 +# retain: true +# restore_state: true +# +# - platform: mqtt +# name: "Caja eje2 pila2 206 213" +# state_topic: "zigbee2mqtt/caja-eje2-pila2-206-213/status" +# command_topic: "zigbee2mqtt/caja-eje2-pila2-206-213" +# payload_on: "ON" +# payload_off: "OFF" +# qos: 0 +# retain: true +# restore_state: true +# +# - platform: mqtt +# name: "Caja eje2 pila3 202" +# state_topic: "zigbee2mqtt/caja-eje2-pila3-202/status" +# command_topic: "zigbee2mqtt/caja-eje2-pila3-202" +# payload_on: "ON" +# payload_off: "OFF" +# qos: 0 +# retain: true +# restore_state: true + +# +#telegram_bot: +# - platform: polling +# api_key: 6092066180:AAHJOCNkbjnNg5NUcEOL2kjLDKd7AFecu3s +# allowed_chat_ids: +# #335322529:sergio, 5735162:Iñigo +# - 335322529 +# - 5735162 + +#mqtt: +# discovery: true +# #broker: mqtt://192.168.1.220 +# broker: mqtt://teknes-instalacion.ddns.net:54755 +# birth_message: +# topic: 'homeassistant/status' +# payload: 'online' +# will_message: +# topic: 'homeassistant/status' +# payload: 'offline' + +# Load frontend themes from the themes folder +#frontend: + #themes: !include_dir_merge_named themes + +# Text to speech +tts: + - platform: google_translate + + +api: + + + +automation: !include automations.yaml +script: !include scripts.yaml +scene: !include scenes.yaml diff --git a/config/shutdown-watcher.service b/config/shutdown-watcher.service new file mode 100644 index 0000000..72a581d --- /dev/null +++ b/config/shutdown-watcher.service @@ -0,0 +1,13 @@ +[Unit] +Description=Watcher de senal de apagado ordenado +After=network.target docker.service +Wants=docker.service + +[Service] +Type=simple +ExecStart=/usr/local/sbin/shutdown-watcher.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/docs/examples/apagado_ordenado_fase1.yaml.example b/docs/examples/apagado_ordenado_fase1.yaml.example new file mode 100644 index 0000000..4a55b9b --- /dev/null +++ b/docs/examples/apagado_ordenado_fase1.yaml.example @@ -0,0 +1,37 @@ +alias: Apagado Ordenado FASE 1 +description: "" +# Trigger +triggers: + - trigger: numeric_state + entity_id: # Entidad que controla el trigger + - sensor.pylontech_battery_id_512_carga + below: 99 # Umbral +conditions: [] +actions: + # Apagado de datalogger + - action: switch.turn_off + metadata: {} + target: + entity_id: # Relés dataloggers + - switch.instalacion + - ... + data: {} + # Comando que genera el archivo de señal "apagar" + - action: shell_command.trigger_shutdown + metadata: {} + data: {} + # Retardo de 1 min + - delay: + hours: 0 + minutes: 1 + seconds: 0 + milliseconds: 0 + # Apagado de nodos de servicio + - action: switch.turn_off + metadata: {} + target: + entity_id: # Reles nodos de servicio + - switch.dinamica_principal + - switch.nvr_nvr + data: {} +mode: single diff --git a/docs/examples/apagado_total_fase2.yaml.example b/docs/examples/apagado_total_fase2.yaml.example new file mode 100644 index 0000000..cb5e148 --- /dev/null +++ b/docs/examples/apagado_total_fase2.yaml.example @@ -0,0 +1,37 @@ +alias: Apagado TOTAL FASE 2 +description: "" +# Trigger +triggers: + - trigger: numeric_state + entity_id: # Entidad que controla el tigger + - sensor.pylontech_battery_id_512_carga + below: 95 # Umbral +conditions: [] +actions: + # Apagado de datalogger + - action: switch.turn_off + metadata: {} + target: + entity_id: # Reles dataloggers + - switch.instalacion + - ... + data: {} + # Comando que genera el archivo de señal "apagado_total" + - action: shell_command.trigger_total_shutdown + metadata: {} + data: {} + # Retardo de 1 min + - delay: + hours: 0 + minutes: 1 + seconds: 0 + milliseconds: 0 + # Apagado de nodos de servicio + - action: switch.turn_off + metadata: {} + target: + entity_id: # Reles nodos de servicio + - switch.dinamica_principal + - switch.nvr_nvr + data: {} +mode: single diff --git a/docs/examples/encendido_ordenado.yaml.example b/docs/examples/encendido_ordenado.yaml.example new file mode 100644 index 0000000..21500bd --- /dev/null +++ b/docs/examples/encendido_ordenado.yaml.example @@ -0,0 +1,33 @@ +alias: Encendido Ordenado +description: "" +# Trigger +triggers: + - trigger: numeric_state + entity_id: + - sensor.pylontech_battery_id_512_carga + above: 98 +conditions: [] +actions: + # Comando que genera el archivo de señal "encender" + - action: shell_command.trigger_startup + metadata: {} + data: {} + # Encendido nodos de servicio + - action: switch.turn_on + metadata: {} + target: + entity_id: + - switch.dinamica_principal + - switch.nvr_nvr + data: {} + # Retardo 30s + - delay: "00:00:30" + # Encendido de dataloggers + - action: switch.turn_on + metadata: {} + target: + entity_id: + - switch.instalacion + - ... + data: {} +mode: single diff --git a/docs/images/ConfigCerbo.png b/docs/images/ConfigCerbo.png new file mode 100644 index 0000000..0a05e50 Binary files /dev/null and b/docs/images/ConfigCerbo.png differ diff --git a/docs/images/UmbralesCerbo.png b/docs/images/UmbralesCerbo.png new file mode 100644 index 0000000..76c037a Binary files /dev/null and b/docs/images/UmbralesCerbo.png differ diff --git a/docs/images/esquema_arquitectura_instalacion.drawio b/docs/images/esquema_arquitectura_instalacion.drawio new file mode 100644 index 0000000..4a817f7 --- /dev/null +++ b/docs/images/esquema_arquitectura_instalacion.drawio @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/esquema_arquitectura_instalacion.png b/docs/images/esquema_arquitectura_instalacion.png new file mode 100644 index 0000000..baeb331 Binary files /dev/null and b/docs/images/esquema_arquitectura_instalacion.png differ diff --git a/docs/images/esquema_eventos.drawio b/docs/images/esquema_eventos.drawio new file mode 100644 index 0000000..579ab28 --- /dev/null +++ b/docs/images/esquema_eventos.drawio @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/esquema_eventos.png b/docs/images/esquema_eventos.png new file mode 100644 index 0000000..62eea42 Binary files /dev/null and b/docs/images/esquema_eventos.png differ diff --git a/scripts/mariadb-shutdown.sh b/scripts/mariadb-shutdown.sh new file mode 100644 index 0000000..e206c36 --- /dev/null +++ b/scripts/mariadb-shutdown.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# mariadb_clean_stop.sh - Cierre limipio del contenedor de MariaDB en una maquina + +# CONFIGURACION --------------------------------------------------------------- +set -uo pipefail +# -u -> si no existe una variable devuelve error en vez de tratarla como cadnea vacia +# -o pipefail -> devuelve los errores de cualquier pipe no solo del ultimo + +# VARIABLES +CONTAINER="mariadb" +GRACE=60 +LOG="/var/log/safe-shutdown.log" +# ----------------------------------------------------------------------------- +# funcion para escribir en el log +log() { echo "$(date -Is) [mariadb-safe-stop] $*" | tee -a "$LOG"; } + +# PROGRAMA -------------------------------------------------------------------- +log "inicio" + +# Comprobacion de que el contenedor esta corriendo +# Lista los nombres de los contenedores activos y busca exactamente el nombre de $CONTAINER +# Si no lo encuentra escribe un aviso en el log y sale con 0 +if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then + log "AVISO: el contenedor '$CONTAINER' no esta corriendo. Nada que hacer." + exit 0 +fi + +# Parada del contenedor +log "parando contenedor '$CONTAINER' (tiempo de gracia: ${GRACE}s)..." +if docker stop -t "$GRACE" "$CONTAINER" >/dev/null; then + log "OK: Contenedor parado con exito" +else + log "ERROR: docker stop falló" + exit 1 +fi + +# Verificacion del estado del contenedor +if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then + log "ERROR: '$CONTAINER' sigue activo despues del stop" + exit 1 +fi + +log "contenedor '$CONTAINER' detenido correctamente" +log "fin" \ No newline at end of file diff --git a/scripts/orchestrate-shutdown.sh b/scripts/orchestrate-shutdown.sh new file mode 100644 index 0000000..a22809c --- /dev/null +++ b/scripts/orchestrate-shutdown.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# orchestrate-shutdown.sh — secuencia completa de apagado ordenado. +# Lo dispara el watcher al detectar un fichero de señal. +# Orden: +# 1. HA apaga los Shellys de la instalacion +# 2. HA crea el fichero de señal +# 3. Se inicia este script +# 3.1. Apagado de raspis y/o equipos de servicio +# 3.2. Parado de MariaDB + +set -uo pipefail + +# CONFIGURACION---------------------------------------------------------------- +LOG="/var/log/safe-shutdown.log" +APAGADO_NODOS="/usr/local/sbin/service-nodes-shutdown.sh" +APAGADO_BDD="/usr/local/sbin/mariadb-shutdown.sh" +# ----------------------------------------------------------------------------- + +log() { echo "$(date -Is) [orchestrate] $*" | tee -a "$LOG"; } + +log "===== SECUENCIA DE APAGADO INICIADA =====" + +# 1. Apagar la(s) raspi(s) de servicio por SSH (espera a que hagan halt). +log "paso 1: apagando raspis de servicio..." +if "$APAGADO_NODOS"; then + log "paso 1 ok" +else + log "paso 1: aviso, el script para el apagado de los nodos devolvio error" +fi + +# 2. Cerrar la base de datos limpiamente. +log "paso 2: cerrando la base de datos..." +if "$APAGADO_BDD"; then + log "paso 2 OK" +else + log "paso 2: ERROR al cerrar la BD" +fi + +log "===== SECUENCIA DE APAGADO COMPLETADA =====" \ No newline at end of file diff --git a/scripts/orchestrate-startup.sh b/scripts/orchestrate-startup.sh new file mode 100644 index 0000000..316233f --- /dev/null +++ b/scripts/orchestrate-startup.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# orchestrate-startup.sh — secuencia de arranque tras un rebote (corre en el HOST). +# Lo dispara el watcher al detectar el fichero de señal 'encender'. +# Orden INVERSO al apagado: primero la BD (para que esté lista antes de que +# los escritores vuelvan), luego HA reenciende Shelly/nodos por su cuenta. +# +# NOTA: el reencendido de los Shelly (Arduinos y raspi de servicio) lo hace la +# automatización de HA. Este script solo se ocupa de lo que vive en el host: +# arrancar el contenedor de la base de datos y confirmar que responde. + +set -uo pipefail + +# CONFIG ---------------------------------------------------------------------- +CONTAINER="mariadb" +LOG="/var/log/safe-shutdown.log" +# ----------------------------------------------------------------------------- + +log() { echo "$(date -Is) [orchestrate-startup] $*" | tee -a "$LOG"; } + +log "===== SECUENCIA DE ARRANQUE INICIADA =====" + +# ¿Ya está corriendo? (idempotente: por si la señal llega dos veces) +if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then + log "el contenedor '$CONTAINER' ya está corriendo. Nada que hacer." + log "===== SECUENCIA DE ARRANQUE COMPLETADA =====" + exit 0 +fi + +# 1. Arrancar el contenedor de la BD. +log "arrancando contenedor '$CONTAINER'..." +if docker start "$CONTAINER" >/dev/null; then + log "docker start devolvió OK" +else + log "ERROR: docker start falló" + exit 1 +fi + +# 2. Esperar a que MariaDB acepte conexiones (no basta con que el contenedor +# esté 'Up': el servidor tarda unos segundos en estar listo). +log "esperando a que MariaDB acepte conexiones..." +ready=0 +for i in {1..30}; do + if docker exec "$CONTAINER" mariadb-admin ping --silent &>/dev/null; then + ready=1 + log "MariaDB responde (tras ${i}s)" + break + fi + sleep 1 +done + +if [[ "$ready" -eq 0 ]]; then + log "ERROR: MariaDB no respondió tras 30s" + exit 1 +fi + +log "===== SECUENCIA DE ARRANQUE COMPLETADA =====" \ No newline at end of file diff --git a/scripts/orchestrate-total-shutdown.sh b/scripts/orchestrate-total-shutdown.sh new file mode 100644 index 0000000..43313a6 --- /dev/null +++ b/scripts/orchestrate-total-shutdown.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# total-shutdown.sh — FASE 2: apagado TOTAL de la raspi principal. +# Corre en el HOST. Cierra BD + HA y finalmente apaga la propia máquina. +# +# ATENCIÓN: la última acción es 'poweroff'. Tras ejecutarse, la raspi se apaga. +# Solo volverá a encenderse cuando reciba de nuevo alimentación (corte físico + +# retorno de tensión). Si la apagas con corriente aún disponible, NO arrancará +# sola: requerirá corte/retorno de alimentación o encendido manual. +# +# Este script asume que la FASE 1 (Arduinos + raspi de servicio) ya se ejecutó. +# Pensado para dispararse solo cuando el SoC cruza el segundo umbral (más bajo) +# y la red sigue sin volver. + +set -uo pipefail + +# CONFIG ---------------------------------------------------------------------- +DB_CONTAINER="mariadb" +HA_CONTAINER="homeassistant" +APAGADO_NODOS="/usr/local/sbin/service-nodes-shutdown.sh" +APAGADO_BDD="/usr/local/sbin/mariadb-shutdown.sh" +LOG="/var/log/safe-shutdown.log" +# ----------------------------------------------------------------------------- + +log() { echo "$(date -Is) [full-shutdown] $*" | tee -a "$LOG"; } + +log "===== FASE 2: APAGADO TOTAL INICIADO =====" +# 1. Comprobar que los nodos de servicio estan apagados +log "paso 1: asegurando el apagado de nodos de servicio..." +if "$APAGADO_NODOS"; then + log "paso 1: OK, todos los nodos apagados" +else + log "paso 1: ERROR, el script de apagado de nodos fallo" +fi + +# 2. Cerrar la BD limpiamente (si sigue corriendo). Reutiliza el script probado. +if docker ps --format '{{.Names}}' | grep -qx "$DB_CONTAINER"; then + log "paso 2: la BD sigue corriendo, cerrándola..." + if "$APAGADO_BDD"; then + log "paso 2 OK" + else + log "paso 2: AVISO, el cierre de BD devolvió error (continuamos igualmente)" + fi +else + log "paso 2: la BD ya estaba parada (cerrada en fase 1). Saltando." +fi + +# 3. Parar Home Assistant limpiamente. +if docker ps --format '{{.Names}}' | grep -qx "$HA_CONTAINER"; then + log "paso 3: parando Home Assistant..." + if docker stop -t 30 "$HA_CONTAINER" >/dev/null; then + log "paso 3 OK" + else + log "paso 3: AVISO, docker stop de HA devolvió error (continuamos)" + fi +else + log "paso 3: Home Assistant no estaba corriendo. Saltando." +fi + +# 4. Forzar que todo baje a disco antes de cortar. +log "paso 4: sincronizando discos (sync)..." +sync +sleep 2 +sync +log "paso 4 OK" + +# 5. Apagar la propia máquina. ESTA ES LA ÚLTIMA LÍNEA QUE SE EJECUTA. +log "paso 5: apagando la raspi principal. Hasta luego." +log "===== FASE 2: APAGADO TOTAL COMPLETADO (poweroff a continuación) =====" +sync +systemctl poweroff \ No newline at end of file diff --git a/scripts/service-nodes-shutdown.sh b/scripts/service-nodes-shutdown.sh new file mode 100644 index 0000000..ba6a928 --- /dev/null +++ b/scripts/service-nodes-shutdown.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# service-nodes-shutdown.sh - Apagado de la(s) raspi(s) de servicio por SSH de +# forma segura. Se ejecuta en la raspi principal. Ordena el poweroff y espera +# a que deje de hacer ping antes de continuar. Despues corta la alimentacion +# fisica a traves del relé correspondiente. + +# REQUISITOS: +# - Claves SSH sin passphrase en /root/.ssh/id_shutdown autorizada en cada nodo +# - Regla sudoers NOPASSWD para /usr/sbin/poweroff en cada nodo + +set -uo pipefail + +# CONFIGURACION --------------------------------------------------------------- +NODES=( + "pi@192.168.1.219|sudo /usr/sbin/poweroff" # Raspi Dinamica + "root@192.168.1.50|/sbin/poweroff" # NVR +) +SSH_KEY="/root/.ssh/id_shutdown" +POWEROFF_CMD="sudo /usr/sbin/poweroff" +TIMEOUT_HALT=60 # Timeout para el apagado +LOG="/var/log/safe-shutdown.log" +SSH_OPTS="-i $SSH_KEY -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new" +# ----------------------------------------------------------------------------- + +log() { echo "$(date -Is) [service-nodes-shutdown] $*" | tee -a "$LOG"; } + +log "inicio (${#NODES[@]} nodo/s)" + +for entry in "${NODES[@]}"; do + host_user="${entry%%|*}" + host="${host_user#*@}" # extrae la IP/host del NODE + cmd="${entry#*|}" + log "apagando $host_user ..." + + # Ordenar poweroff por ssh + if ssh $SSH_OPTS "$host_user" "$cmd"; then + log "$entry: orden de poweroff enviada" + else # Si falla avisamos y seguimos (puede no ser un error real) + log "$entry: AVISO, el SSH devolvio error" + fi + + # Esperamos la NO respuesta de ping + halted=0 + for ((i=1; i/dev/null; then + log "$host: apagado confirmado (dejo de responder tras ${i}s)" + halted=1 + break + fi + sleep 1 + done + + if [[ "$halted" -eq 0 ]]; then + log "$host: AVISO, sigue respondiendo tras ${TIMEOUT_HALT}s (apagado no confirmado)" + fi +done + +log "fin" \ No newline at end of file diff --git a/scripts/shutdown-watcher.sh b/scripts/shutdown-watcher.sh new file mode 100644 index 0000000..fb829e4 --- /dev/null +++ b/scripts/shutdown-watcher.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CONFIGURACION --------------------------------------------------------------- +SIGNAL_DIR="/opt/shutdown-signal" +SHUTDOWN_ORCHESTRATOR="/usr/local/sbin/orchestrate-shutdown.sh" +TOTAL_SHUTDOWN_ORCHESTRATOR="/usr/local/sbin/orchestrate-total-shutdown.sh" +STARTUP_ORCHESTRATOR="/usr/local/sbin/orchestrate-startup.sh" +LOG="/var/log/safe-shutdown.log" + +# PROGRAMA -------------------------------------------------------------------- +log() { echo "$(date -Is) [watcher] $*" | tee -a "$LOG"; } + +log "watcher iniciado, vigilando $SIGNAL_DIR/" + +while true; do + inotifywait -q -e create -e moved_to "$SIGNAL_DIR" | + while read -r dir event file; do + case "$file" in + apagar) + log "señal detectada ($file). Lanzando secuencia de apagado." + "$SHUTDOWN_ORCHESTRATOR" + rm -f "$SIGNAL_DIR/apagar" + log "señal consumida y borrada." + ;; + encender) + log "señal detectada ($file). Lanzando secuencia de encendido." + "$STARTUP_ORCHESTRATOR" + rm -f "$SIGNAL_DIR/encender" + log "señal consumida y borrada." + ;; + apagado_total) + log "señal detectada ($file). Lanzando secuencia de apagado TOTAL." + "$TOTAL_SHUTDOWN_ORCHESTRATOR" + rm -f "$SIGNAL_DIR/apagado_total" + log "señal consumida y borrada." + ;; + esac + done +done \ No newline at end of file