From 3f08745544919d453ae106a6bf48c2c1568486a6 Mon Sep 17 00:00:00 2001
From: lilly
Date: Fri, 6 Nov 2026 15:18:03 +0100
Subject: [PATCH] implement door status synchronization towards club-assistant
---
api/src/dooris_api/__init__.py | 18 +++++
api/src/dooris_api/app.py | 17 ++++-
api/src/dooris_api/ccujack.py | 101 ++++++++++++++++-----------
api/src/dooris_api/home_assistant.py | 50 +++++++++++++
api/src/dooris_api/mqtt_client.py | 10 ++-
5 files changed, 150 insertions(+), 46 deletions(-)
create mode 100644 api/src/dooris_api/home_assistant.py
diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py
index e096e98..2140b6e 100644
--- a/api/src/dooris_api/__init__.py
+++ b/api/src/dooris_api/__init__.py
@@ -123,6 +123,24 @@ def main():
default=os.environ.get("DOORIS_MAIN_LOCK", None),
help="Name or ID of the door which will be considered the main door for spaceapi. If none is set, integration to spaceapi is disabled",
)
+ argp.add_argument(
+ "--home-assistant-mqtt",
+ required=False,
+ default=os.environ.get("DOORIS_HOME_ASSISTANT_MQTT", "club-assistant.ccchh.net:1883"),
+ help="$HOSTNAME:$PORT of an MQTT server connected to our Home-Assistant",
+ )
+ argp.add_argument(
+ "--home-assistant-mqtt-user",
+ required=False,
+ default=os.environ.get("DOORIS_HOME_ASSISTANT_MQTT_USER", "dooris"),
+ help="Username to authenticate towards our Home-Assistant MQTT server",
+ )
+ argp.add_argument(
+ "--home-assistant-mqtt-password",
+ required="DOORIS_HOME_ASSISTANT_MQTT_PASSWORD" not in os.environ,
+ default=os.environ.get("DOORIS_HOME_ASSISTANT_MQTT_PASSWORD", None),
+ help="Password to authenticate towards our Home-Assistant MQTT server",
+ )
args = argp.parse_args()
diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py
index b7eed10..a0d574b 100644
--- a/api/src/dooris_api/app.py
+++ b/api/src/dooris_api/app.py
@@ -15,6 +15,7 @@ from dooris_api import deps, models, exceptions, app_config
from dooris_api.ccujack import CCUJackClient
from dooris_api.keycloak import KeycloakClient
from dooris_api.spaceapi import SpaceApiClient
+from dooris_api.home_assistant import HomeAssistantClient
logger = logging.getLogger(__name__)
@@ -32,6 +33,14 @@ async def lifespan(app: FastAPI):
scope=app_cfg.openid_scope,
)
+ app.extra["home_assistant"] = HomeAssistantClient(
+ auth=BasicAuth(
+ app_cfg.home_assistant_mqtt_user, app_cfg.home_assistant_mqtt_password
+ ),
+ mqtt_conn=app_cfg.home_assistant_mqtt,
+ )
+ await app.extra["home_assistant"].start()
+
app.extra["spaceapi"] = SpaceApiClient(
base_uri=app_cfg.spaceapid_base_uri,
auth=BasicAuth(app_cfg.spaceapid_user, app_cfg.spaceapid_password),
@@ -43,6 +52,7 @@ async def lifespan(app: FastAPI):
mqtt_conn=app_cfg.ccujack_mqtt,
spaceapi_client=app.extra["spaceapi"],
main_lock=app_cfg.main_lock,
+ home_assistant=app.extra["home_assistant"],
)
await app.extra["ccujack"].start()
@@ -56,8 +66,11 @@ async def lifespan(app: FastAPI):
yield
- await app.extra["ccujack"].close_connections()
- await app.extra["keycloak"].stop()
+ await asyncio.gather(
+ app.extra["ccujack"].close_connections(),
+ app.extra["home_assistant"].close_connections(),
+ app.extra["keycloak"].stop(),
+ )
app = FastAPI(
diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py
index c3a4d89..f0dde7a 100644
--- a/api/src/dooris_api/ccujack.py
+++ b/api/src/dooris_api/ccujack.py
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
from dooris_api.mqtt_client import AsyncMqttClient
from dooris_api.spaceapi import SpaceApiClient
+from dooris_api.home_assistant import HomeAssistantClient
logger = logging.getLogger(__name__)
@@ -76,9 +77,18 @@ class CCUJackClient:
task_find_locks: asyncio.Task
data_updated: asyncio.Event
spaceapi_client: SpaceApiClient
+ home_assistant_client: HomeAssistantClient
main_lock: Optional[str]
- def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str, spaceapi_client: SpaceApiClient, main_lock: Optional[str]):
+ def __init__(
+ self,
+ base_uri: str,
+ auth: BasicAuth,
+ mqtt_conn: str,
+ spaceapi_client: SpaceApiClient,
+ main_lock: Optional[str],
+ home_assistant: HomeAssistantClient,
+ ):
self.http = ClientSession(
base_url=base_uri,
auth=auth,
@@ -92,13 +102,14 @@ class CCUJackClient:
self.task_find_locks = None
self.data_updated = asyncio.Event()
self.spaceapi_client = spaceapi_client
+ self.home_assistant_client = home_assistant
self.main_lock = main_lock
async def start(self):
self.task_process_messages = asyncio.get_running_loop().create_task(
self.process_mqt_messages(), name="process-mqtt-messages"
)
- self.task_cron= asyncio.get_running_loop().create_task(
+ self.task_cron = asyncio.get_running_loop().create_task(
self.cron(), name="ccujack-cron"
)
@@ -131,7 +142,7 @@ class CCUJackClient:
self.locks = new_locks
self.data_updated.set()
self.data_updated.clear()
-
+
# update active mqtt subscriptions based on newly discovered devices
mqtt_topics = set()
for i_lock, lock_channels in self.locks:
@@ -141,7 +152,7 @@ class CCUJackClient:
f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}"
)
await self.mqtt.update_subscriptions(mqtt_topics)
- await self._update_spaceapi()
+ await self._update_federated_status()
async def process_mqt_messages(self):
while True:
@@ -157,7 +168,7 @@ class CCUJackClient:
self.data_updated.set()
if param_name.endswith("/LOCK_STATE"):
- await self._update_spaceapi()
+ await self._update_federated_status()
except Exception as e:
logger.exception(f"could not process incoming mqtt message: {e}")
@@ -167,10 +178,11 @@ class CCUJackClient:
async def cron(self):
while True:
try:
-
logger.info("Running CCUJack cron")
if not self.mqtt.is_connected:
- logger.warning("MQTT client was discovered to be disconnected; reconnecting now")
+ logger.warning(
+ "MQTT client was discovered to be disconnected; reconnecting now"
+ )
await self.mqtt.connect()
await self.find_locks()
@@ -181,8 +193,7 @@ class CCUJackClient:
except Exception as e:
logger.exception(f"Error in CCUJack cron task: {e}")
finally:
- await asyncio.sleep(15 * 60) # 15 minutes
-
+ await asyncio.sleep(15 * 60) # 15 minutes
async def query_param_value(self, address: str) -> CCUValue:
if address in self.param_values:
@@ -196,45 +207,51 @@ class CCUJackClient:
logger.debug("Writing parameter value '%s' to '%s'", value, address)
await self.http.put(f"/device/{address}/~pv", json={"v": value})
- async def _update_spaceapi(self):
+ async def _update_federated_status(self):
"""
- Write the current lock state into spaceapid
+ Write the current lock states into other systems suchs as SpaceApi or Club-Assistant
"""
if not self.main_lock:
- logger.debug("No main lock is configured, skipping spaceapid update")
- return
+ logger.info("No main lock is configured, skipping spaceapid update")
- logger.info(f"Pushing current state of lock {self.main_lock} to spaceapid")
- for (i_lock, lock_channels) in self.locks:
- if i_lock.identifier.lower() == self.main_lock.lower() or i_lock.title.lower() == self.main_lock.lower():
- is_currently_open = False
+ for i_lock, lock_channels in self.locks:
+ # figure out the current lock state
+ binary_is_currently_open = False
+ detailed_status = "unknown"
+ for i_channel, params in lock_channels:
+ for i_param in params:
+ if i_param.id == "LOCK_STATE":
+ # do a proper lookup and not just directly access the cache because the method
+ # could have been triggered by something other than MQTT in which case the value
+ # would not necessarily be available yet
+ value = await self.query_param_value(
+ f"{i_lock.address}/{i_channel.index}/{i_param.id}"
+ )
+ match value.v:
+ case 0: # unknown
+ detailed_status = "unknown"
+ binary_is_currently_open = False
+ case 1: # locked
+ detailed_status = "locked"
+ binary_is_currently_open = False
+ case 2: # unlocked
+ detailed_status = "unlocked"
+ binary_is_currently_open = True
- # find the correct parameter and extract it into a usable variable
- for (i_channel, params) in lock_channels:
- for i_param in params:
- if i_param.id == "LOCK_STATE":
+ # post updates to home-assistant every time
+ await self.home_assistant_client.set_status(
+ i_lock.title.lower(), detailed_status
+ )
- # do a proper lookup and not just directly access the cache because the method
- # could have been triggered by something other than MQTT in which case the value
- # would not necessarily be available yet
- value = await self.query_param_value(
- f"{i_lock.address}/{i_channel.index}/{i_param.id}"
- )
- match value.v:
- case 0: # unknown
- is_currently_open = False
- case 1: # locked
- is_currently_open = False
- case 2: # unlocked
- is_currently_open = True
-
- # actually post the update
- await self.spaceapi_client.set_status(is_currently_open)
-
- break
-
- else:
- logger.error(f"Could not push current lock state of lock {self.main_lock} to spaceapid because no such lock is known")
+ # post update to spaceapi only if thie is the main lock
+ if self.main_lock and (
+ i_lock.identifier.lower() == self.main_lock.lower()
+ or i_lock.title.lower() == self.main_lock.lower()
+ ):
+ logger.info(
+ f"Pushing current state of lock {self.main_lock} to spaceapid"
+ )
+ await self.spaceapi_client.set_status(binary_is_currently_open)
async def _inspect_ccu_device(
self, device_ref: CCURef
diff --git a/api/src/dooris_api/home_assistant.py b/api/src/dooris_api/home_assistant.py
new file mode 100644
index 0000000..782927f
--- /dev/null
+++ b/api/src/dooris_api/home_assistant.py
@@ -0,0 +1,50 @@
+"""
+Integration into our clubs Home-Assistant for lock state synchonization
+"""
+
+import logging
+import asyncio
+from dooris_api.mqtt_client import AsyncMqttClient
+from aiohttp import BasicAuth
+
+
+logger = logging.getLogger(__name__)
+
+
+class HomeAssistantClient:
+ def __init__(self, auth: BasicAuth, mqtt_conn: str):
+ self.mqtt = AsyncMqttClient(mqtt_conn, auth.login, auth.password)
+
+ async def start(self):
+ self.task_cron = asyncio.get_running_loop().create_task(
+ self.cron(), name="home-assistant-cron"
+ )
+
+ async def set_status(self, lock_title: str, status: str):
+ logger.info(
+ f"Pushing status of lock '{lock_title}' -> '{status}' to Home-Assistant"
+ )
+ await self.mqtt.publish(
+ f"z9/dooris/{lock_title}/status", status.encode("UTF-8"), qos=1
+ )
+
+ async def cron(self):
+ while True:
+ try:
+ logger.info("Running Home-Assistant cron")
+ if not self.mqtt.is_connected:
+ logger.warning(
+ "MQTT client was discovered to be disconnected; reconnecting now"
+ )
+ await self.mqtt.connect()
+
+ except asyncio.CancelledError:
+ logger.info("Home-Assistant task cron stopped")
+ raise
+ except Exception as e:
+ logger.exception(f"Error in Home-Assistant cron task: {e}")
+ finally:
+ await asyncio.sleep(15 * 60) # 15 minutes
+
+ async def close_connections(self):
+ await self.mqtt.disconnect()
diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py
index 505588a..8b5bd92 100644
--- a/api/src/dooris_api/mqtt_client.py
+++ b/api/src/dooris_api/mqtt_client.py
@@ -143,7 +143,9 @@ class AsyncMqttClient:
if to_remove:
self.active_subscriptions.difference_update(to_remove)
if self.is_connected:
- logger.info(f"mqtt client unsubscribing from topics {','.join(to_remove)}")
+ logger.info(
+ f"mqtt client unsubscribing from topics {','.join(to_remove)}"
+ )
self.fut_unsubscribe = asyncio.get_running_loop().create_future()
self.client.unsubscribe(list(to_remove))
await self.fut_unsubscribe
@@ -170,7 +172,7 @@ class AsyncMqttClient:
async def disconnect(self):
if not self.is_connected:
return
-
+
logger.info("Disconnecting mqtt client from broker")
self.fut_disconnect = asyncio.get_running_loop().create_future()
self.client.disconnect()
@@ -179,3 +181,7 @@ class AsyncMqttClient:
@property
def is_connected(self) -> bool:
return self.client.is_connected()
+
+ async def publish(self, topic: str, msg: bytes, qos: int):
+ logger.debug(f"Publishing mqtt message on topic {topic}: {msg}")
+ self.client.publish(topic, msg, qos)