srne-mqtt/srnemqtt/consumers/mqtt.py

362 lines
12 KiB
Python
Raw Normal View History

2021-11-14 00:55:43 +00:00
# -*- coding: utf-8 -*-
2021-11-14 03:46:17 +00:00
import json
2023-12-16 22:36:53 +00:00
from logging import getLogger
2022-01-26 19:41:59 +00:00
from time import sleep
2023-12-09 15:35:45 +00:00
from typing import Any, Dict, List, Optional, TypeAlias
2021-11-14 00:55:43 +00:00
from uuid import uuid4
import paho.mqtt.client as mqtt
2023-01-07 17:24:41 +00:00
from ..solar_types import DataName
2021-11-14 00:55:43 +00:00
from . import BaseConsumer
2023-12-16 22:36:53 +00:00
logger = getLogger(__name__)
MAP_VALUES: Dict[DataName, Dict[str, Any]] = {
# DataName.BATTERY_VOLTAGE_MIN: {},
# DataName.BATTERY_VOLTAGE_MAX: {},
# DataName.CHARGE_MAX_CURRENT: {},
# DataName.DISCHARGE_MAX_CURRENT: {},
# DataName.CHARGE_MAX_POWER: {},
# DataName.DISCHARGE_MAX_POWER: {},
# DataName.CHARGE_AMP_HOUR: {},
# DataName.DISCHARGE_AMP_HOUR: {},
DataName.PRODUCTION_ENERGY: {
2021-11-14 02:32:30 +00:00
"unit": "Wh",
"type": "energy",
"state_class": "total_increasing",
},
DataName.CONSUMPTION_ENERGY: {
2021-11-14 02:32:30 +00:00
"unit": "Wh",
"type": "energy",
"state_class": "total_increasing",
},
# DataName.RUN_DAYS: {},
# DataName.DISCHARGE_COUNT: {},
# DataName.FULL_CHARGE_COUNT: {},
# DataName.TOTAL_CHARGE_AMP_HOURS: {},
# DataName.TOTAL_DISCHARGE_AMP_HOURS: {},
DataName.TOTAL_PRODUCTION_ENERGY: {
2021-11-14 02:32:30 +00:00
"unit": "Wh",
"type": "energy",
"state_class": "total_increasing",
2021-11-14 03:46:17 +00:00
"expiry": 180,
2021-11-14 02:32:30 +00:00
},
DataName.TOTAL_CONSUMPTION_ENERGY: {
2021-11-14 02:32:30 +00:00
"unit": "Wh",
"type": "energy",
"state_class": "total_increasing",
2021-11-14 03:46:17 +00:00
"expiry": 180,
2021-11-14 02:32:30 +00:00
},
#
DataName.BATTERY_CHARGE: {
"unit": "%",
"type": "battery",
"state_class": "measurement",
},
DataName.BATTERY_VOLTAGE: {
"unit": "V",
"type": "voltage",
"state_class": "measurement",
},
DataName.BATTERY_CURRENT: {
"unit": "A",
"type": "current",
"state_class": "measurement",
},
DataName.INTERNAL_TEMPERATURE: {
2021-11-14 02:32:30 +00:00
"unit": "°C",
"type": "temperature",
"state_class": "measurement",
},
DataName.BATTERY_TEMPERATURE: {
2021-11-14 02:32:30 +00:00
"unit": "°C",
"type": "temperature",
"state_class": "measurement",
},
DataName.LOAD_VOLTAGE: {
"unit": "V",
"type": "voltage",
"state_class": "measurement",
},
DataName.LOAD_CURRENT: {
"unit": "A",
"type": "current",
"state_class": "measurement",
},
DataName.LOAD_POWER: {"unit": "W", "type": "power", "state_class": "measurement"},
DataName.LOAD_ENABLED: {
"type": "outlet",
"platform": "switch",
},
DataName.PANEL_VOLTAGE: {
"unit": "V",
"type": "voltage",
"state_class": "measurement",
},
DataName.PANEL_CURRENT: {
"unit": "A",
"type": "current",
"state_class": "measurement",
},
DataName.PANEL_POWER: {"unit": "W", "type": "power", "state_class": "measurement"},
# DataName.LOAD_ENABLED: {},
DataName.CALCULATED_BATTERY_POWER: {
"unit": "W",
"type": "power",
"state_class": "measurement",
},
DataName.CALCULATED_PANEL_POWER: {
"unit": "W",
"type": "power",
"state_class": "measurement",
},
DataName.CALCULATED_LOAD_POWER: {
"unit": "W",
"type": "power",
"state_class": "measurement",
},
2021-11-14 02:32:30 +00:00
}
2021-11-14 00:55:43 +00:00
2023-12-09 15:35:45 +00:00
PayloadType: TypeAlias = str | bytes | bytearray | int | float | None
2021-11-14 00:55:43 +00:00
class MqttConsumer(BaseConsumer):
initialized: List[str]
2023-12-10 22:59:50 +00:00
_client: mqtt.Client | None = None
2021-11-14 00:55:43 +00:00
def __init__(self, settings: Dict[str, Any]) -> None:
self.initialized = []
super().__init__(settings)
2023-12-10 22:59:50 +00:00
@property
def client(self) -> mqtt.Client:
if self._client is not None:
return self._client
self._client = mqtt.Client(
client_id=self.settings["client"]["id"], userdata=self
)
self._client.on_connect = self.on_connect
self._client.on_message = self.on_message
self._client.on_disconnect = self.on_disconnect
self._client.on_connect_fail = self.on_connect_fail
2021-11-14 00:55:43 +00:00
# Will must be set before connecting!!
2023-12-10 22:59:50 +00:00
self._client.will_set(
2021-11-14 00:55:43 +00:00
f"{self.topic_prefix}/available", payload="offline", retain=True
)
while True:
try:
2023-12-10 22:59:50 +00:00
self._client.connect(
self.settings["client"]["host"],
self.settings["client"]["port"],
self.settings["client"]["keepalive"],
)
break
except OSError as err:
# Network is unreachable
if err.errno == 101:
pass
# Temporary failure in name resolution
elif err.errno == -3:
pass
else:
2023-12-16 22:36:53 +00:00
logger.exception("Unknown error connecting to mqtt server")
raise
2023-12-16 22:36:53 +00:00
logger.warning(
"Temporary failure connecting to mqtt server", exc_info=True
)
sleep(0.1)
2023-12-10 22:59:50 +00:00
return self._client
2021-11-14 00:55:43 +00:00
def config(self, settings: Dict[str, Any]):
super().config(settings)
settings.setdefault("client", {})
settings["client"].setdefault("id", None)
settings["client"].setdefault("host", "")
settings["client"].setdefault("port", 1883)
settings["client"].setdefault("keepalive", 60)
if not settings.get("device_id"):
settings["device_id"] = str(uuid4())
settings.setdefault("prefix", "solarmppt")
settings.setdefault("discovery_prefix", "homeassistant")
2023-12-10 22:59:50 +00:00
_controller_id: str | None = None
@property
def controller_id(self) -> str:
assert self.controller is not None
# Controller serial is fetched from device, cache it.
if self._controller_id is None:
self._controller_id = self.controller.serial
return f"{self.controller.manufacturer_id}_{self._controller_id}"
2021-11-14 00:55:43 +00:00
@property
def topic_prefix(self):
2023-12-10 22:59:50 +00:00
return f"{self.settings['prefix']}/{self.controller_id}"
2021-11-14 00:55:43 +00:00
def get_ha_config(
self,
2023-12-09 15:35:45 +00:00
id: str,
name: str,
2021-11-14 00:55:43 +00:00
unit: Optional[str] = None,
type: Optional[str] = None,
expiry: int = 90,
state_class: Optional[str] = None,
platform: str = "sensor",
2021-11-14 00:55:43 +00:00
):
assert state_class in [None, "measurement", "total", "total_increasing"]
2023-12-10 22:59:50 +00:00
assert self.controller is not None
2021-11-14 00:55:43 +00:00
res = {
"~": f"{self.topic_prefix}",
2023-12-10 22:59:50 +00:00
"unique_id": f"{self.controller_id}_{id}",
"object_id": f"{self.controller_id}_{id}", # Used for entity id
2021-11-14 00:55:43 +00:00
"availability_topic": "~/available",
"state_topic": f"~/{id}",
2021-11-14 02:32:30 +00:00
"name": name,
2021-11-14 00:55:43 +00:00
"device": {
"identifiers": [
2023-12-10 22:59:50 +00:00
self.controller_id,
2021-11-14 00:55:43 +00:00
],
2023-12-10 22:59:50 +00:00
"manufacturer": self.controller.manufacturer,
"model": self.controller.model,
"sw_version": self.controller.version,
2023-12-10 22:59:50 +00:00
"via_device": self.settings["device_id"],
2021-11-14 03:46:17 +00:00
"suggested_area": "Solar panel",
2023-12-10 22:59:50 +00:00
"name": self.controller.name,
2021-11-14 00:55:43 +00:00
},
"force_update": True,
"expire_after": expiry,
}
if unit:
res["unit_of_meas"] = unit
if type:
res["dev_cla"] = type
if state_class:
res["state_class"] = state_class
if platform == "switch":
res["command_topic"] = f"{res['state_topic']}/set"
res["payload_on"] = True
res["payload_off"] = False
2021-11-14 03:46:17 +00:00
return res
2021-11-14 00:55:43 +00:00
# The callback for when the client receives a CONNACK response from the server.
@staticmethod
def on_connect(client: mqtt.Client, userdata: "MqttConsumer", flags, rc):
2023-12-16 22:36:53 +00:00
logger.info("MQTT connected with result code %s", rc)
2021-11-14 00:55:43 +00:00
# Subscribing in on_connect() means that if we lose the connection and
# reconnect then subscriptions will be renewed.
# client.subscribe("$SYS/#")
client.publish(
f"{userdata.topic_prefix}/available", payload="online", retain=True
)
load_set_topic = f"{userdata.topic_prefix}/load_enabled/set"
client.message_callback_add(load_set_topic, userdata.on_load_switch)
client.subscribe(load_set_topic)
@staticmethod
def on_load_switch(
client: mqtt.Client, userdata: "MqttConsumer", message: mqtt.MQTTMessage
):
assert userdata.controller is not None
2023-12-16 22:36:53 +00:00
logger.debug(message.payload)
payload = message.payload.decode().upper() in ("ON", "TRUE", "ENABLE", "YES")
res = userdata.controller.load_enabled = payload
client.publish(
f"{userdata.topic_prefix}/load_enabled", payload=res, retain=True
)
2022-01-26 19:41:59 +00:00
@staticmethod
def on_connect_fail(client: mqtt.Client, userdata: "MqttConsumer"):
2023-12-16 22:36:53 +00:00
logger.warning("on_connect_fail")
2022-01-26 19:41:59 +00:00
2021-11-14 00:55:43 +00:00
# The callback for when a PUBLISH message is received from the server.
@staticmethod
def on_message(client, userdata, msg):
2023-12-16 22:36:53 +00:00
logger.info(msg.topic + " " + str(msg.payload))
2021-11-14 00:55:43 +00:00
2022-01-26 19:41:59 +00:00
@staticmethod
def on_disconnect(client: mqtt.Client, userdata: "MqttConsumer", rc, prop=None):
2023-12-16 22:36:53 +00:00
logger.warning("on_disconnect %s", rc)
2022-01-26 19:41:59 +00:00
2021-11-14 00:55:43 +00:00
def poll(self):
2022-01-26 19:41:59 +00:00
res = self.client.loop(timeout=0.1, max_packets=5)
if res != mqtt.MQTT_ERR_SUCCESS:
2023-12-16 22:36:53 +00:00
logger.warning("loop returned non-success: %s", res)
2022-01-26 19:41:59 +00:00
try:
sleep(1)
res = self.client.reconnect()
if res != mqtt.MQTT_ERR_SUCCESS:
2023-12-16 22:36:53 +00:00
logger.error("Reconnect failed: %s", res)
2022-01-26 19:41:59 +00:00
except (OSError, mqtt.WebsocketConnectionError) as err:
2023-12-16 22:36:53 +00:00
logger.error("Reconnect failed: %s", err)
2022-01-26 19:41:59 +00:00
2021-11-14 00:55:43 +00:00
return super().poll()
2023-12-09 15:35:45 +00:00
def write(self, data: Dict[str, PayloadType]):
2021-11-14 03:46:17 +00:00
self.client.publish(f"{self.topic_prefix}/raw", payload=json.dumps(data))
2021-11-14 02:32:30 +00:00
2023-12-10 22:59:50 +00:00
for dataname, data_value in data.items():
if dataname in MAP_VALUES:
if dataname not in self.initialized:
km = MAP_VALUES[DataName(dataname)]
pretty_name = dataname.replace("_", " ").capitalize()
disc_prefix = self.settings["discovery_prefix"]
platform = km.get("platform", "sensor")
2023-12-09 15:35:45 +00:00
2021-11-14 02:32:30 +00:00
self.client.publish(
f"{disc_prefix}/{platform}/{self.controller_id}/{dataname}/config",
2023-12-10 22:59:50 +00:00
payload=json.dumps(
self.get_ha_config(dataname, pretty_name, **km)
),
2021-11-14 02:32:30 +00:00
retain=True,
)
2023-12-10 22:59:50 +00:00
self.initialized.append(dataname)
2021-11-14 02:32:30 +00:00
2023-12-10 22:59:50 +00:00
self.client.publish(
f"{self.topic_prefix}/{dataname}", data_value, retain=True
)
2021-11-14 00:55:43 +00:00
def exit(self):
self.client.publish(
f"{self.topic_prefix}/available", payload="offline", retain=True
)
while self.client.want_write():
self.client.loop_write(10)
self.client.disconnect()
return super().exit()
# Client(client_id="", clean_session=True, userdata=None,
# protocol=MQTTv311, transport="tcp")
# connect_srv(domain, keepalive=60, bind_address="")
# Connect to a broker using an SRV DNS lookup to obtain the broker address.
# Takes the following arguments:
# domain
# the DNS domain to search for SRV records.
# If None, try to determine the local domain name.
# client.will_set(topic, payload=None, qos=0, retain=False)
# Blocking call that processes network traffic, dispatches callbacks and
# handles reconnecting.
# Other loop*() functions are available that give a threaded interface and a
# manual interface.
# client.loop_forever()