Compare commits

..

2 commits

Author SHA1 Message Date
d46d866705
app: fix syntax error in cat language
All checks were successful
Build Container / Build Container (push) Successful in 1m32s
2026-05-18 20:38:37 +02:00
401bea8927
app: add cat language
Some checks failed
Build Container / Build Container (push) Failing after 1m17s
2026-05-18 20:32:12 +02:00
11 changed files with 103 additions and 406 deletions

View file

@ -1,16 +1,3 @@
FROM docker.io/alpine:3.22 AS build-frontend
ENV PNPM_HOME=/usr/local/share/dooris/pnpm/
WORKDIR /usr/local/src/dooris/
RUN apk add --no-cache pnpm
ADD --link app/package.json app/pnpm-lock.yaml app/
RUN pnpm --dir=app/ install --frozen-lockfile --package-import-method=copy
ADD --link . /usr/local/src/dooris/
RUN pnpm --dir=app/ run build
FROM docker.io/alpine:3.22 AS base
ARG APP_UID=10000
@ -22,11 +9,12 @@ ENV UV_LINK_MODE=copy
ENV UV_CACHE_DIR=/var/cache/dooris/uv/
ENV UV_NO_MANAGED_PYTHON=true
ENV VIRTUAL_ENV=/usr/local/share/dooris/venv/
ENV PATH=$VIRTUAL_ENV/bin:$PATH
ENV PNPM_HOME=/usr/local/share/dooris/pnpm/
ENV PATH=$PNPM_HOME:$VIRTUAL_ENV/bin:$PATH
ENV DOORIS_SERVE_STATIC=/var/www/dooris/static/
WORKDIR /usr/local/src/dooris/
RUN apk add --no-cache uv python3
RUN apk add --no-cache uv python3 pnpm
RUN addgroup -g $APP_GID dooris &&\
adduser -h /usr/local/src/dooris -u $APP_UID -G dooris -D dooris &&\
mkdir -p /var/www/dooris/ /usr/local/share/dooris/ /usr/local/src/dooris/ /var/cache/dooris/ &&\
@ -37,14 +25,16 @@ RUN addgroup -g $APP_GID dooris &&\
FROM base AS deps
USER dooris
ADD --link --chown=dooris:dooris api/pyproject.toml api/uv.lock api/
ADD --link --chown=dooris:dooris app/package.json app/pnpm-lock.yaml app/
RUN uv venv $VIRTUAL_ENV &&\
uv sync --active --frozen --no-install-project --no-editable
RUN pnpm --dir=app/ install --frozen-lockfile --package-import-method=copy
FROM deps AS final
ADD --chown=dooris:dooris --link . /usr/local/src/dooris/
COPY --chown=dooris:dooris --from=build-frontend --link /usr/local/src/dooris/app/dist/ $DOORIS_SERVE_STATIC
RUN pnpm --dir=app/ run build --outDir=$DOORIS_SERVE_STATIC
RUN --mount=type=cache,uid=$APP_UID,gid=$APP_GID,target=$UV_CACHE_DIR \
uv sync --active --frozen

View file

@ -6,7 +6,6 @@ requires-python = ">=3.12"
dependencies = [
"aiohttp>=3.13.5",
"fastapi>=0.136.1",
"paho-mqtt>=2.1.0",
"simple-openid-connect>=2.4.0",
"uvicorn>=0.46.0",
]

View file

@ -1,6 +1,4 @@
import os
import sys
import logging
from contextvars import ContextVar
from argparse import ArgumentParser, Namespace
@ -54,12 +52,6 @@ def main():
default=os.environ.get("DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122"),
help="The URL under which a CCUJACK instance is hosted that actually operates the locks",
)
argp.add_argument(
"--ccujack-mqtt",
required=False,
default=os.environ.get("DOORIS_CCUJACK_MQTT", "hmdooris-ccu.ccchh.net:1883"),
help="The $HOSTNAME:$PORT of the CCUJack embedded MQTT server",
)
argp.add_argument(
"--ccujack-user",
required="DOORIS_CCUJACK_USER" not in os.environ,
@ -74,10 +66,6 @@ def main():
)
args = argp.parse_args()
# setup logging
logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] %(filename)s: %(message)s")
# setup app
app_config.set(args)
import uvicorn
from dooris_api.app import app
@ -86,7 +74,6 @@ def main():
from fastapi.staticfiles import StaticFiles
app.mount("/", StaticFiles(directory=args.serve_static, html=True), name="static")
# start webserver
config = uvicorn.Config(app, port=8000, log_level="debug")
server = uvicorn.Server(config)
server.run()

View file

@ -1,11 +1,11 @@
from typing import Optional, List, AsyncIterable
from typing import Optional, List
import logging
import secrets
import asyncio
import sys
import os
from datetime import datetime, UTC
from fastapi import FastAPI, Request, Response, status
from fastapi.responses import RedirectResponse
from fastapi.sse import EventSourceResponse
from contextlib import asynccontextmanager
from simple_openid_connect.client import OpenidClient
from simple_openid_connect.data import TokenSuccessResponse, RpInitiatedLogoutRequest
@ -22,6 +22,12 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
app_cfg = app_config.get()
root_logger = logging.getLogger("")
root_logger.setLevel(logging.INFO)
root_logger.addHandler(logging.StreamHandler(sys.stderr))
app_logger = logging.getLogger("dooris_api")
app_logger.setLevel(logging.DEBUG)
app.extra["oidc_client"] = OpenidClient.from_issuer_url(
url=app_cfg.openid_issuer,
authentication_redirect_uri=f"{app_cfg.base_url}/auth/login-callback",
@ -32,15 +38,12 @@ async def lifespan(app: FastAPI):
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,
auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password)
)
await app.extra["ccujack"].start()
await app.extra["ccujack"].find_locks()
yield
await app.extra["ccujack"].close_connections()
app = FastAPI(
title="Dooris",
@ -65,7 +68,9 @@ async def get_user_info(
) -> models.UserStatus:
return models.UserStatus(
is_authorized=current_user.may_operate_locks,
guaranteed_session_until=datetime.fromtimestamp(current_user.id_token.exp, UTC),
guaranteed_session_until=datetime.fromtimestamp(
current_user.id_token.exp, UTC
),
username=current_user.id_token.preferred_username,
ccchh_roles=current_user.ccchh_roles,
)
@ -114,9 +119,7 @@ async def login_init(
response_class=RedirectResponse,
status_code=302,
)
async def login_callback(
req: Request, resp: Response, oidc_client: deps.OpenidClient
) -> str:
async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient) -> str:
# check that the user is currently in an authenticating state
# these cookies are set by the login_init() view
if (
@ -234,19 +237,6 @@ async def list_locks(ccujack: deps.CCUJackClient) -> List[models.Lock]:
return result
@app.get(
"/api/locks/stream",
tags=["locks"],
responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}},
response_class=EventSourceResponse,
)
async def watch_locks(ccujack: deps.CCUJackClient) -> AsyncIterable[List[models.Lock]]:
while True:
yield await list_locks(ccujack)
await ccujack.data_updated.wait()
await asyncio.sleep(0.1) # debounce multiple mqtt parameter updates
@app.patch(
"/api/locks/{lock_id}",
tags=["locks"],

View file

@ -1,11 +1,9 @@
from typing import List, Tuple, Optional, Any, Dict
from typing import List, Tuple, Optional, Any
from aiohttp import ClientSession, BasicAuth, TCPConnector
import logging
import asyncio
from pydantic import BaseModel, Field
from dooris_api.mqtt_client import AsyncMqttClient
logger = logging.getLogger(__name__)
@ -70,50 +68,21 @@ LockData = List[Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInf
class CCUJackClient:
base_uri: str
locks: LockData
param_values: Dict[str, Any]
task_process_messages: asyncio.Task
task_find_locks: asyncio.Task
data_updated: asyncio.Event
def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str):
def __init__(self, base_uri: str, auth: BasicAuth):
self.http = ClientSession(
base_url=base_uri,
auth=auth,
raise_for_status=True,
connector=TCPConnector(ssl=False),
)
self.mqtt = AsyncMqttClient(mqtt_conn, auth.login, auth.password)
self.locks = None
self.param_values = dict()
self.task_process_messages = None
self.task_find_locks = None
self.data_updated = asyncio.Event()
async def start(self):
await self.mqtt.connect()
await self.find_locks()
self.task_cron= asyncio.get_running_loop().create_task(
self.cron(), name="ccujack-cron"
)
self.task_process_messages = asyncio.get_running_loop().create_task(
self.process_mqt_messages(), name="process-mqtt-messages"
)
async def close_connections(self):
await asyncio.gather(self.mqtt.disconnect(), self.http.close())
self.task_process_messages.cancel()
self.task_process_messages = None
self.task_cron.cancel()
self.task_cron = None
async def find_locks(self):
logger.debug("Inspecting lock devices present in CCUJack")
async with self.http.get("/device") as resp:
devices = CCUDeviceList.model_validate(await resp.json())
# inspect CCUJACK for locks
device_infos = await asyncio.gather(
*[
self._inspect_ccu_device(i)
@ -122,57 +91,9 @@ class CCUJackClient:
]
)
# save the result
new_locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK]
if new_locks != self.locks:
logger.info("Found new locks, updating state")
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:
for i_channel, channel_params in lock_channels:
for i_param in channel_params:
mqtt_topics.add(
f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}"
)
await self.mqtt.update_subscriptions(mqtt_topics)
async def process_mqt_messages(self):
while True:
try:
msg = await self.mqtt.messages.get()
param_name = msg.topic.removeprefix("device/status/")
param_value = CCUValue.model_validate_json(msg.payload)
logger.debug(
f"Got new value from MQTT for parameter {param_name}: {param_value}"
)
self.param_values[param_name] = param_value
self.data_updated.set()
except Exception as e:
logger.exception(f"could not process incoming mqtt message: {e}")
finally:
self.data_updated.clear()
async def cron(self):
while True:
try:
await asyncio.sleep(60 * 60) # 1 hour
logger.info("Running CCUJack cron")
await self.find_locks()
except Exception as e:
logger.exception(f"Error in CCUJack cron task: {e}")
async def query_param_value(self, address: str) -> CCUValue:
if address in self.param_values:
return self.param_values[address]
self.locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK]
async def query_param_value(self, address: str):
logger.debug("Querying parameter value from '%s'", address)
async with self.http.get(f"/device/{address}/~pv") as resp:
return CCUValue.model_validate(await resp.json())

View file

@ -4,7 +4,6 @@ from datetime import datetime, UTC, timedelta
from fastapi import Request, Depends, Response
from simple_openid_connect.data import TokenSuccessResponse
from simple_openid_connect.client import OpenidClient
from simple_openid_connect.exceptions import ValidationError
from dooris_api import models, exceptions
from dooris_api.ccujack import CCUJackClient
@ -25,19 +24,19 @@ async def get_current_user(
) -> Optional[models.CurrentUser]:
# easiest case: we still have an access token (which is the most fleeting component)
# everything else should still be valid so we can just use it
if all(i in req.cookies for i in ("access_token", "id_token")):
if all(i in req.cookies for i in ("access_token", "id_token", "auth_nonce")):
logger.debug(
"user is fully authenticated, returning current user from existing id_token"
)
id_token = oidc_client.decode_id_token(
req.cookies["id_token"], nonce=req.cookies.get("auth_nonce", None),
req.cookies["id_token"], nonce=req.cookies["auth_nonce"]
)
return models.CurrentUser(
id_token=id_token, raw_id_token=req.cookies["id_token"]
)
# if we have a refresh token, try to get new tokens
elif all(i in req.cookies for i in ("refresh_token",)):
elif all(i in req.cookies for i in ("refresh_token", "auth_nonce")):
logger.debug(
"user has been previously authenticated, trying to recover with refresh_token"
)
@ -45,7 +44,7 @@ async def get_current_user(
token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"])
if isinstance(token_resp, TokenSuccessResponse):
logger.debug("successfully got new tokens from refresh token")
persist_auth_state(oidc_client, resp, token_resp, auth_start_time, None)
persist_auth_state(oidc_client, resp, token_resp, auth_start_time, req.cookies["auth_nonce"])
# return the newly gotten info
id_token = oidc_client.decode_id_token(token_resp.id_token)

View file

@ -1,161 +0,0 @@
#
# This whole implementation is adapted from the upstream GitHub example
# https://github.com/eclipse-paho/paho.mqtt.python/blob/master/examples/loop_asyncio.py
#
from typing import Any, Set, Iterable
import logging
import asyncio
import socket
import paho.mqtt.client as mqtt
logger = logging.getLogger(__name__)
class AsyncLooper:
"""
Helper class to implement loopgin with asyncio for the underlying mqtt IO
"""
def __init__(self, loop: asyncio.AbstractEventLoop, client: mqtt.Client):
self.loop = loop
self.client = client
self.client.on_socket_open = self.on_socket_open
self.client.on_socket_close = self.on_socket_close
self.client.on_socket_register_write = self.on_socket_register_write
self.client.on_socket_unregister_write = self.on_socket_unregister_write
def on_socket_open(self, client, userdata, sock):
def cb():
client.loop_read()
self.loop.add_reader(sock, cb)
self.task_misc = self.loop.create_task(self.misc_loop())
def on_socket_close(self, client, userdata, sock):
self.loop.remove_reader(sock)
self.task_misc.cancel()
def on_socket_register_write(self, client, userdata, sock):
def cb():
client.loop_write()
self.loop.add_writer(sock, cb)
def on_socket_unregister_write(self, client, userdata, sock):
self.loop.remove_writer(sock)
async def misc_loop(self):
while self.client.loop_misc() == mqtt.MQTT_ERR_SUCCESS:
try:
await asyncio.sleep(1)
except asyncio.CancelledError:
break
class AsyncMqttClient:
loop: asyncio.AbstractEventLoop
connection_string: str
looper: AsyncLooper
client: mqtt.Client
active_subscriptions: Set[str]
messages: asyncio.Queue
def __init__(self, connection_string: str, username: str, password: str):
self.connection_string = connection_string
self.active_subscriptions = set()
self.messages = asyncio.Queue()
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="dooris")
self.client.username = username
self.client.password = password
self.client.on_connect = self.on_connect
self.client.on_connect_fail = self.on_connect_fail
self.client.on_message = self.on_message
self.client.on_disconnect = self.on_disconnect
self.client.on_subscribe = self.on_subscribe
self.client.on_unsubscribe = self.on_unsubscribe
self.client.on_disconnect = self.on_disconnect
def on_connect(
self,
client: mqtt.Client,
userdata: Any,
flags: mqtt.ConnectFlags,
reason_code,
properties: mqtt.Properties,
):
logger.debug(f"mqtt client connected with message '{reason_code}'")
self.fut_connected.set_result(None)
def on_connect_fail(self, client, userdata):
logger.error("mqtt client could not connect to broker")
self.fut_connected.set_exception(
Exception("mqtt client could not connect to broker")
)
def on_disconnect(
self,
client: mqtt.Client,
userdata: Any,
flags: mqtt.DisconnectFlags,
reason_code,
properties: mqtt.Properties,
):
logger.debug(f"mqtt client disconnected with message '{reason_code}'")
if self.fut_disconnect:
self.fut_disconnect.set_result(None)
def on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
self.messages.put_nowait(msg)
def on_subscribe(self, client, userdata, mid, reason_code, properties):
logger.debug(f"mqtt client subscribed to topics with message '{reason_code}'")
self.fut_subscribe.set_result(None)
def on_unsubscribe(self, client, userdata, mid, reason_code, properties):
logger.debug(
f"mqtt client unsubscribed from topics with message '{reason_code}'"
)
self.fut_unsubscribe.set_result(None)
async def update_subscriptions(self, topics: Iterable[str], qos: int = 1):
"""
Update MQTT subscriptions so that the client is subscribed to exactly the given list of topics
"""
to_add = topics.difference(self.active_subscriptions)
if to_add:
logger.info(f"mqtt client subscribing to topics {', '.join(to_add)}")
self.fut_subscribe = asyncio.get_running_loop().create_future()
self.client.subscribe([(i, qos) for i in to_add])
await self.fut_subscribe
self.active_subscriptions.update(to_add)
to_remove = self.active_subscriptions.difference(topics)
if 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
self.active_subscriptions.difference_update(to_remove)
async def connect(self):
server_host, server_port = self.connection_string.rsplit(":", maxsplit=1)
self.looper = AsyncLooper(asyncio.get_running_loop(), self.client)
logger.info("Connecting to mqtt server at %s:%s", server_host, server_port)
self.fut_connected = asyncio.get_running_loop().create_future()
self.client.connect(server_host, int(server_port))
self.client.socket().setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2048)
await self.fut_connected
async def disconnect(self):
logger.info("Disconnecting mqtt client from broker")
self.fut_disconnect = asyncio.get_running_loop().create_future()
self.client.disconnect()
await self.fut_disconnect

11
api/uv.lock generated
View file

@ -400,7 +400,6 @@ source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "fastapi" },
{ name = "paho-mqtt" },
{ name = "simple-openid-connect" },
{ name = "uvicorn" },
]
@ -414,7 +413,6 @@ dev = [
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.5" },
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "paho-mqtt", specifier = ">=2.1.0" },
{ name = "simple-openid-connect", specifier = ">=2.4.0" },
{ name = "uvicorn", specifier = ">=0.46.0" },
]
@ -736,15 +734,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/6c/d8a02ffb24876b5f51fbd781f479fc6525a518553a4196bd0433dae9ff8e/orderedmultidict-1.0.2-py2.py3-none-any.whl", hash = "sha256:ab5044c1dca4226ae4c28524cfc5cc4c939f0b49e978efa46a6ad6468049f79b", size = 11897, upload-time = "2025-11-18T08:00:41.44Z" },
]
[[package]]
name = "paho-mqtt"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
]
[[package]]
name = "parso"
version = "0.8.7"

View file

@ -89,23 +89,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/locks/stream": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Watch Locks */
get: operations["watch_locks_api_locks_stream_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/locks/{lock_id}": {
parameters: {
query?: never;
@ -152,7 +135,7 @@ export interface components {
* @description Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/)
* @enum {string}
*/
HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026,FORBIDDEN_TO_OPERATE" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND";
HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND";
/** Lock */
Lock: {
/** Name */
@ -195,17 +178,14 @@ export interface components {
};
/** UserStatus */
UserStatus: {
/** Is Logged In */
is_logged_in: boolean;
/** Is Authorized */
is_authorized: boolean;
/**
* Guaranteed Session Until
* Format: date-time
*/
guaranteed_session_until: string;
/** Guaranteed Session Until */
guaranteed_session_until: string | null;
/** Username */
username: string;
/** Ccchh Roles */
ccchh_roles: string[];
username: string | null;
};
/** ValidationError */
ValidationError: {
@ -352,35 +332,6 @@ export interface operations {
};
};
};
watch_locks_api_locks_stream_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": unknown;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": components["schemas"]["HttpProblemDetail"];
};
};
};
};
operate_lock_api_locks__lock_id__patch: {
parameters: {
query?: never;
@ -414,15 +365,6 @@ export interface operations {
"application/json": components["schemas"]["HttpProblemDetail"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HttpProblemDetail"];
};
};
/** @description Not Found */
404: {
headers: {

View file

@ -1,5 +1,5 @@
import {Fetcher} from "openapi-typescript-fetch"
import type {components, paths} from "../api/schema"
import type {paths} from "../api/schema"
import type {ui} from "../i18n/ui.ts"
const fetcher = Fetcher.for<paths>()
@ -29,7 +29,7 @@ declare global {
lang: keyof typeof ui;
doors: Array<DoorType>;
auth: AuthType;
doorAction: (action: "unlock" | "lock", doorId: string) => void;
doorAction: (action: 'unlock' | 'lock', doorId: string) => void;
}
}
@ -105,18 +105,18 @@ async function checkUser() {
if (e instanceof getUserInfo.Error) {
const error = e.getActualType()
if (error.status >= 500 && error.status < 600) {
if (error.status === 401) {
if (!auth.recentLogout)
auth.recentLogout = auth.authenticated // set recentLogout true, if user was logged in before
auth.authenticated = false
auth.authorized = false
auth.until = null
auth.username = ""
} else if (error.status >= 500 && error.status < 600) {
apiError.current = "serverError"
} else {
console.error("unknown error:", error)
}
if (!auth.recentLogout)
auth.recentLogout = auth.authenticated // set recentLogout true, if user was logged in before
auth.authenticated = false
auth.authorized = false
auth.until = null
auth.username = ""
}
} finally {
localStorage.setItem("auth", JSON.stringify(auth))
@ -125,24 +125,15 @@ async function checkUser() {
}
}
async function subscribeDoorEvents() {
async function fetchDoors() {
if (doors.length === 0) {
loading.doors = true
}
refresh()
const getDoors = fetcher.path("/api/locks/").method("get").create()
const evtSource = new EventSource("/api/locks/stream")
evtSource.onerror = () => {
if (!window.navigator.onLine) {
apiError.current = "networkError"
} else {
apiError.current = "serverError"
}
}
evtSource.onmessage = (event) => {
const doorInfo: Array<components["schemas"]["Lock"]> = JSON.parse(event.data)
try {
const {data: doorInfo} = await getDoors({})
apiError.current = null
while (doors.length) {
@ -173,6 +164,29 @@ async function subscribeDoorEvents() {
loading.doors = false
refresh()
} catch (e) {
// check which operation threw the exception
if (e instanceof getDoors.Error) {
const error = e.getActualType()
if (error.status === 401) {
console.log("unauthorized")
loading.doors = false
refresh()
} else if (error.status >= 500 && error.status < 600) {
apiError.current = "serverError"
clearInterval(doorsInterval)
} else {
console.error("unknown error:", error)
}
}
if (e instanceof Error) {
switch (e.name) {
case "TypeError":
apiError.current = "networkError"
}
}
}
}
@ -281,7 +295,7 @@ function refresh() {
loadAuthFromLocalStorage()
subscribeDoorEvents()
const doorsInterval = setInterval(fetchDoors, 250) // TODO: replace with SSE
checkUser()
document.addEventListener("loadeddata", () => {

View file

@ -96,4 +96,31 @@ export const ui = {
"chat.dooris": "dOwOris",
"chat.user": "my fren :3",
},
meow: {
"dooris": "meoowris 🐈️ <span class='opacity-50 text-xs'>(DOORIS)</span>",
"unauthorized.title": "rawwwwr 😾⛔️",
"unauthorized.description": `meow mreoow: <a class='underline' href='https://wiki.hamburg.ccc.de/club:prozesse:aufnahmeprozesse-fuer-berechtigungsgruppen'>🌐 meow</a>`,
"unauthenticated.title": "meow? 🫴🐈️",
"unauthenticated.description": `meow mreoow: <a class='underline' href='https://wiki.hamburg.ccc.de/club:prozesse:aufnahmeprozesse-fuer-berechtigungsgruppen'>🌐 meow</a>`,
"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