diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dce2499 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.10.14-bookworm + +WORKDIR /app + +RUN apt-get update && apt-get install usbutils -y && pip install --upgrade pip + +COPY . /app/dali2mqtt + +RUN cd dali2mqtt && pip install -r requirements.txt + +WORKDIR /app/dali2mqtt + +ENTRYPOINT ["python", "-m", "dali2mqtt.dali2mqtt", "--config", "/app/dali-config/config.yaml"] diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..02a474a --- /dev/null +++ b/config.yaml @@ -0,0 +1,8 @@ +dali_driver: hasseb +devices_names: devices.yaml +ha_discovery_prefix: homeassistant +log_color: false +log_level: info +mqtt_base_topic: dali2mqtt +mqtt_port: 1883 +mqtt_server: localhost diff --git a/dali2mqtt/dali2mqtt.py b/dali2mqtt/dali2mqtt.py index e7904a5..304eccc 100644 --- a/dali2mqtt/dali2mqtt.py +++ b/dali2mqtt/dali2mqtt.py @@ -5,8 +5,10 @@ import logging import random import re +from threading import Thread import time import os +import concurrent.futures import paho.mqtt.client as mqtt @@ -66,6 +68,8 @@ logging.basicConfig(format=LOG_FORMAT, level=os.environ.get("LOGLEVEL", "INFO")) logger = logging.getLogger(__name__) +associated_lamp_update_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + def dali_scan(dali_driver): """Scan a maximum number of dali devices.""" @@ -92,10 +96,10 @@ def scan_groups(dali_driver, lamps): try: logging.debug("Search for groups for Lamp {}".format(lamp)) group1 = dali_driver.send( - gear.QueryGroupsZeroToSeven(address.Short(lamp)) + gear.QueryGroupsZeroToSeven(lamp.short_address) ).value.as_integer group2 = dali_driver.send( - gear.QueryGroupsEightToFifteen(address.Short(lamp)) + gear.QueryGroupsEightToFifteen(lamp.short_address) ).value.as_integer # logger.debug("Group 0-7: %d", group1) @@ -117,7 +121,7 @@ def scan_groups(dali_driver, lamps): groups[i + 8].append(lamp) lamp_groups.append(i + 8) - logger.debug("Lamp %d is in groups %s", lamp, lamp_groups) + logger.debug("Lamp %d is in groups %s", lamp.short_address.address, lamp_groups) except Exception as e: logger.warning("Can't get groups for lamp %s: %s", lamp, e) @@ -125,7 +129,7 @@ def scan_groups(dali_driver, lamps): return groups -def initialize_lamps(data_object, client): +def initialize_lamps(data_object, client:mqtt.Client): """Initialize all lamps and groups.""" driver = data_object["driver"] @@ -149,78 +153,88 @@ def create_mqtt_lamp(address, name): address, ) - data_object["all_lamps"][name] = lamp_object + device_name = lamp_object.device_name + data_object["all_lamps"][device_name] = lamp_object mqtt_data = [ ( - HA_DISCOVERY_PREFIX.format(ha_prefix, name), + HA_DISCOVERY_PREFIX.format(ha_prefix, device_name), lamp_object.gen_ha_config(mqtt_base_topic), True, ), ( - MQTT_BRIGHTNESS_STATE_TOPIC.format(mqtt_base_topic, name), + MQTT_BRIGHTNESS_STATE_TOPIC.format(mqtt_base_topic, device_name), lamp_object.level, - False, + True, ), ( - MQTT_BRIGHTNESS_MAX_LEVEL_TOPIC.format(mqtt_base_topic, name), + MQTT_BRIGHTNESS_MAX_LEVEL_TOPIC.format(mqtt_base_topic, device_name), lamp_object.max_level, True, ), ( - MQTT_BRIGHTNESS_MIN_LEVEL_TOPIC.format(mqtt_base_topic, name), + MQTT_BRIGHTNESS_MIN_LEVEL_TOPIC.format(mqtt_base_topic, device_name), lamp_object.min_level, True, ), ( MQTT_BRIGHTNESS_PHYSICAL_MINIMUM_LEVEL_TOPIC.format( - mqtt_base_topic, name + mqtt_base_topic, device_name ), lamp_object.min_physical_level, True, ), ( - MQTT_STATE_TOPIC.format(mqtt_base_topic, name), + MQTT_STATE_TOPIC.format(mqtt_base_topic, device_name), MQTT_PAYLOAD_ON if lamp_object.level > 0 else MQTT_PAYLOAD_OFF, - False, + True, ), ] for topic, payload, retain in mqtt_data: - client.publish(topic, payload, retain) + client.publish(topic = topic, payload = payload, retain=retain) logger.info(lamp_object) + return lamp_object except DALIError as err: logger.error("While initializing <%s> @ %s: %s", name, address, err) - + + lamp_objects = [] for lamp in lamps: short_address = address.Short(lamp) - create_mqtt_lamp( + lamp_objects.append(create_mqtt_lamp( short_address, devices_names_config.get_friendly_name(short_address.address), - ) + )) - groups = scan_groups(driver, lamps) - for group in groups: + groups = scan_groups(driver, lamp_objects) + for group,lamps in groups.items(): logger.debug("Publishing group %d", group) - group_address = address.Group(int(group)) + group_address = address.Group(int(group)) + name = f"group_{group}" + name = devices_names_config.get_friendly_name(name) + group_object = create_mqtt_lamp(group_address, name) + """Link all the lamps to the group object""" + group_object.associated_lamps = lamps + """Link the group to the lamp""" + for lamp_object in lamps: + lamp_object.add_associated_lamp(group_object) - create_mqtt_lamp(group_address, f"group_{group}") if devices_names_config.is_devices_file_empty(): devices_names_config.save_devices_names_file(data_object["all_lamps"]) logger.info("initialize_lamps finished") -def on_detect_changes_in_config(mqtt_client): +def on_detect_changes_in_config(mqtt_client:mqtt.Client): """Callback when changes are detected in the configuration file.""" logger.info("Reconnecting to server") mqtt_client.disconnect() -def on_message_cmd(mqtt_client, data_object, msg): +def on_message_cmd(mqtt_client:mqtt.Client, data_object, msg): """Callback on MQTT command message.""" logger.debug("Command on %s: %s", msg.topic, msg.payload) light = re.search( @@ -236,33 +250,27 @@ def on_message_cmd(mqtt_client, data_object, msg): MQTT_PAYLOAD_OFF, retain=True, ) + update_associated_lamps(mqtt_client, data_object, lamp_object) except DALIError as err: logger.error("Failed to set light <%s> to OFF: %s", light, err) except KeyError: logger.error("Lamp %s doesn't exists", light) -def on_message_reinitialize_lamps_cmd(mqtt_client, data_object, msg): +def on_message_reinitialize_lamps_cmd(mqtt_client:mqtt.Client, data_object, msg): """Callback on MQTT scan lamps command message.""" logger.debug("Reinitialize Command on %s", msg.topic) initialize_lamps(data_object, mqtt_client) def get_lamp_object(data_object, light): - """Retrieve lamp object from data object.""" - if "group_" in light: - """Check if the comand is for a dali group""" - group = int(re.search(r"group_(\d+)", light).group(1)) - lamp_object = data_object["all_lamps"][group] - else: - """The command is for a single lamp""" - if light not in data_object["all_lamps"]: - raise KeyError - lamp_object = data_object["all_lamps"][light] - return lamp_object - - -def on_message_brightness_cmd(mqtt_client, data_object, msg): + """Retrieve lamp/group object from data object.""" + if light not in data_object["all_lamps"]: + raise KeyError + return data_object["all_lamps"][light] + + +def on_message_brightness_cmd(mqtt_client:mqtt.Client, data_object, msg): """Callback on MQTT brightness command message.""" logger.debug("Brightness Command on %s: %s", msg.topic, msg.payload) light = re.search( @@ -273,22 +281,27 @@ def on_message_brightness_cmd(mqtt_client, data_object, msg): lamp_object = get_lamp_object(data_object, light) try: - lamp_object.level = int(msg.payload.decode("utf-8")) + new_level = int(msg.payload.decode("utf-8")) + if not lamp_object.level_change_needed(new_level): + return + + lamp_object.level = new_level if lamp_object.level == 0: # 0 in DALI is turn off with fade out lamp_object.off() logger.debug("Set light <%s> to OFF", light) mqtt_client.publish( - MQTT_STATE_TOPIC.format(data_object["base_topic"], light), - MQTT_PAYLOAD_ON if lamp_object.level != 0 else MQTT_PAYLOAD_OFF, - retain=False, + topic = MQTT_STATE_TOPIC.format(data_object["base_topic"], light), + payload = MQTT_PAYLOAD_ON if lamp_object.level != 0 else MQTT_PAYLOAD_OFF, + retain=True, ) mqtt_client.publish( - MQTT_BRIGHTNESS_STATE_TOPIC.format(data_object["base_topic"], light), - lamp_object.level, + topic = MQTT_BRIGHTNESS_STATE_TOPIC.format(data_object["base_topic"], light), + payload = lamp_object.level, retain=True, ) + update_associated_lamps(mqtt_client, data_object, lamp_object) except ValueError as err: logger.error( "Can't convert <%s> to integer %d..%d: %s", @@ -301,7 +314,7 @@ def on_message_brightness_cmd(mqtt_client, data_object, msg): logger.error("Lamp %s doesn't exists", light) -def on_message_brightness_get_cmd(mqtt_client, data_object, msg): +def on_message_brightness_get_cmd(mqtt_client:mqtt.Client, data_object, msg): """Callback on MQTT brightness get command message.""" logger.debug("Brightness Get Command on %s: %s", msg.topic, msg.payload) light = re.search( @@ -310,21 +323,47 @@ def on_message_brightness_get_cmd(mqtt_client, data_object, msg): ).group(1) try: lamp_object = get_lamp_object(data_object, light) + retrieve_actual_level(mqtt_client, data_object, lamp_object) + except KeyError: + logger.error("Lamp %s doesn't exists", light) + +def update_associated_lamps(mqtt_client:mqtt.Client, data_object, lamp_object): + if lamp_object.associated_lamps: + """Give 1 sec to complete the main action before evaluating the associations""" + time.sleep(1) + associated_lamp_update_executor.submit(execute_update_associated_lamps, mqtt_client, data_object, lamp_object) + +def execute_update_associated_lamps(mqtt_client:mqtt.Client, data_object, lamp_object): + if lamp_object.associated_lamps: + requested_lamps =[] + for assoc_lamp in lamp_object.associated_lamps: + if not assoc_lamp.device_name in requested_lamps: + retrieve_actual_level(mqtt_client, data_object, assoc_lamp) + requested_lamps.append(assoc_lamp.device_name) + if lamp_object.is_group(): + if assoc_lamp.associated_lamps: + for nested_lamp in assoc_lamp.associated_lamps: + if nested_lamp.device_name != lamp_object.device_name and not nested_lamp.device_name in requested_lamps: + retrieve_actual_level(mqtt_client, data_object, nested_lamp) + requested_lamps.append(nested_lamp.device_name) + +def retrieve_actual_level(mqtt_client:mqtt.Client, data_object, lamp_object): try: + light = lamp_object.device_name lamp_object.actual_level() logger.debug("Get light <%s> results in %d", light, lamp_object.level) mqtt_client.publish( - MQTT_BRIGHTNESS_STATE_TOPIC.format(data_object["base_topic"], light), - lamp_object.level, - retain=False, + topic= MQTT_BRIGHTNESS_STATE_TOPIC.format(data_object["base_topic"], light), + payload=lamp_object.level, + retain=True, ) mqtt_client.publish( - MQTT_STATE_TOPIC.format(data_object["base_topic"], light), - MQTT_PAYLOAD_ON if lamp_object.level != 0 else MQTT_PAYLOAD_OFF, - retain=False, + topic = MQTT_STATE_TOPIC.format(data_object["base_topic"], light), + payload = MQTT_PAYLOAD_ON if lamp_object.level != 0 else MQTT_PAYLOAD_OFF, + retain=True, ) except ValueError as err: @@ -334,18 +373,16 @@ def on_message_brightness_get_cmd(mqtt_client, data_object, msg): lamp_object.min_level, lamp_object.max_level, err, - ) - except KeyError: - logger.error("Lamp %s doesn't exists", light) + ) -def on_message(mqtt_client, data_object, msg): # pylint: disable=W0613 +def on_message(mqtt_client:mqtt.Client, data_object, msg): # pylint: disable=W0613 """Default callback on MQTT message.""" logger.error("Don't publish to %s", msg.topic) def on_connect( - client, + client:mqtt.Client, data_object, flags, result, @@ -362,7 +399,7 @@ def on_connect( ] ) client.publish( - MQTT_DALI2MQTT_STATUS.format(mqtt_base_topic), MQTT_AVAILABLE, retain=True + topic=MQTT_DALI2MQTT_STATUS.format(mqtt_base_topic), payload=MQTT_AVAILABLE, retain=True ) initialize_lamps(data_object, client) @@ -377,7 +414,7 @@ def create_mqtt_client( devices_names_config, ha_prefix, log_level, -): +) -> mqtt.Client: """Create MQTT client object, setup callbacks and connection to server.""" logger.debug("Connecting to %s:%s", mqtt_server, mqtt_port) mqttc = mqtt.Client( @@ -422,7 +459,7 @@ def create_mqtt_client( def main(args): """Main loop.""" - mqttc = None + mqttc : mqtt.Client = None config = Config(args, lambda: on_detect_changes_in_config(mqttc)) if config.log_color: diff --git a/dali2mqtt/lamp.py b/dali2mqtt/lamp.py index c8fd852..037e548 100644 --- a/dali2mqtt/lamp.py +++ b/dali2mqtt/lamp.py @@ -3,6 +3,7 @@ import logging import dali.gear.general as gear +import dali.address as address from dali2mqtt.consts import ( ALL_SUPPORTED_LOG_LEVELS, LOG_FORMAT, @@ -36,6 +37,7 @@ def __init__( self.driver = driver self.short_address = short_address self.friendly_name = friendly_name + self.associated_lamps = None self.device_name = slugify(friendly_name) @@ -49,7 +51,11 @@ def __init__( self.min_physical_level = None logger.warning("Set min_physical_level to None as %s failed: %s", _min_physical_level, err) self.min_level = driver.send(gear.QueryMinLevel(short_address)).value + if not isinstance(self.min_level, int): + self.min_level = self.min_physical_level if self.min_physical_level else 86 self.max_level = driver.send(gear.QueryMaxLevel(short_address)).value + if not isinstance(self.max_level, int): + self.max_level = 254 self.level = driver.send(gear.QueryActualLevel(short_address)).value def gen_ha_config(self, mqtt_base_topic): @@ -84,7 +90,11 @@ def gen_ha_config(self, mqtt_base_topic): def actual_level(self): """Retrieve actual level from ballast.""" - self.__level = self.driver.send(gear.QueryActualLevel(self.short_address)) + local_level = self.driver.send(gear.QueryActualLevel(self.short_address)).value + if isinstance(local_level,int): + self.__level = local_level + else: + self.__level = 0 @property def level(self): @@ -94,22 +104,56 @@ def level(self): @level.setter def level(self, value): """Commit level to ballast.""" - if not self.min_level <= value <= self.max_level and value != 0: - raise ValueError - self.__level = value - self.driver.send(gear.DAPC(self.short_address, self.level)) - logger.debug( - "Set lamp <%s> brightness level to %s", self.friendly_name, self.level - ) + if isinstance(value, int) and value != 0: + if isinstance(self.min_level, int) and value < self.min_level: + value = self.min_level + elif isinstance(self.max_level, int) and value > self.max_level: + value = self.max_level + + if isinstance(value, int): + self.__level = value + self.driver.send(gear.DAPC(self.short_address, self.level)) + logger.debug( + "Set lamp <%s> brightness level to %s", self.friendly_name, self.level + ) + else: + self.__level = 0 + + def level_change_needed(self, value): + if isinstance(value, int): + if value == 0: + return True + + if isinstance(self.min_level, int) and value < self.min_level: + value = self.min_level + elif isinstance(self.max_level, int) and value > self.max_level: + value = self.max_level + + current_level = self.level + return value != current_level + return False + + + def off(self): """Turn off ballast.""" self.driver.send(gear.Off(self.short_address)) + self.__level = 0 def __str__(self): """Serialize lamp information.""" + addr = self.short_address.address if hasattr(self.short_address,"address") else self.short_address return ( - f"{self.device_name} - address: {self.short_address.address}, " + f"{self.device_name} - address: {addr}, " f"actual brightness level: {self.level} (minimum: {self.min_level}, " f"max: {self.max_level}, physical minimum: {self.min_physical_level})" ) + + def is_group(self): + return isinstance(self.short_address, address.Group) + + def add_associated_lamp(self, assoc_lamp_object): + if self.associated_lamps is None: + self.associated_lamps = [] + self.associated_lamps.append(assoc_lamp_object)