update api, operate doors

This commit is contained in:
kritzl 2026-05-14 16:55:53 +02:00
commit d19a26e128
Signed by: kritzl
SSH key fingerprint: SHA256:5BmINP9VjZWaUk5Z+2CTut1KFhwLtd0ZynMekKbtViM
4 changed files with 154 additions and 11 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
.envrc .envrc
.direnv/ .direnv/
.env
**/__pycache__ **/__pycache__
api/dist api/dist

View file

@ -55,6 +55,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/auth/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Logout */
get: operations["logout_auth_logout_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/locks/": { "/api/locks/": {
parameters: { parameters: {
query?: never; query?: never;
@ -72,6 +89,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/locks/{lock_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Operate Lock */
patch: operations["operate_lock_api_locks__lock_id__patch"];
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@ -101,13 +135,23 @@ export interface components {
* @description Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/) * @description Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/)
* @enum {string} * @enum {string}
*/ */
HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026:DOOR_NOT_FOUND"; HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND";
/** Lock */ /** Lock */
Lock: { Lock: {
/** Name */ /** Name */
name: string; name: string;
/** Id */
id: string;
status: components["schemas"]["LockStatus"]; status: components["schemas"]["LockStatus"];
}; };
/** LockOperation */
LockOperation: {
/**
* Desired State
* @enum {string}
*/
desired_state: "open" | "closed";
};
/** LockStatus */ /** LockStatus */
LockStatus: { LockStatus: {
/** Is Unreachable */ /** Is Unreachable */
@ -241,6 +285,24 @@ export interface operations {
}; };
}; };
}; };
logout_auth_logout_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
302: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
list_locks_api_locks__get: { list_locks_api_locks__get: {
parameters: { parameters: {
query?: never; query?: never;
@ -270,4 +332,57 @@ export interface operations {
}; };
}; };
}; };
operate_lock_api_locks__lock_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
lock_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LockOperation"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HttpProblemDetail"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HttpProblemDetail"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
} }

View file

@ -67,7 +67,7 @@ async function checkUser() {
const {data: userInfo} = await getUserInfo({}) const {data: userInfo} = await getUserInfo({})
apiError.current = null apiError.current = null
auth.authenticated = userInfo.is_logged_in auth.authenticated = true
auth.authorized = userInfo.is_authorized auth.authorized = userInfo.is_authorized
auth.until = userInfo.guaranteed_session_until ? new Date(userInfo.guaranteed_session_until) : null auth.until = userInfo.guaranteed_session_until ? new Date(userInfo.guaranteed_session_until) : null
auth.username = userInfo.username ?? "" auth.username = userInfo.username ?? ""
@ -98,7 +98,9 @@ async function checkUser() {
} }
async function fetchDoors() { async function fetchDoors() {
if (doors.length === 0) {
loading.doors = true loading.doors = true
}
refresh() refresh()
const getDoors = fetcher.path("/api/locks/").method("get").create() const getDoors = fetcher.path("/api/locks/").method("get").create()
@ -123,7 +125,7 @@ async function fetchDoors() {
} }
doors.push({ doors.push({
id: door.name, // TODO: replace by actual ID id: door.id,
label: door.name, label: door.name,
state: state, state: state,
batteryLow: door.status.is_low_battery, batteryLow: door.status.is_low_battery,
@ -157,9 +159,25 @@ async function fetchDoors() {
} }
} }
function setDoorInfo(doorElement: HTMLDivElement, door: DoorType) { async function doorAction(action: "unlock" | "lock", doorId: string) {
const operateDoors = fetcher.path("/api/locks/{lock_id}").method("patch").create()
const stateMap: Record<string, "open" | "closed"> = {
unlock: "open",
lock: "closed",
}
operateDoors({
lock_id: doorId,
desired_state: stateMap[action],
})
}
function setDoorInfo(doorElement: HTMLDivElement, door: DoorType, initial: boolean = false) {
const labelElement: HTMLDivElement = doorElement.querySelector("[data-label]")! const labelElement: HTMLDivElement = doorElement.querySelector("[data-label]")!
const buttonElements: Array<HTMLButtonElement> = Array.from(doorElement.querySelectorAll("button")) const lockButton: HTMLButtonElement = doorElement.querySelector("[data-button='lock']")!
const unlockButton: HTMLButtonElement = doorElement.querySelector("[data-button='unlock']")!
const buttonElements: Array<HTMLButtonElement> = [lockButton, unlockButton]
doorElement.dataset.id = door.id doorElement.dataset.id = door.id
doorElement.dataset.state = door.state doorElement.dataset.state = door.state
@ -190,6 +208,16 @@ function setDoorInfo(doorElement: HTMLDivElement, door: DoorType) {
labelElement.innerHTML = door.label labelElement.innerHTML = door.label
if (initial) {
lockButton.addEventListener("click", () => {
doorAction("lock", door.id)
})
unlockButton.addEventListener("click", () => {
doorAction("unlock", door.id)
})
}
buttonElements.forEach(button => { buttonElements.forEach(button => {
button.disabled = ["unlocking", "locking"].includes(door.state) || apiError.current !== null button.disabled = ["unlocking", "locking"].includes(door.state) || apiError.current !== null
}) })
@ -205,7 +233,7 @@ function refresh() {
const targetElement = document.importNode(template.content, true) const targetElement = document.importNode(template.content, true)
doorElement = targetElement.querySelector("[data-state]")! doorElement = targetElement.querySelector("[data-state]")!
setDoorInfo(doorElement, door) setDoorInfo(doorElement, door, true)
list.appendChild(targetElement) list.appendChild(targetElement)
} }
@ -233,9 +261,8 @@ function refresh() {
} }
loadAuthFromLocalStorage() loadAuthFromLocalStorage()
fetchDoors() setInterval(fetchDoors, 250) // TODO: replace with SSE
checkUser() checkUser()
document.addEventListener("loadeddata", () => { document.addEventListener("loadeddata", () => {

View file

@ -44,10 +44,10 @@ const t = useTranslations(lang)
</div> </div>
</div> </div>
<div class="hidden group-data-active:grid grid-cols-2 w-full items-center gap-2 mt-4"> <div class="hidden group-data-active:grid grid-cols-2 w-full items-center gap-2 mt-4">
<button class="btn btn-outline btn-error"> <button class="btn btn-outline btn-error" data-button="lock">
{t("button.close")} {t("button.close")}
</button> </button>
<button class="btn btn-outline btn-success"> <button class="btn btn-outline btn-success" data-button="unlock">
{t("button.open")} {t("button.open")}
</button> </button>
</div> </div>