From ecd6c2c48f9e5e099e9b1507341c4c889cfa163b Mon Sep 17 00:00:00 2001
From: lilly
Date: Tue, 11 Aug 2026 16:04:11 +0200
Subject: [PATCH] implement spaceapid integration into API
---
api/src/dooris_api/__init__.py | 33 +++++++++++++++++++++-
api/src/dooris_api/app.py | 8 ++++++
api/src/dooris_api/ccujack.py | 51 +++++++++++++++++++++++++++++++++-
api/src/dooris_api/deps.py | 10 +++++++
api/src/dooris_api/models.py | 1 +
api/src/dooris_api/spaceapi.py | 28 +++++++++++++++++++
6 files changed, 129 insertions(+), 2 deletions(-)
create mode 100644 api/src/dooris_api/spaceapi.py
diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py
index 10d1ff5..6c39f39 100644
--- a/api/src/dooris_api/__init__.py
+++ b/api/src/dooris_api/__init__.py
@@ -79,7 +79,11 @@ def main():
required=False,
nargs=1,
action="append",
- default=[i for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") if bool(i)],
+ default=[
+ i
+ for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",")
+ if bool(i)
+ ],
)
argp.add_argument(
"--kc-ssh-attr-group",
@@ -93,6 +97,33 @@ def main():
type=Path,
help="A file to which ssh authorized keys fetched from keycloak will be written",
)
+ argp.add_argument(
+ "--spaceapid-base-uri",
+ required=False,
+ default=os.environ.get(
+ "DOORIS_SPACEAPID_BASE_URI", "https://spaceapi.hamburg.ccc.de/"
+ ),
+ help="The URL under which a running spaceapid is reachable",
+ )
+ argp.add_argument(
+ "--spaceapid-user",
+ required=False,
+ default=os.environ.get("DOORIS_SPACEAPID_USER", "dooris"),
+ help="The username used to authenticate against spaceapid",
+ )
+ argp.add_argument(
+ "--spaceapid-password",
+ required="DOORIS_SPACEAPID_PASSWORD" not in os.environ,
+ default=os.environ.get("DOORIS_SPACEAPID_PASSWORD", None),
+ help="The password used to authenticate against spaceapid",
+ )
+ argp.add_argument(
+ "--main-lock",
+ required=False,
+ default=os.environ.get("DOORIS_MAIN_DOOR", 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",
+ )
+
args = argp.parse_args()
# setup logging
diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py
index 73cab3d..9be8164 100644
--- a/api/src/dooris_api/app.py
+++ b/api/src/dooris_api/app.py
@@ -14,6 +14,7 @@ from aiohttp import BasicAuth
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
logger = logging.getLogger(__name__)
@@ -31,10 +32,17 @@ async def lifespan(app: FastAPI):
scope=app_cfg.openid_scope,
)
+ app.extra["spaceapi"] = SpaceApiClient(
+ base_uri=app_cfg.spaceapid_base_uri,
+ auth=BasicAuth(app_cfg.spaceapid_user, app_cfg.spaceapid_password),
+ )
+
app.extra["ccujack"] = CCUJackClient(
base_uri=app_cfg.ccujack_url,
auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password),
mqtt_conn=app_cfg.ccujack_mqtt,
+ spaceapi_client=app.extra["spaceapi"],
+ main_lock=app_cfg.main_lock,
)
await app.extra["ccujack"].start()
diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py
index 6856128..c3a4d89 100644
--- a/api/src/dooris_api/ccujack.py
+++ b/api/src/dooris_api/ccujack.py
@@ -5,6 +5,7 @@ import asyncio
from pydantic import BaseModel, Field
from dooris_api.mqtt_client import AsyncMqttClient
+from dooris_api.spaceapi import SpaceApiClient
logger = logging.getLogger(__name__)
@@ -74,8 +75,10 @@ class CCUJackClient:
task_process_messages: asyncio.Task
task_find_locks: asyncio.Task
data_updated: asyncio.Event
+ spaceapi_client: SpaceApiClient
+ main_lock: Optional[str]
- def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str):
+ def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str, spaceapi_client: SpaceApiClient, main_lock: Optional[str]):
self.http = ClientSession(
base_url=base_uri,
auth=auth,
@@ -88,6 +91,8 @@ class CCUJackClient:
self.task_process_messages = None
self.task_find_locks = None
self.data_updated = asyncio.Event()
+ self.spaceapi_client = spaceapi_client
+ self.main_lock = main_lock
async def start(self):
self.task_process_messages = asyncio.get_running_loop().create_task(
@@ -136,6 +141,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()
async def process_mqt_messages(self):
while True:
@@ -150,6 +156,9 @@ class CCUJackClient:
self.param_values[param_name] = param_value
self.data_updated.set()
+ if param_name.endswith("/LOCK_STATE"):
+ await self._update_spaceapi()
+
except Exception as e:
logger.exception(f"could not process incoming mqtt message: {e}")
finally:
@@ -187,6 +196,46 @@ 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):
+ """
+ Write the current lock state into spaceapid
+ """
+ if not self.main_lock:
+ logger.debug("No main lock is configured, skipping spaceapid update")
+ return
+
+ 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
+
+ # 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":
+
+ # 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")
+
async def _inspect_ccu_device(
self, device_ref: CCURef
) -> Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInfo]]]]:
diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py
index 8f65df2..34550a3 100644
--- a/api/src/dooris_api/deps.py
+++ b/api/src/dooris_api/deps.py
@@ -9,6 +9,7 @@ from simple_openid_connect.client import OpenidClient
from dooris_api import app_config
from dooris_api import models, exceptions
from dooris_api.ccujack import CCUJackClient
+from dooris_api.spaceapi import SpaceApiClient
logger = logging.getLogger(__name__)
@@ -213,3 +214,12 @@ def get_ccujack(req: Request) -> CCUJackClient:
CCUJackClient = Annotated[CCUJackClient, Depends(get_ccujack)]
+
+
+
+def get_spaceapi_client(req: Request) -> SpaceApiClient:
+ return req.app.extra["spaceapi"]
+
+
+SpaceApiClient = Annotated[SpaceApiClient, Depends(get_spaceapi_client)]
+
diff --git a/api/src/dooris_api/models.py b/api/src/dooris_api/models.py
index bfb741c..ee95233 100644
--- a/api/src/dooris_api/models.py
+++ b/api/src/dooris_api/models.py
@@ -54,6 +54,7 @@ class LockStatus(BaseModel):
is_unreachable: bool
is_low_battery: bool
is_error_jammed: bool
+ is_main_lock: bool
lock_target_level: Literal["locked", "unlocked", "open"]
lock_state: Literal["unknown", "locked", "unlocked"]
activity_state: Literal["unknown", "locking", "unlocking", "stable"]
diff --git a/api/src/dooris_api/spaceapi.py b/api/src/dooris_api/spaceapi.py
new file mode 100644
index 0000000..72d7fa9
--- /dev/null
+++ b/api/src/dooris_api/spaceapi.py
@@ -0,0 +1,28 @@
+import logging
+from aiohttp import ClientSession, BasicAuth
+
+
+logger = logging.getLogger(__name__)
+
+
+class SpaceApiClient:
+ """
+ Client for interacting with spaceapid
+
+ ref: https://git.hamburg.ccc.de/CCCHH/spaceapid
+ """
+ base_uri: str
+ http: ClientSession
+
+ def __init__(self, base_uri: str, auth: BasicAuth):
+ self.base_uri = base_uri
+ self.http = ClientSession(
+ base_url=base_uri,
+ auth=auth,
+ raise_for_status=True,
+ )
+
+ async def set_status(self, is_open: bool):
+ logger.info(f"Setting club status to open={is_open} via spaceapid")
+ await self.http.put("/state/open", data=str(is_open).encode("ASCII"))
+