Controlar SAI incompatible con QNAP mediante una Raspberry Pi Zero 2W y NUT

Hace poco instale un SAI de mas capacidad y ohhhhhhh sorpresa no era compatible con QNAP ( que me tienen hasta los webs ) , después de barajar diferentes alternativas decidí usar una Raspberry Pi Zero 2W que tenia libre.

El conexionado es sencillo, por un lado la alimentación y por el otro un cable µUSB OTG al cable USB del SAI

Lo primero será instalar NUT en la Pi

sudo apt-get install nut

 Nos creara en el directorio /etc/nut/ una serie de ficheros donde configuraremos NUT

El primero que configuraremos será el archivo nut.conf

MODE=netserver

En el archivo ups.conf crearemos la configuración de nuestro SAI , es importante que el driver sea el correcto , podemos encontrar mas información aquí

maxretry = 3

[sai_2000va]
      driver = blazer_usb
      port = auto
      desc = "SAI 2000VA"
      pollinterval = 15

En el archivo upsd.conf configuraremos todo lo relacionado con el NUT Server

LISTEN 127.0.0.1 3493

MAXAGE 25
MAXCONN 1024

En el archivo upsd.users configuraremos todo lo relacionado con usuarios y roles

[admin]
	password  = <<PASSWORD>>
	actions = SET
	instcmds = ALL
[upsmon_local]
	password  = P@ssw0rd
	upsmon master
[upsmon_remote]
	password  = P@ssw0rd
	upsmon slave
[monuser]
	password  = pass
	upsmon slave

En el archivo upsmon.conf configuraremos todo lo relacionado con la monitorización y el apagado del SAI si es necesario

# --------------------------------------------------------------------------
# DEADTIME - Interval to wait before declaring a stale ups "dead"
#
# upsmon requires a UPS to provide status information every few seconds
# (see POLLFREQ and POLLFREQALERT) to keep things updated.  If the status
# fetch fails, the UPS is marked stale.  If it stays stale for more than
# DEADTIME seconds, the UPS is marked dead.
#
# A dead UPS that was last known to be on battery is assumed to have gone
# to a low battery condition.  This may force a shutdown if it is providing
# a critical amount of power to your system.
#
# Note: DEADTIME should be a multiple of POLLFREQ and POLLFREQALERT.
# Otherwise you'll have "dead" UPSes simply because upsmon isn't polling
# them quickly enough.  Rule of thumb: take the larger of the two
# POLLFREQ values, and multiply by 3.

DEADTIME 25

# --------------------------------------------------------------------------

MONITOR sai_2000va@localhost 1 admin  <<PASSWORD>>  master

Iniciaremos NUT y comprobamos su estado al arrancar

sudo systemctl start nut-driver
sudo systemctl status nut-driver

SI nos da un error de conexión entonces reiniciaremos todo los servicios relacionados

sudo service nut-server restart
sudo service nut-client restart
sudo systemctl restart nut-monitor
sudo upsdrvctl stop
sudo upsdrvctl start

Tendremos que ver como con sudo systemctl status nut-driver el servicio esta activo y sin errores

Con el comando upsc veremos la información del SAI al que nos hemos conectado

sudo upsc sai_2000va@localhost

Esta seria una salida típica a la llamada del comando upsc

Se puede utilizar el comando upslog para realizar una monitorización contínua del SAI de la siguiente manera:

sudo upslog -ssai_2000va@localhost -l -

Ahora pasaremos a montar nut-cgi para poder verlo en el navegador

Instalaremos apache y nut-cgi

sudo apt install apache2 nut-cgi

Editaremos el fichero upsset.conf y descomentaremos la linea que pone I_HAVE_SECURED_MY_CGI_DIRECTORY

sudo nano /etc/nut/upsset.conf

Quedaria asi :

#
# Assuming you have all this done (and it works), then you may uncomment
# the line below and start using upsset.cgi through your web browser.
#

###
I_HAVE_SECURED_MY_CGI_DIRECTORY
###

Habilitaremos el modulo cgi

sudo a2enmod cgi

Editaremos el fichero hosts.conf y añadiremos la siguiente linea

MONITOR sai_2000va@localhost "SAI 2000VA"

Reiniciaremos apache con el comando

sudo systemctl restart apache2

Ahora podremos llamar al CGI mediante el siguiente enlace http://192.168.1.113/cgi-bin/nut/upsstats.cgi

Si todo es correcto deberíamos ver lo siguiente

Y si pulsamos el enlace que aparece en el nombre del SAI aparecerá algo similar a esto

Y si pulsamos el enlace que aparece en All data nos aparecerá un resumen de los datos del SAI

Podemos hacer la prueba de quitar la alimentación al SAI para ver como pasa de modo OL “en linea” a modo OB “en bateria”

Y veremos que progresivamente en nivel de carga va bajando

Y el estado cambia tambien …

Para asegurarnos que arranca el servicio editaremos /etc/rc.local y añadiremos esto antes de la linea en la que pone “exit 0”

(sudo upsdrvctl start
sleep 30
sudo service nut-server start
sudo service nut-client start)&

Si vamos al panel de control del NAS lo configuraremos como SAI esclavo en red

Pero ohhhhhhhhhhhhhh sorpresa , no hace na de naaaaaaaaaaaaaaaaaaaaa

En QNAP como no la configuración no esta en etc/nut , ellos son mas chulos que un ocho y la ponen en /etc/config/ups

Editaremos el fichero upsmon.conf

[~] # cd /etc/config/ups
[/etc/config/ups] # ls
ups.conf  upsd.conf  upsdrv.map  upsd.users  upsmon.conf
[/etc/config/ups] # vi upsmon.conf

Y ohhhhhhhhhh sorpresa cuando en el panel de control cambiamos la IP del servidor NUT la cambia , pero pone a piñón el usuario y la contraseña , por sus web tiene que ser admin y 123456 respectivamente y para mas inri el nombre del sai tiene que ser qnapups

En el archivo upsmon.conf reconfiguraremos con el nuevo nombre , usuario y contraseña

MONITOR qnapups@192.168.1.113 1 admin 123456 slave

En el archivo hosts.conf reconfiguraremos con el nuevo nombre

MONITOR qnapups@localhost "SAI 2000VA"

En el archivo ups.conf reconfiguraremos con el nuevo nombre

maxretry = 3

[qnapups]
      driver = blazer_usb
      port = auto
      desc = "SAI 2000VA"
      pollinterval = 15
      vendorid = "0001"
      productid = "0000"
      product = "MEC0003"
      vendor = "MEC"
      bus = "001"

En el archivo upsd.conf añadiremos las dos líneas inferiores

LISTEN 0.0.0.0 3493
LISTEN 127.0.0.1 3493
LISTEN 192.168.1.113 3493

En el archivo upsd.users cambiaremos la contraseña del usuario admin

[admin]
        password  = 123456
        actions = SET
        instcmds = ALL
[upsmon_local]
        password  = P@ssw0rd
        upsmon master
[upsmon_remote]
        password  = P@ssw0rd
        upsmon slave
[monuser]
        password  = pass
        upsmon slave

En el archivo upsmon.conf cambiaremos a la nueva configuración

RUN_AS_USER root
MONITOR qnapups@localhost 1 upsmon_local P@ssw0rd master

Reiniciaremos los servicios de nuevo para que coja los valores de la nueva configuración

systemctl restart nut-driver

systemctl restart nut-server
systemctl restart nut-client
systemctl restart nut-monitor

systemctl status nut-monitor

Comprobamos que arranca bien el servicio nut-monitor y que no hay errores

Al reiniciar el nut-driver veremos que se manda un mensaje broadcast de perdida de comunicación y otro al reestablecer la comunicacion

root@pi2w3:/etc/nut# sudo systemctl restart  nut-driver

Broadcast message from root@pi2w3 (somewhere) (Wed Apr 10 01:25:29 2024):

UPS qnapups@localhost is unavailable

Broadcast message from root@pi2w3 (somewhere) (Wed Apr 10 01:25:34 2024):

Communications with UPS qnapups@localhost established

root@pi2w3:/etc/nut# sudo systemctl status nut-driver

Ahora por fin , ya se ve en la seccion de UPS del panel de control del QNAP

Si quitamos la alimentación veremos como detecta el cambio , y si pasase en ese estado el tiempo que le programemos entraria en modo “autoproteccion”

También veremos los eventos de perdida y recuperación de la alimentación del SAI

Al final todo el conjunto ocupa nada y menos y cabe en la caja de la pi , quedaria así

Con un disipador interno no se calienta prácticamente si esta solo dedicada a esta dedicado a hacer de servidor NUT

Y con esto y un bizcocho ………

Modificar sensor de temperatura zigbee cambiar AHT20 para hacerlo externo

Tengo este sensor en el arcón congelador , la verdad es que es el único que me aguanta temperaturas de -20ºC , el único inconveniente es que las baterías las devora a esas temperaturas , y una bateria que debería aguantar un año o mas en diez días esta agotada.

Decidí que tenia que ponerle solución y para ello lo abrí a ver que había tripas adentro

Vamos bien , vi que tenia un sensor de temperatura I2C AHT20

Teóricamente podria aguantar hasta -40ºC según la tabla de características del modulo

Empecé por mirar donde estaban las señales en el modulo ZTU , pero alli directamente no llegaban

Luego me cerciore donde iban SDA y SCL , así como la tensión de la bateria y GND en los pads de la placa

Estos serian los pads de cada una de las señales que necesitaremos para conectar nuestro modulo externo.

El siguiente paso es localizar un AHT20 en aliexpress , por ejemplo este.

Soldamos un cable flat de cuatro hilos al modulo

Y lo conectamos a nuestro modulo zigbee ( previamente habremos desoldado el AHT20 que estaba soldado en la placa )

Con cuidad meteremos todo el conjunto en su caja

Y nos tiene que quedar así de mono

Protegeremos nuestro modulo con pegamento termofusible , esto hará que no nos marque correctamente la humedad ambiental , pero realimente el valor que nos interesa es la temperatura

Una vez instalado nos deberá quedar así de mono

Una vez añadido a zigbee2mqtt empezaremos a crear los sensores , he comentado la parte de humedad y todo lo relacionado con ella ya que no la vamos a usar , en la parte de la bateria no devuelve un valor porcentual , devuelve low , medium y high que transformaremos a numérico

  ### TERMOMETRO ARCON CONGELADOR
  
    - state_topic: "zigbee2mqtt/temperatura_congelador"
      availability_topic: "zigbee2mqtt/bridge/state"
      unit_of_measurement: "°C"
      device_class: "temperature"
      value_template: "{{ value_json.temperature }}"
      name: "temperatura_congelador_temperatura"  
  
    # - state_topic: "zigbee2mqtt/temperatura_congelador"
      # availability_topic: "zigbee2mqtt/bridge/state"
      # unit_of_measurement: "%"
      # device_class: "humidity"
      # value_template: "{{ value_json.humidity }}"
      # name: "temperatura_congelador_humedad"  
  
    - state_topic: "zigbee2mqtt/temperatura_congelador"
      availability_topic: "zigbee2mqtt/bridge/state"
      unit_of_measurement: "%"
      icon: "mdi:battery"
      device_class: "battery"
      # value_template: "{{ value_json.battery }}"
      value_template: >-
        {% if value_json.battery_state == 'low' %}
          5
        {% elif value_json.battery_state == 'medium' %}
          50
        {% elif value_json.battery_state == 'high' %}
          100
        {% else %}
          0
        {% endif %}      
      expire_after: 86400
      force_update: true
      name: "temperatura_congelador_bateria"  
  
    - state_topic: "zigbee2mqtt/temperatura_congelador"
      availability_topic: "zigbee2mqtt/bridge/state"
      icon: "mdi:signal"
      unit_of_measurement: "lqi"
      value_template: "{{ value_json.linkquality }}"    
      name: "temperatura_congelador_estado"  
      
    - state_topic: "zigbee2mqtt/temperatura_congelador"
      availability_topic: "zigbee2mqtt/bridge/state"
      icon: "mdi:calendar-clock"
      value_template: "{{ value_json.last_seen }}"
      name: "temperatura_congelador_ultima_conexion"         

Crearemos un sensor de disponibilidad

    - state_topic: "zigbee2mqtt/temperatura_congelador/availability"       
      availability_topic:  "zigbee2mqtt/temperatura_congelador/availability"     
      name: temperatura_congelador_disponibilidad      
      device_class: "connectivity"      
      payload_on: "online"
      payload_off: "offline"  

Lo ponemos monos en en nuestro customize.yaml

# sensor.temperatura_congelador_humedad:
  # friendly_name: Humedad arcon congelador
sensor.temperatura_congelador_temperatura:
  friendly_name: Temperatura arcon congelador
sensor.temperatura_congelador_bateria:
  friendly_name: Termometro arcon congelador
sensor.temperatura_congelador_estado:
  friendly_name: Termometro arcon congelador
  
binary_sensor.temperatura_congelador_disponibilidad: 
  friendly_name: "Sensor temp. arcon congelador"

Creamos un par de sensores de tiempo para el sensor de disponibilidad

  - platform: template
    sensors:
      arcon_temperatura_sin_conexion:
        friendly_name: "Temperatura arcon ultima lectura"
        value_template: >
          {{ ((as_timestamp(now()) - as_timestamp(states.sensor.temperatura_congelador_ultima_conexion.last_changed)) | int(0) | round(0,default=0)) }}
        unit_of_measurement: 'Seg.'
        icon_template: mdi:timer-outline
                  
  - platform: template
    sensors:
      arcon_temperatura_sin_conexion_tiempo:
        friendly_name: "Temperatura arcon ultima lectura"
        value_template: >-
            {% set s = ((as_timestamp(now()- timedelta(hours=1)) - as_timestamp(states.sensor.temperatura_congelador_ultima_conexion.last_changed)) | int(0) | round(0,default=0)) %}
            {{ s | timestamp_custom("%H:%M") }}
        icon_template: mdi:timer-outline

Y otro sensor de tiempo en minutos de la ultima conexión

  - platform: template
    sensors:
      temperatura_congelador_ultima_conexion_minutos:
        value_template: >-
          {% set x1 = as_timestamp(states('sensor.temperatura_congelador_ultima_conexion')) %}
          {% set x2 = as_timestamp(now()) %}
          {% set time = x2 - x1 | int(0) %}
          {% set days = (time/86400) | int %}
          {% set hours = (time / 3600 % 24) | int %}
          {% set minutes = (((time / 3600) % 1) * 60) | int %}
          {{ days ~ 'd ' ~ hours ~ 'h ' ~ minutes ~ 'm' }}        
        friendly_name: Ultima conexión sensor temp. congelador
        icon_template: "mdi:calendar-clock"

A la vez creamos un sensor de bateria así con iconos con porcentajes y esas cosas

      temperatura_congelador_battery_level:
        friendly_name: Sensor temperatura congelador
        value_template: "{{ states.sensor.temperatura_congelador_bateria.state |default(-1)| int(0)  if states.sensor.temperatura_congelador_bateria is not none }}"
        unit_of_measurement: '%'
        icon_template: '{% if states.sensor.temperatura_congelador_bateria.state| int(0)   == 100 %}
                          mdi:battery
                       {% elif states.sensor.temperatura_congelador_bateria.state| int(0)   >= 90 %}
                          mdi:battery-90
                       {% elif states.sensor.temperatura_congelador_bateria.state| int(0)   >= 80 %}
                          mdi:battery-80
                       {% elif states.sensor.temperatura_congelador_bateria.state| int(0)   >= 70 %}
                          mdi:battery-70
                       {% elif states.sensor.temperatura_congelador_bateria.state| int(0)   >= 60 %}
                          mdi:battery-60
                       {% elif states.sensor.temperatura_congelador_bateria.state| int(0)   >= 50 %}
                          mdi:battery-50
                       {% elif states.sensor.temperatura_congelador_bateria.state| int(0)   >= 40 %}
                          mdi:battery-40
                       {% elif states.sensor.temperatura_congelador_bateria.state| int(0)   >= 30 %}
                          mdi:battery-30
                       {% elif states.sensor.temperatura_congelador_bateria.state| int(0)   >= 20 %}
                          mdi:battery-20
                       {% elif states.sensor.temperatura_congelador_bateria.state| int(0)   >= 10 %}
                          mdi:battery-10
                       {% else %}
                          mdi:battery-outline
                       {% endif %}'    

Crearemos un sensor extra donde podremos calibrar si queremos un offset para la temperatura leida

#################################################################################################
## SENSOR CONGELADOR
#################################################################################################

  - platform: template
    sensors:
      temperatura_congelador_calibrada:
        friendly_name: 'Temperatura congelador'
        value_template: >
          {% if states('sensor.temperatura_congelador_temperatura') | float(0) | round(5,default=0) == 0 %}
            {{ states('sensor.temperatura_congelador_calibrada') | float(0) | round(2,default=0) }}
          {% else %}        
            {% set T = (states.sensor.temperatura_congelador_temperatura.state | float(0) | round(5,default=0)) %}
            {% set Ajuste  = -0.5 %}
            {% set Resultado =  T + Ajuste | round(1,default=0)  %}
            {{Resultado |round(1,default=0)  }}
          {% endif %}  
        unit_of_measurement: '°C'
        icon_template: mdi:thermometer

  # - platform: template
    # sensors:
      # humedad_congelador_calibrada:
        # friendly_name: 'Humedad congelador'
        # value_template: >
          # {% if states('sensor.temperatura_congelador_humedad') | float(0) | round(5,default=0) == 0 %}
            # {{ states('sensor.humedad_congelador_calibrada') | float(0) | round(2,default=0) }}
          # {% else %}        
            # {% set H = (states.sensor.temperatura_congelador_humedad.state | float(0) | round(5,default=0)) %}
            # {% set Ajuste  =  0.0 %}
            # {% set Resultado =  H + Ajuste | round(1,default=0)  %}
            # {{Resultado |round(1,default=0) }}
          # {% endif %}  
        # unit_of_measurement: '%'
        # icon_template: mdi:water-percent
                  
  # - platform: template
    # sensors:
      # sensacion_calor_congelador_calibrada:
        # friendly_name: 'Indice de calor arcon'
        # value_template: >-
          # {% set T = ((states.sensor.temperatura_congelador_calibrada.state | float(0) | round(2,default=0))*1.8)+32 %}
          # {% set RH = states.sensor.humedad_congelador_calibrada.state | float(0) | round(5,default=0) %}
          
          # {% set STEADMAN_HI = 0.5 * (T + 61.0 + ((T-68.0)*1.2) + (RH*0.094)) %}
          
          # {% if STEADMAN_HI >= 80 %}
          
          # {% set ROTHFUSZ_HI = -42.379 + 2.04901523*T + 10.14333127*RH - 0.22475541*T*RH - 0.00683783*T*T - 0.05481717*RH*RH + 0.00122874*T*T*RH + 0.00085282*T*RH*RH - 0.00000199*T*T*RH*RH %}
                           
          # {% set HI = ROTHFUSZ_HI %}
            
          # {% if RH < 13 and 80 < T < 112 %}
             # {% set ADJUSTMENT = ((13-RH)/4)*((17-(T-95)|abs)/17)*0.5 %}
             # {% set HI = HI - ADJUSTMENT %}
             # {% elif RH > 85 and 80 < T < 87 %}
               # {% set ADJUSTMENT = ((RH-85)/10) * ((87-T)/5) %}
               # {% set HI = HI + ADJUSTMENT %}
             # {% endif %}
              
          # {% else %}
            # {% set HI = STEADMAN_HI %}
          # {% endif %}
          
          # {% set HI_C = (HI-32)/1.8 %}
            
          # {{- HI_C|round(1,default=0) -}}
        # unit_of_measurement: '°C'
        # icon_template: mdi:thermometer      

A partir de este momento ya podemos empezar a crear nuestras automatizaciones , esta por ejemplo hace que si sube la temperatura por encima de los -10ºC nos envia avisos de alarma por Telegram con los valores , así como los valores del enchufe inteligente que esta alimentando al arcón.

############################################
### Arcon  -> Alarma por temperatura
############################################

- id: arcon congelador por temperatura alta
  alias: arcon congelador por temperatura alta
  initial_state: 'on'
  trigger:
    - platform: time_pattern
      #cada cinco minutos
      minutes: '/5'
  condition:
    - condition: template
      value_template: "{{(states.sensor.temperatura_congelador_calibrada.state | int(0) | round(0,default=0)) > -10 }}"      

  action:
    - service: notify.notif_telegram_ha_urgentes
      data:  
        message: |
          {{"\U00002744"}}{{"\U00002744"}} Alarma *arcon* : {{now().strftime("%H:%M:%S")}} {{"\U00002744"}}{{"\U00002744"}}
           
          *Temp. congelador:* {{(states.sensor.temperatura_congelador_calibrada.state | round(2,default=0))}} ºC
          
          {% if is_state("switch.enchufe_23_congelador", "on") %}{{"\U0001F7E2"}} *Enchufe arcon*: Encendido {% else %}{{"\U0001F534"}} *Enchufe arcon*: Apagado{% endif %}
          
          *Intensidad congelador:* {{(states.sensor.enchufe_23_congelador_current.state | round(2,default=0))}} Amp.
          *Tensión congelador:* {{(states.sensor.enchufe_23_congelador_voltage.state  | round(2,default=0))}} V.
          *Potencia congelador:* {{(states.sensor.enchufe_23_congelador_power.state | round(2,default=0))}} W.

    - service: notify.notif_telegram_bot
      data:  
        message: |
          {{"\U00002744"}}{{"\U00002744"}} Alarma *arcon* : {{now().strftime("%H:%M:%S")}} {{"\U00002744"}}{{"\U00002744"}}
           
          *Temp. congelador:* {{(states.sensor.temperatura_congelador_calibrada.state | round(2,default=0))}} ºC
          
          {% if is_state("switch.enchufe_23_congelador", "on") %}{{"\U0001F7E2"}} *Enchufe arcon*: Encendido {% else %}{{"\U0001F534"}} *Enchufe arcon*: Apagado{% endif %}
          
          *Intensidad congelador:* {{(states.sensor.enchufe_23_congelador_current.state | round(2,default=0))}} Amp.
          *Tensión congelador:* {{(states.sensor.enchufe_23_congelador_voltage.state  | round(2,default=0))}} V.
          *Potencia congelador:* {{(states.sensor.enchufe_23_congelador_power.state | round(2,default=0))}} W.

    - service: notify.notif_telegram_grupo_ha
      data:  
        message: |
          {{"\U00002744"}}{{"\U00002744"}} Alarma *arcon* : {{now().strftime("%H:%M:%S")}} {{"\U00002744"}}{{"\U00002744"}}
           
          *Temp. congelador:* {{(states.sensor.temperatura_congelador_calibrada.state | round(2,default=0))}} ºC
          
          {% if is_state("switch.enchufe_23_congelador", "on") %}{{"\U0001F7E2"}} *Enchufe arcon*: Encendido {% else %}{{"\U0001F534"}} *Enchufe arcon*: Apagado{% endif %}
          
          *Intensidad congelador:* {{(states.sensor.enchufe_23_congelador_current.state | round(2,default=0))}} Amp.
          *Tensión congelador:* {{(states.sensor.enchufe_23_congelador_voltage.state  | round(2,default=0))}} V.
          *Potencia congelador:* {{(states.sensor.enchufe_23_congelador_power.state | round(2,default=0))}} W.

Y con esto y un bizcocho …