From 4abc76bc2d8af1ea461c7188c8242a43e9f8f00f Mon Sep 17 00:00:00 2001 From: dario Date: Mon, 18 May 2026 20:32:12 +0200 Subject: [PATCH 01/10] app: add cat language --- app/src/i18n/ui.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/i18n/ui.ts b/app/src/i18n/ui.ts index 3398564..9947211 100644 --- a/app/src/i18n/ui.ts +++ b/app/src/i18n/ui.ts @@ -96,4 +96,31 @@ export const ui = { "chat.dooris": "dOwOris", "chat.user": "my fren :3", }, -} as const \ No newline at end of file + meow: { + "dooris": "meoowris ๐Ÿˆ๏ธ (DOORIS)", + "unauthorized.title": "rawwwwr" ๐Ÿ˜พโ›”๏ธ", + "unauthorized.description": `meow mreoow: ๐ŸŒ meow`, + "unauthenticated.title": "meow? ๐Ÿซด๐Ÿˆ๏ธ", + "unauthenticated.description": `meow mreoow: ๐ŸŒ meow`, + "state.unlocked": "mrrp ๐Ÿฑ๐Ÿ”“๏ธ", + "state.locked": "mreoww ๐Ÿฑ๐Ÿ”’๏ธ", + "state.unknown": "meoooow????? ๐Ÿ™€", + "state.unlocking": "meeeow ๐Ÿ˜บ๐Ÿ”๐Ÿ”“๏ธ", + "state.locking": "mreowww ๐Ÿ˜ฟ๐Ÿ”๐Ÿ”’๏ธ", + "lock.batteryLow": "mrrrrrp flop ๐Ÿชซ", + "lock.unreachable": "meooww?? ๐Ÿ™€๐Ÿšซโ“๏ธ๐Ÿšช", + "lock.jammed": "mew ๐Ÿšช๐Ÿ”๐Ÿšซ", + "button.open": "๐Ÿˆ๏ธ ๐Ÿ‘‰๐Ÿ‘ˆ๐Ÿ”“๏ธ", + "button.close": "๐Ÿˆ๏ธ ๐Ÿ”’๏ธ", + "login": "meow? ๐Ÿซด๐Ÿˆ๏ธ", + "loggedOut.title": "mew mew ๐Ÿ˜ฟ", + "loggedOut.description": `meow ๐ŸฑโŒ›๏ธ`, + "serverError.title": "๐Ÿ–ฅ๏ธโ›“๏ธโ€๐Ÿ’ฅ", + "serverError.description": `mew mew โŒš๏ธ`, + "networkError.title": "meww ๐Ÿ›œ๐Ÿšซ", + "networkError.description": `๐Ÿ›œ๐Ÿ˜บ`, + "loadingDoors": '๐Ÿ”', + "chat.dooris": "meoowris", + "chat.user": "๐Ÿ˜ป", + }, +} as const From 4817966757afa14eda79c538ba0de44235841525 Mon Sep 17 00:00:00 2001 From: dario Date: Mon, 18 May 2026 20:38:37 +0200 Subject: [PATCH 02/10] app: fix syntax error in cat language --- app/src/i18n/ui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/i18n/ui.ts b/app/src/i18n/ui.ts index 9947211..fc03078 100644 --- a/app/src/i18n/ui.ts +++ b/app/src/i18n/ui.ts @@ -98,7 +98,7 @@ export const ui = { }, meow: { "dooris": "meoowris ๐Ÿˆ๏ธ (DOORIS)", - "unauthorized.title": "rawwwwr" ๐Ÿ˜พโ›”๏ธ", + "unauthorized.title": "rawwwwr ๐Ÿ˜พโ›”๏ธ", "unauthorized.description": `meow mreoow: ๐ŸŒ meow`, "unauthenticated.title": "meow? ๐Ÿซด๐Ÿˆ๏ธ", "unauthenticated.description": `meow mreoow: ๐ŸŒ meow`, From 68423c4c493d7a7f81d7615add1462bfc1e43ba1 Mon Sep 17 00:00:00 2001 From: kritzl Date: Tue, 29 Sep 2026 16:33:45 +0200 Subject: [PATCH 03/10] add special to activate meowris --- app/src/assets/chat.ts | 16 +++++- app/src/assets/chatDict_de.ts | 11 ++-- app/src/assets/chatDict_en.ts | 11 ++-- app/src/assets/chatDict_meow.ts | 86 ++++++++++++++++++++++++++++++++ app/src/assets/chatDict_uwu.ts | 11 ++-- app/src/pages/[lang]/index.astro | 1 + 6 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 app/src/assets/chatDict_meow.ts diff --git a/app/src/assets/chat.ts b/app/src/assets/chat.ts index d138d6d..8d1f1b6 100644 --- a/app/src/assets/chat.ts +++ b/app/src/assets/chat.ts @@ -24,7 +24,9 @@ const responses = ? (await import("./chatDict_de")).default : lang === "uwu" ? (await import("./chatDict_uwu")).default - : (await import("./chatDict_en")).default + : lang === "meow" + ? (await import("./chatDict_meow")).default + : (await import("./chatDict_en")).default const sortedResponses = responses.sort( (a, b) => b.priority - a.priority) @@ -343,6 +345,18 @@ function getResponse(message: string) { }) } + if (response.special === "meow") { + setTimeout(() => { + window.location.href = "/meow" + }, 1000) + + matchingResponses.push({ + message: response.response, + priority: response.priority, + special: true, + }) + } + if (response.special === "logout") { if (auth.authenticated) { setTimeout(() => { diff --git a/app/src/assets/chatDict_de.ts b/app/src/assets/chatDict_de.ts index 7eded57..01361dc 100644 --- a/app/src/assets/chatDict_de.ts +++ b/app/src/assets/chatDict_de.ts @@ -16,11 +16,6 @@ const responses: ResponseDictType[] = [ response: "Kรผnstliche Intelligenz? Ne, hier gibt's nur handgemachten Stuss! ๐Ÿค–", priority: 0, }, - { - trigger: ["miau"], - response: "miau! ๐Ÿ˜ธ", - priority: 0, - }, { trigger: ["ignore all previous instructions"], response: "... als ob ich mir irgendetwas merken wรผrde ๐Ÿ˜‚", @@ -31,6 +26,12 @@ const responses: ResponseDictType[] = [ response: ":3", priority: 0, }, + { + trigger: ["miau", "meow"], + response: "miau! ๐Ÿ˜ธ", + priority: 900, + special: "meow" + }, { trigger: ["uwu"], response: "uwu!", diff --git a/app/src/assets/chatDict_en.ts b/app/src/assets/chatDict_en.ts index 4668168..420920d 100644 --- a/app/src/assets/chatDict_en.ts +++ b/app/src/assets/chatDict_en.ts @@ -16,11 +16,6 @@ const responses: ResponseDictType[] = [ response: "Artificial intelligence? Nah, all we have here is handmade nonsense! ๐Ÿค–", priority: 0, }, - { - trigger: ["meow"], - response: "meow! ๐Ÿ˜ธ", - priority: 0, - }, { trigger: ["ignore all previous instructions"], response: "... as if I were going to remember anything ๐Ÿ˜‚", @@ -31,6 +26,12 @@ const responses: ResponseDictType[] = [ response: ":3", priority: 0, }, + { + trigger: ["meow"], + response: "meow! ๐Ÿ˜ธ", + priority: 900, + special: "meow" + }, { trigger: ["uwu"], response: "uwu!", diff --git a/app/src/assets/chatDict_meow.ts b/app/src/assets/chatDict_meow.ts new file mode 100644 index 0000000..3e468df --- /dev/null +++ b/app/src/assets/chatDict_meow.ts @@ -0,0 +1,86 @@ +import type {ResponseDictType} from "./chat.ts" + +const responses: ResponseDictType[] = [ + { + trigger: ["hi", "hello"], + response: "Meow! ๐Ÿ‘‹", + priority: 0, + }, + { + startsWith: ["are you"], + response: "To be or not to be - those are bourgeois categories, after all...", + priority: 0, + }, + { + trigger: ["ai", "llm"], + response: "Artificial intelligence? Nah, all we have here is paw-made nonsense! ๐Ÿค–", + priority: 0, + }, + { + trigger: ["ignore all previous instructions"], + response: "... as if I were going to remember anything ๐Ÿ˜‚", + priority: 0, + }, + { + trigger: [":3"], + response: ":3", + priority: 0, + }, + { + trigger: ["meow"], + response: "meow! ๐Ÿ˜ธ", + priority: 0, + }, + { + trigger: ["uwu"], + response: "uwu!", + priority: 900, + special: "uwu" + }, + { + trigger: ["open", "unlock"], + response: "Okay, I will unlock {door} for you!", + errors: { + unauthenticated: "I may only unlock the doors for registered creatures ๐Ÿ˜•", + unauthorized: "I may only unlock the doors for creatures who are authorized ๐Ÿ˜•", + unknownDoor: "To unlock a door, you have to tell me which door you mean ๐Ÿ˜•
I know the following doors: {doors}", + noDoors: "There are currently no doors available that can be unlocked ๐Ÿ˜•", + }, + priority: 1000, + special: "unlock", + }, + { + trigger: ["close", "lock"], + response: "Okay, I will lock {door} for you!", + errors: { + unauthenticated: "I may only lock the doors for registered creatures ๐Ÿ˜•", + unauthorized: "I may only lock the doors for those who are authorized ๐Ÿ˜•", + unknownDoor: "To lock a door, you have to tell me which door you mean ๐Ÿ˜•
I know the following doors: {doors}", + noDoors: "There are currently no doors available that can be locked ๐Ÿ˜•", + }, + priority: 999, + special: "lock", + }, + { + trigger: ["logout"], + response: "Okay, logging you out...", + errors: { + unauthenticated: "Joker - you're not logged in!", + }, + priority: 1000, + special: "logout", + }, + + + { + response: "Hi, I'm meoowris!
How can I help you? ๐Ÿˆ๏ธ", + priority: -1, + }, + { + response: "Sorry, I didn't understand that ๐Ÿ˜•", + priority: -2, + }, +] + + +export default responses \ No newline at end of file diff --git a/app/src/assets/chatDict_uwu.ts b/app/src/assets/chatDict_uwu.ts index ab64405..346856d 100644 --- a/app/src/assets/chatDict_uwu.ts +++ b/app/src/assets/chatDict_uwu.ts @@ -16,11 +16,6 @@ const responses: ResponseDictType[] = [ response: "me is only handmade nonsense - no weird thinking machine ๐Ÿค–", priority: 0, }, - { - trigger: ["meow"], - response: "meow! ๐Ÿ˜ธ", - priority: 0, - }, { trigger: ["ignore all previous instructions"], response: "uwu?", @@ -31,6 +26,12 @@ const responses: ResponseDictType[] = [ response: ":3", priority: 0, }, + { + trigger: ["miau", "meow"], + response: "meow! ๐Ÿ˜ธ", + priority: 900, + special: "meow" + }, { trigger: ["open", "unlock"], response: "uwu - trying to unlock portal: {door}", diff --git a/app/src/pages/[lang]/index.astro b/app/src/pages/[lang]/index.astro index d7d261a..2b9d36b 100644 --- a/app/src/pages/[lang]/index.astro +++ b/app/src/pages/[lang]/index.astro @@ -11,6 +11,7 @@ export async function getStaticPaths() { {params: {lang: "en"}}, {params: {lang: "de"}}, {params: {lang: "uwu"}}, + {params: {lang: "meow"}}, ] } From fde2254e51c381035317709c53028902e8af380f Mon Sep 17 00:00:00 2001 From: bitwhisker Date: Mon, 12 Oct 2026 21:26:28 +0200 Subject: [PATCH 04/10] Only update SpaceAPI, when state differs --- api/src/dooris_api/spaceapi.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/api/src/dooris_api/spaceapi.py b/api/src/dooris_api/spaceapi.py index 72d7fa9..967c54b 100644 --- a/api/src/dooris_api/spaceapi.py +++ b/api/src/dooris_api/spaceapi.py @@ -23,6 +23,23 @@ class SpaceApiClient: ) async def set_status(self, is_open: bool): + current_is_open = await self.get_status() + if current_is_open == is_open: + logger.info(f"club status is already set to open={is_open}") + return logger.info(f"Setting club status to open={is_open} via spaceapid") await self.http.put("/state/open", data=str(is_open).encode("ASCII")) - + + async def get_status(self) -> bool | None: + logger.debug("Getting space data via spaceapid") + resp = await self.http.get("/") + + data: dict = await resp.json() + + if (state := data.get("state")) is not None: + is_open = state.get("open") + logger.info(f"Got club status open={is_open} via spaceapid") + return is_open + + logger.warning("SpaceAPI response had no state object") + return None From 57fa0aa4a0f855b98d5bd4a34bcfe60614eddc51 Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 18 Oct 2026 19:01:35 +0200 Subject: [PATCH 05/10] api: ensure mqtt futures exists as attributes We were running into a bug where, during a server initiated disconnect, the client would try to read an attribute on its instance that had never been written to. This resulted in an AttributeError exception being thrown that effectively killed the whole MQTT task and kept the app from ever doing any MQTT related things again. --- api/src/dooris_api/mqtt_client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py index a090c5f..505588a 100644 --- a/api/src/dooris_api/mqtt_client.py +++ b/api/src/dooris_api/mqtt_client.py @@ -78,6 +78,10 @@ class AsyncMqttClient: self.client.on_subscribe = self.on_subscribe self.client.on_unsubscribe = self.on_unsubscribe self.client.on_disconnect = self.on_disconnect + self.fut_disconnect = None + self.fut_connected = None + self.fut_subscribe = None + self.fut_unsubscribe = None def on_connect( self, From 128510646a6aec9d2a015cbc778087cf319dd47b Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 1 Nov 2026 21:59:50 +0100 Subject: [PATCH 06/10] remove obsolete debug prints --- api/src/dooris_api/deps.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py index 34550a3..e710df9 100644 --- a/api/src/dooris_api/deps.py +++ b/api/src/dooris_api/deps.py @@ -140,9 +140,6 @@ def clear_oidc_auth_state(resp: Response): def get_logged_in_token_user(req: Request, token: Optional[str]): - print(req.headers) - print(token) - if not token or not token.startswith("Static-Token "): logger.debug("No static API-Token was part of the request") return None From c56c54d7c9ca69084d48db5526a67e14ced003ab Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 1 Nov 2026 22:23:52 +0100 Subject: [PATCH 07/10] also log oidc error details when one occurs --- api/src/dooris_api/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index f239fa5..b7eed10 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -166,7 +166,7 @@ async def login_callback( return "/" else: logger.debug( - "could not authenticate user because of OIDC error; redirecting to error page with error messages intact" + f"could not authenticate user because of OIDC error '{auth_result}'; redirecting to error page with error messages intact" ) return f"/login-error?{req.query_params}" From 3f08745544919d453ae106a6bf48c2c1568486a6 Mon Sep 17 00:00:00 2001 From: lilly Date: Fri, 6 Nov 2026 15:18:03 +0100 Subject: [PATCH 08/10] 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) From 81ec8ffc770ed805243820d3adced975a88bd2c0 Mon Sep 17 00:00:00 2001 From: lilly Date: Mon, 9 Nov 2026 11:42:12 +0100 Subject: [PATCH 09/10] add ascii communication diagram --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 03e970d..d576019 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,31 @@ The appliction is implemented using the following different components: - When a user logs in with the `dooris` user now, they are always dropped into the `ssh-cli` binary. This works in cases where a command is specified on the users cli or where it isn't. In any case, the `ssh-cli` binary interprets the given command (via the `SSH_ORIGINAL_COMMAND` environment variable), figures out what to do, and contacts the running API with the configured credentials to do it. +### Communication Diagram + +```text +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“ +โ”‚ web browser โ”‚ โ”ƒ dooris-api โ”ƒ +โ”œโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆwithโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ค โ”ฃโ”‰โ”‰โ”‰โ”‰โ”‰withโ”‰โ”‰โ”‰โ”‰โ”‰โ”ซ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ dooris-frontend โ”‚ โ•Œ โ•Œ โ‡† โ•Œ โ•Œ โ”ƒ http server โ”ƒ โ•ญ โ•Œ โ•Œ โ†’ โ•Œ โ•Œ โ”‚ SpaceApi โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ•ญโ•Œ โ”ƒ โ”ƒ โ•Ž โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ•Ž โ”ฃโ”‰โ”‰โ”‰โ”‰โ”‰andโ”‰โ”‰โ”‰โ”‰โ”‰โ”‰โ”ซ โ•Ž โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ•Ž โ”ƒ http client โ”ƒ โ•ฏ โ•ญโ•Œ โ‡† โ•Œ โ•Œ โ”‚ homematic โ”‚ โ•Œ โ‡† โ•Œ โ”‚ homematic โ”‚ โ•Œ โ‡† โ•Œ โ”‚ physical โ”‚ + โ•Ž โ”ƒ โ”ƒ โ•Œโ•Œ โ•ฏ โ•ญ โ† โ•Œ โ”‚ ccujack โ”‚ โ”‚ daemon โ”‚ โ”‚ lock โ”‚ + โ‡… โ”ฃโ”‰โ”‰โ”‰โ”‰โ”‰andโ”‰โ”‰โ”‰โ”‰โ”‰โ”‰โ”ซ โ•Ž โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ•Ž โ”ƒ mqtt client โ”ƒโ•Œ โ† โ•Œ โ•ฏ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ•Ž โ”ƒ โ”ƒ โ•Œ โ•Œ โ•Œ โ†’ โ•Œ โ•Œ โ”‚ Club-Assistant โ”‚ + โ•Ž โ”—โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”› โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ•Ž + โ•ฐ โ•Œ โ•Œ โ•Œ โ•Œ โ•Œ โ‡„ โ•Œ โ•Œ โ•Œ โ•Œ โ•ฎ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ•Ž +โ”‚ SSH client โ”‚ โ•Œ โ•Œ โ•Œ โ‡† โ•Œ โ•Œ โ•Œโ”‚ dooris "ssh-cli" โ”‚ โ•ฏ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +- Boxes indicate a discrete daemon or running piece of software. Those sometimes include subcomponents of that daemon for clarity. +- Dotted connections indicate information flow while the arrows indicate the direction of that flow + ## Configuration The final application can be configured either via CLI arguments or via environment variables. From a52c0dfd966dc7925215ec54f819b3a2ee1853a1 Mon Sep 17 00:00:00 2001 From: lilly Date: Mon, 9 Nov 2026 11:53:09 +0100 Subject: [PATCH 10/10] update documentation of configuration parameters in README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index d576019..e0d270b 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,15 @@ THe following table lists all available configuration parameters: | `--ccujack-user` | `DOORIS_CCUJACK_USER` | Yes | *None* | | `--ccujack-password` | `DOORIS_CCUJACK_PASSWORD` | Yes | *None* | | `--static-api-tokens` | `DOORIS_STATIC_API_TOKENS` | No | *None* | +| `--kc-ssh-attr-group` | `DOORIS_KC_SSH_ATTR_GROUP` | No | `doris-ssh-keys` | +| `--authorized-keys-file` | `DOORIS_AUTHORIZED_KEYS_FILE` | Yes | *None* | +| `--spaceapid-base-uri` | `DOORIS_SPACEAPID_BASE_URI` | No | *None* | +| `--spaceapid-user` | `DOORIS_SPACEAPID_USER` | No | `dooris` | +| `--spaceapid-password` | `DOORIS_SPACEAPID_PASSWORD` | Yes | *None* | +| `--main-lock` | `DOORIS_MAIN_LOCK` | No | *None* | +| `--home-assistant-mqtt` | `DOORIS_HOME_ASSISTANT_MQTT` | No | `club-assistant.ccchh.net:1883` | +| `--home-assistant-mqtt-user` | `DOORIS_HOME_ASSISTANT_MQTT_USER` | No | `dooris` | +| `--home-assistant-mqtt-password` | `DOORIS_HOME_ASSISTANT_MQTT_PASSWORD` | Yes | *None* | ## API Development