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")) +