From fc5a281e85724d8dac9baafb2ede569d567f2682 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Mon, 12 Jan 2026 15:25:34 +0000 Subject: [PATCH] Upload --- box/__pycache__/api.cpython-312.pyc | Bin 0 -> 3931 bytes box/api.py | 141 ++ box/api/__init__.py | 0 box/api/http.py | 127 ++ box/audio/__init__.py | 0 box/audio/base.py | 16 + box/audio/mock.py | 19 + box/config/box_config.json | 10 +- .../box_controller.cpython-312.pyc | Bin 9352 -> 17776 bytes box/core/__pycache__/state.cpython-312.pyc | Bin 1540 -> 1648 bytes box/core/box_controller.py | 151 +- box/core/controller.py | 7 + box/core/identity.py | 15 + box/core/lifecycle.py | 52 + box/core/state.py | 3 + box/data/.keep | 0 box/data/media/.keep | 0 box/data/media/book_1/01.mp3 | 0 box/data/media/book_1/02.mp3 | 0 box/data/media/book_1/03.mp3 | 0 box/data/media/book_2/01.mp3 | 0 box/data/media/book_2/02.mp3 | 0 box/data/media/book_2/03.mp3 | 0 box/data/media/book_3/01.mp3 | 0 box/data/media/book_3/02.mp3 | 0 box/data/media/book_3/03.mp3 | 0 box/data/media/book_4/01.mp3 | 0 box/data/media/book_4/02.mp3 | 0 box/data/media/book_4/03.mp3 | 0 box/data/media/book_5/01.mp3 | 0 box/data/media/book_5/02.mp3 | 0 box/data/media/book_5/03.mp3 | 0 box/data/media/book_6/CD1/Titel A.mp3 | 0 box/data/media/book_6/CD1/Titel B.mp3 | 0 box/data/media/book_6/CD2/Titel A.mp3 | 0 box/data/media/book_6/CD2/Titel B.mp3 | 0 box/main.py | 243 ++- box/media/book_6/CD1/Titel A.mp3 | 0 box/media/book_6/CD1/Titel B.mp3 | 0 box/media/book_6/CD2/Titel A.mp3 | 0 box/media/book_6/CD2/Titel B.mp3 | 0 box/requirements.txt | 4 + box/schemas/box_config.schema.json | 3 + box/setup/__init__.py | 0 box/setup/webui.py | 71 + box/spotify/__init__.py | 0 box/spotify/base.py | 23 + box/spotify/manager.py | 54 + box/spotify/mock.py | 55 + box/storage/box_storage.py | 52 + box/storage/box_store.py | 34 + box/storage/secret_store.py | 57 + box/storage/secrets_store.py | 57 + box/storage/state_store.py | 24 + box/wifi/__init__.py | 0 box/wifi/base.py | 45 + box/wifi/manager.py | 115 ++ box/wifi/mock.py | 109 ++ dev.sh | 34 + docs/README.md | 56 + docs/api.md | 114 ++ docs/box-lifecycle.md | 42 + docs/cli.md | 68 +- docs/dev-workflow.md | 61 + docs/gui-control-panel.md | 53 + docs/offline-scenarios.md | 57 + docs/playback-testing.md | 65 + docs/security-storage.md | 60 + docs/setup-webgui.md | 40 + docs/storage.md | 13 + docs/virtual-box.md | 22 + gui/.gitignore | 24 + gui/.vscode/extensions.json | 3 + gui/README.md | 5 + gui/index.html | 13 + gui/package-lock.json | 1337 +++++++++++++++++ gui/package.json | 18 + gui/public/vite.svg | 1 + gui/src/App.vue | 82 + gui/src/api.js | 24 + gui/src/assets/vue.svg | 1 + gui/src/components/HelloWorld.vue | 43 + gui/src/main.js | 5 + gui/src/style.css | 79 + gui/vite.config.js | 14 + 85 files changed, 3748 insertions(+), 73 deletions(-) create mode 100644 box/__pycache__/api.cpython-312.pyc create mode 100644 box/api.py create mode 100644 box/api/__init__.py create mode 100644 box/api/http.py create mode 100644 box/audio/__init__.py create mode 100644 box/audio/base.py create mode 100644 box/audio/mock.py create mode 100644 box/core/controller.py create mode 100644 box/core/identity.py create mode 100644 box/core/lifecycle.py create mode 100644 box/data/.keep create mode 100644 box/data/media/.keep create mode 100644 box/data/media/book_1/01.mp3 create mode 100644 box/data/media/book_1/02.mp3 create mode 100644 box/data/media/book_1/03.mp3 create mode 100644 box/data/media/book_2/01.mp3 create mode 100644 box/data/media/book_2/02.mp3 create mode 100644 box/data/media/book_2/03.mp3 create mode 100644 box/data/media/book_3/01.mp3 create mode 100644 box/data/media/book_3/02.mp3 create mode 100644 box/data/media/book_3/03.mp3 create mode 100644 box/data/media/book_4/01.mp3 create mode 100644 box/data/media/book_4/02.mp3 create mode 100644 box/data/media/book_4/03.mp3 create mode 100644 box/data/media/book_5/01.mp3 create mode 100644 box/data/media/book_5/02.mp3 create mode 100644 box/data/media/book_5/03.mp3 create mode 100644 box/data/media/book_6/CD1/Titel A.mp3 create mode 100644 box/data/media/book_6/CD1/Titel B.mp3 create mode 100644 box/data/media/book_6/CD2/Titel A.mp3 create mode 100644 box/data/media/book_6/CD2/Titel B.mp3 create mode 100644 box/media/book_6/CD1/Titel A.mp3 create mode 100644 box/media/book_6/CD1/Titel B.mp3 create mode 100644 box/media/book_6/CD2/Titel A.mp3 create mode 100644 box/media/book_6/CD2/Titel B.mp3 create mode 100644 box/requirements.txt create mode 100644 box/setup/__init__.py create mode 100644 box/setup/webui.py create mode 100644 box/spotify/__init__.py create mode 100644 box/spotify/base.py create mode 100644 box/spotify/manager.py create mode 100644 box/spotify/mock.py create mode 100644 box/storage/box_storage.py create mode 100644 box/storage/box_store.py create mode 100644 box/storage/secret_store.py create mode 100644 box/storage/secrets_store.py create mode 100644 box/storage/state_store.py create mode 100644 box/wifi/__init__.py create mode 100644 box/wifi/base.py create mode 100644 box/wifi/manager.py create mode 100644 box/wifi/mock.py create mode 100755 dev.sh create mode 100644 docs/README.md create mode 100644 docs/api.md create mode 100644 docs/box-lifecycle.md create mode 100644 docs/dev-workflow.md create mode 100644 docs/gui-control-panel.md create mode 100644 docs/offline-scenarios.md create mode 100644 docs/playback-testing.md create mode 100644 docs/security-storage.md create mode 100644 docs/setup-webgui.md create mode 100644 docs/storage.md create mode 100644 docs/virtual-box.md create mode 100644 gui/.gitignore create mode 100644 gui/.vscode/extensions.json create mode 100644 gui/README.md create mode 100644 gui/index.html create mode 100644 gui/package-lock.json create mode 100644 gui/package.json create mode 100644 gui/public/vite.svg create mode 100644 gui/src/App.vue create mode 100644 gui/src/api.js create mode 100644 gui/src/assets/vue.svg create mode 100644 gui/src/components/HelloWorld.vue create mode 100644 gui/src/main.js create mode 100644 gui/src/style.css create mode 100644 gui/vite.config.js diff --git a/box/__pycache__/api.cpython-312.pyc b/box/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9e0061a3f95ecb7db2326f877296e9efdea1109 GIT binary patch literal 3931 zcmcgvOKcm*8J=D4@}*spmLgeCdnG@Yc15E>i^Oo!+Jc{nqW;@(^M~;Ixr&j8J(er@}v28aq-B4!c zvOSYg3&v<+mI#3GKM5e5E%5mTs*0(fzg^uXEgmoZbe)q(Pk_-_pn?_#0(8MV{T&cD z(M@ID$Ds%HJ#eIiat!8f z2#vi$Felc;1{CHSbD||wVsb1Fx6I<^*|SKFO?XUN4tXLNk9}RSi7{zdPSDNk?iCw1 zx>s}+>%^IhWE?QQqVp~Lpl;Sh_3{l6AAweGF;jskE&mH>9=(V^)vDt<9*_-vHJ1P` zy@R#}+-MsMy1=D!wKd?<`)_N&rN+?KfJ=?4tpT^QjRjrI{5l3YWhdmA$5R#QUR>2& z=w*Hp8plkJpl?om+PE1nqak=L=Ik`aKA9Tb-e27(R9E;>ei zTV`v^#2iO%Iy6!lI#3=uP#qku3}(uMndVUM&?kwH z6V=|KO7Cd7ceL6wux#Ej8{>cPil^e6C?1RZ&N>&()FX@5@JJAXT8nR)pMyt!#4f+Y z0v8y#)fZUd4C6pDFwg?)))NN3>3QZJ!rjWlZk{MIL^MA2+ZIA-CbP z^C7okr4Q;7IHD_p+h>oEe>Zk=JMS~*83uE0?4bzMG zGjlmFPNT9?P>YW4ah5*gdR&3%*ZhF16boJy0z2nku3hj%7jopJPHHjO9uT4d97a zpmKt&P(Ng%wVTV4S1Be=MOIt_X$6h~g4gL-EnmR>J zb@}y~mdCz$B;&8{CmMyGj_jk11P~5XrV7y=NJkEpQ=)CLmL2@_%?-RLHPkPVF5}#T ztD?Cf(fv#NmR$-}JNuS1cQR|8dsfbrB9Eiul6Y6UdwxZ}fBgRItBD`1M}PRFBeAsi z_KzrX-<^GT^+%mMOJk3_hVE*gzPjGE=W)-D&qJ$+#@Bl$R#OvIp{FA3EDJkV&VC`t zP}nVhq=4T!mbtV3n5MCzP%<&{))q8VR%E>L3zh+U-}NL6Sox~C6xtfLl~hG zPpygDeBMY|7}*eZ28$*8D#`8TDfxc7l0H~YAFQO0meWT!QpX^l zE+_9KH&VlN=IJ$YN3~D-WZ>h0m7hH7+h00GRWkK($9mUjT_qFiJ*QVwr~N&Bm1H-B z1ApnsP2|>j`_EUggeAA@*{`+7;welj~ zv>h@ILo{1BSK;%oMf5nni}4WV%FCZ#q9>EMMAE|jJo@qPbb_wa*suz!&cU0+*J91 FastAPI: + app = FastAPI() + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.get("/status") + def status() -> dict[str, Any]: + state = controller.state + return { + "box_id": state.box_id, + "network_status": wifi_manager.network_status, + "connected_ssid": wifi_manager.connected_ssid, + "wifi_profiles_count": wifi_manager.profiles_count, + "spotify_status": spotify_manager.status, + "server_status": _server_status(storage), + "playback_status": { + "state": state.state.value, + "active_uid": state.active_uid, + "playback_root": state.playback_path, + "current_file": state.current_media, + "file_index": state.file_index, + "position": state.position, + "duration": state.current_duration, + "volume": state.volume, + "last_error": state.last_error, + }, + } + + @app.post("/command") + def command(request: CommandRequest) -> dict[str, Any]: + cmd = request.command + payload = request.payload or {} + print(f"API command: {cmd} payload={payload}") + + if cmd == "nfc_on": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.handle_nfc_on(uid) + elif cmd == "nfc_off": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.handle_nfc_off(uid) + elif cmd == "play_pause": + controller.play_pause() + elif cmd == "next": + controller.next_track() + elif cmd == "prev": + controller.prev_track() + elif cmd == "vol_up": + controller.volume_up() + elif cmd == "vol_down": + controller.volume_down() + elif cmd == "stop": + if controller.state.active_uid: + controller.handle_nfc_off(controller.state.active_uid) + else: + controller.stop() + elif cmd == "trigger_error": + error_type = payload.get("type") + if not isinstance(error_type, str): + raise HTTPException(status_code=400, detail="type is required") + controller.trigger_error(error_type) + elif cmd == "wifi_add_profile": + ssid = payload.get("ssid") + password = payload.get("password") + priority = payload.get("priority", 0) + if not isinstance(ssid, str) or not isinstance(password, str): + raise HTTPException(status_code=400, detail="ssid and password required") + if not isinstance(priority, int): + priority = 0 + wifi_manager.add_profile(ssid, password, priority) + elif cmd == "wifi_remove_profile": + ssid = payload.get("ssid") + if not isinstance(ssid, str): + raise HTTPException(status_code=400, detail="ssid is required") + wifi_manager.remove_profile(ssid) + elif cmd == "wifi_reset": + wifi_manager.reset() + elif cmd == "spotify_set_tokens": + access_token = payload.get("access_token") + refresh_token = payload.get("refresh_token") + expires_at = payload.get("expires_at") + account_id = payload.get("account_id") + if not isinstance(access_token, str) or not isinstance(refresh_token, str): + raise HTTPException(status_code=400, detail="tokens are required") + if not isinstance(expires_at, int): + raise HTTPException(status_code=400, detail="expires_at is required") + if account_id is not None and not isinstance(account_id, str): + raise HTTPException(status_code=400, detail="account_id must be string") + spotify_manager.set_tokens(access_token, refresh_token, expires_at, account_id) + elif cmd == "spotify_clear": + spotify_manager.clear() + else: + raise HTTPException(status_code=400, detail="unknown command") + + persist_resume() + return {"ok": True} + + return app + + +def _server_status(storage: BoxStorage) -> str: + box_meta = storage.load_box_meta() + if box_meta.get("server_reachable") is False: + return "UNREACHABLE" + if box_meta.get("server_reachable") is True: + return "OK" + return "UNKNOWN" diff --git a/box/api/__init__.py b/box/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/box/api/http.py b/box/api/http.py new file mode 100644 index 0000000..512af00 --- /dev/null +++ b/box/api/http.py @@ -0,0 +1,127 @@ +"""HTTP API (FastAPI) for the Box.""" + +from __future__ import annotations + +from typing import Any, Callable + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from core.box_controller import BoxController +from core.lifecycle import NetworkLifecycle +from spotify.base import SpotifyBackend +from wifi.base import WifiBackend + + +class CommandRequest(BaseModel): + command: str + payload: dict[str, Any] = {} + + +def create_app( + controller: BoxController, + lifecycle: NetworkLifecycle, + wifi_backend: WifiBackend, + spotify_backend: SpotifyBackend, + persist_state: Callable[[], None], +) -> FastAPI: + app = FastAPI() + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.get("/status") + def status() -> dict[str, Any]: + snapshot = lifecycle.snapshot() + state = controller.state + return { + "box_id": state.box_id, + "wifi_state": snapshot.wifi_state.value, + "ip_address": snapshot.ip_address, + "connected_ssid": snapshot.connected_ssid, + "wifi_profiles_count": snapshot.wifi_profiles_count, + "spotify_status": spotify_backend.status, + "playback_state": { + "state": state.state.value, + "active_uid": state.active_uid, + "playback_root": state.playback_path, + "current_file": state.current_media, + "file_index": state.file_index, + "position": state.position, + "duration": state.current_duration, + "volume": state.volume, + "last_error": state.last_error, + }, + } + + @app.post("/command") + def command(request: CommandRequest) -> dict[str, Any]: + cmd = request.command + payload = request.payload or {} + + if cmd == "nfc_on": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.handle_nfc_on(uid) + elif cmd == "nfc_off": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.handle_nfc_off(uid) + elif cmd == "play_pause": + controller.play_pause() + elif cmd == "next": + controller.next_track() + elif cmd == "prev": + controller.prev_track() + elif cmd in {"vol_up", "volume_up"}: + controller.volume_up() + elif cmd in {"vol_down", "volume_down"}: + controller.volume_down() + elif cmd == "stop": + if controller.state.active_uid: + controller.handle_nfc_off(controller.state.active_uid) + else: + controller.stop() + elif cmd == "trigger_error": + error_type = payload.get("type") + if not isinstance(error_type, str): + raise HTTPException(status_code=400, detail="type is required") + controller.trigger_error(error_type) + elif cmd == "wifi_add_profile": + ssid = payload.get("ssid") + password = payload.get("password") + priority = payload.get("priority", 0) + if not isinstance(ssid, str) or not isinstance(password, str): + raise HTTPException(status_code=400, detail="ssid and password required") + if not isinstance(priority, int): + priority = 0 + wifi_backend.add_profile(ssid, password, priority) + elif cmd == "wifi_reset": + wifi_backend.reset() + elif cmd == "spotify_set_tokens": + access_token = payload.get("access_token") + refresh_token = payload.get("refresh_token") + expires_at = payload.get("expires_at") + account_id = payload.get("account_id") + if not isinstance(access_token, str) or not isinstance(refresh_token, str): + raise HTTPException(status_code=400, detail="tokens are required") + if not isinstance(expires_at, int): + raise HTTPException(status_code=400, detail="expires_at is required") + if account_id is not None and not isinstance(account_id, str): + raise HTTPException(status_code=400, detail="account_id must be string") + spotify_backend.set_tokens(access_token, refresh_token, expires_at, account_id) + elif cmd == "spotify_clear": + spotify_backend.clear() + else: + raise HTTPException(status_code=400, detail="unknown command") + + persist_state() + return {"ok": True} + + return app diff --git a/box/audio/__init__.py b/box/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/box/audio/base.py b/box/audio/base.py new file mode 100644 index 0000000..884175e --- /dev/null +++ b/box/audio/base.py @@ -0,0 +1,16 @@ +"""Audio backend interface (placeholder).""" + +from __future__ import annotations + +from typing import Protocol + + +class AudioBackend(Protocol): + def play(self, media_ref: str) -> None: + raise NotImplementedError + + def pause(self) -> None: + raise NotImplementedError + + def stop(self) -> None: + raise NotImplementedError diff --git a/box/audio/mock.py b/box/audio/mock.py new file mode 100644 index 0000000..e926680 --- /dev/null +++ b/box/audio/mock.py @@ -0,0 +1,19 @@ +"""Mock audio backend (no real playback).""" + +from __future__ import annotations + +from audio.base import AudioBackend + + +class MockAudioBackend(AudioBackend): + def __init__(self) -> None: + self._current: str | None = None + + def play(self, media_ref: str) -> None: + self._current = media_ref + + def pause(self) -> None: + return None + + def stop(self) -> None: + self._current = None diff --git a/box/config/box_config.json b/box/config/box_config.json index c2979df..725725e 100644 --- a/box/config/box_config.json +++ b/box/config/box_config.json @@ -24,12 +24,20 @@ "UID_5": { "type": "folder", "path": "../media/book_5" + }, + "UID_6": { + "type": "folder", + "path": "../media/book_6" } }, "resume": { "UID_1": { "file_index": 0, - "position": 50 + "position": 12 + }, + "UID_2": { + "file_index": 1, + "position": 0 } } } \ No newline at end of file diff --git a/box/core/__pycache__/box_controller.cpython-312.pyc b/box/core/__pycache__/box_controller.cpython-312.pyc index f81618426d0bd75ab65b42c67d7949a702e39ffa..2d4ce0e2cc729a2748e4ac1ef077f9c09d980abe 100644 GIT binary patch literal 17776 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 delta 3172 zcmZuzYitzP6`s5E*x8+Z*xvQ-8ryi+@9|@8pi_uTKEd++(q-Fq*SlLMZ=x!q0%zYmC#I2JhL36TS5Y6b!}rk_#lCm2P% zVxz+hv(Gi@IP>a@!4a!#N!Dt}>RG5Kq>toyTBp$OfkMcCL8ga=|C+>(8;y;vgVjwHpIi_2PZvCC<* zHY+J>sMgS*x}1Rw1v9^_n437d0S}=N9&GcuW1h8PhdId|^R8J03+B6G$!53I&Jrh^ zWlh}^D_C=6MysKv$wy@Kq8^A2o-kR_bPL1^b8eZ2oHiQG1>lW^a>hwUmWE-kR1fn~ z&x~x-Xa=tfgUd{gt8$UyifO8zP99TZnfMWdAJ-D8jNy$Pnn)y-*myENnM~*zgFT{V z4ELJrcs%pG=0-rn86U|caPmY#G3<1F;;3q{Ni~Ju%7hlrB+{waVffbdl1~%p1p70C z-9H@|?$_vk;k0{`&_V;-1b=ovCq+=ze}@S;%fO@I?MbINuqBbgML@xp1az*XXqx zoSsRKo4eN9uy1XL8|WL{VeUoSfJU8?C#(hNcrS{ng2z{-5QEe&iei>o5nXexQ?5H= z(Xv={TP$9wtUbxiNvEXSV&rZ~C3j)y{1EJKD~4@82bsb!OTgRcb0BFp;{wDYs7G+Z zmp&fum-*nP=!B&Lv5vFiL~1d3nYgN^#>Tbu5xgMX3=9*3(ns42JN-{eyVgUxcF8Z1 zp>jG_hC=2vn!#E%3d5Pu6Dd6tPmQPsrzA!)IBKC#mjNn%ghJ;~gz>Y^p#r`yEF*iN zEHG&+;Ncf-Lom%dp{CUji=q!+sljt^@DF z0AF(ezTE7D0%sAFIUMjap$M{F9#928ylJ+j@Nravh2}O_)`swX2vhl3JTALtnD^4p3!;LP- z46>IUHre_G%_!Mx-6!RYnRi+{)izOK(x;VbbyU<-VdiHO)tafmxEYOJibm5;7R^gD z?7BLVPAR(K(A1;xL@JRwVu5X9O3-y1^?wqD;m9P8sM01pOzGQX1?o2Lo{<|a?|FCsTl?=c_AEE{+>(25#Fyp%dG4HaM!KC>y;9SZ(<@gR znsd6mQom-eU1@5~*&FUwHe4F|%}}l%SqT>3t7p8Sg~-cCFFN1Zz2v`Me6{#a%g*JN zowpkMZ*$@Pdr_z;n{_7f4zm=guj-(!m^`oxQh8UTW$CEF>~CA?Fchs zJBW1^Uo>vin;VSk0gss zW~t?R`_=YYMx!NOCI&mEPRb6GnA%flGFUyMX*E<6)EBU5>xPifV@g6Z?1_x3S&AMz zoJgvMpr^Hrs$gj|`Ab==b(mR0Nw4jpC_MGl4VT%(tO>duM<})Nv)Ek)!AQCADw_2< z=!*O}UJ|}Acz*DW;g9k<=6`-U{%+!}#P7$>=HJZgILV{98!TN=m*p+DgB>SbE8&KZ zyp0PjzhvjTUlTs`Hr^B)zq!vd`CGsF)Z3`jbbn7S>}lZM>*7$pA1vr;;h@PEfbXmR z66(NV^N@A=H)buO8n{~>Cg-nxTm2K#C)+Fg0I; zs>{LqRs(hX4zl_jaq!XAfN5p2^097Y5atd|0C?Tk?Q*ll_tvn049nI5C8xG diff --git a/box/core/__pycache__/state.cpython-312.pyc b/box/core/__pycache__/state.cpython-312.pyc index b447ae9b8590b15cba9c4a4af59bd18ac09655f8..cbc9a7ff9bea8bc7582720c67c5fe41fce451879 100644 GIT binary patch delta 466 zcmZqS`M|?_nwOW00SFE+OUZ27$ScgqC^K1+QIaEtC5o+*Rg-P99it-GEmqf{Apamg zP4>x2jK)kwypty}+RBIlwY4zZ;1+Cfxxpvi;C_Qww88BLk8q=Fk_r81`o+W>ViF{BEn3at@Y%?PrVfgwsbMHDC}2A2~-k`ql42g*r+$ewu8Pa~X{%FJ-h67X%v8!f=CIu)*a9pLm1&4PMbkw<5vG zzZvD2J}^v{X4+xH#E>eGD!4{?H6zGI28Jl16p>Ws6j2)>8!RV`Bqx$029y&A$yKsy zN=(*fK0f&$vmc+PXb~?^tcVXp@K4TWk>?f%F(p8R)Z_^)>XxiP<}H?#%;b_HWiT@* zv$&*41*AX#M1Y)HBm^QvU<63KxNq`*mZHhktTJ3zSj-@*L?*9h4bT(?DdU8xMKk0V zhfQvNN@-52U6Jl&6*eU;W=64&;t4Ka7=VlqJPb_S-ziO None: + if self._state.state == BoxState.ERROR: + return None + if self._state.active_uid == uid and self._state.state != BoxState.IDLE: + return None + if self._config.get("server_reachable") is False: + print("error: server not reachable") + self._trigger_error("server_unreachable") + return None tag = self._resolve_tag(uid) if tag is None: print(f"error: uid '{uid}' not found in config tags") + self._trigger_error("uid_unmapped") return None + self._state.last_error = None playlist = self._build_playlist(tag) if not playlist: @@ -43,15 +54,24 @@ class BoxController: print( f"error: no playable files found for uid '{uid}' at path '{resolved}'" ) + self._trigger_error("no_playable_files") return None - if self._state.active_uid == uid and uid in self._state._resume: + if uid in self._state._resume: file_index, position = self._state._resume[uid] else: file_index, position = 0, 0 if file_index < 0 or file_index >= len(playlist): - file_index, position = 0, 0 + print( + f"error: stored progress points outside playlist for uid '{uid}'" + ) + self._trigger_error("invalid_resume") + return None + + if self._state.active_uid and self._state.active_uid != uid: + self._store_progress(self._state.active_uid) + self.stop() media_ref = playlist[file_index] duration = self._duration_for(media_ref) @@ -75,10 +95,11 @@ class BoxController: def stop(self) -> None: self._player.stop() - self._state.current_media = None - self._state.state = BoxState.IDLE + self._clear_playback_state() def handle_nfc_off(self, uid: str) -> None: + if self._state.state == BoxState.ERROR: + return None if self._state.active_uid != uid: return None self._store_progress(uid) @@ -96,6 +117,8 @@ class BoxController: return tag def tick(self, seconds: int) -> None: + if self._state.state == BoxState.ERROR: + return None if self._state.state != BoxState.PLAYING: return None if self._state.position is None or self._state.file_index is None: @@ -124,12 +147,14 @@ class BoxController: position = 0 if file_index >= len(self._state._playlist): - self._state.file_index = len(self._state._playlist) - 1 - self._state.position = duration - self._state.current_media = None - self._state.current_file = None - self._state.current_duration = None - self._state.state = BoxState.IDLE + last_index = len(self._state._playlist) - 1 + last_media = self._state._playlist[last_index] + last_duration = self._duration_for(last_media) + self._state.file_index = last_index + self._state.position = last_duration + if self._state.active_uid: + self._store_progress(self._state.active_uid) + self.stop() return None self._state.file_index = file_index @@ -146,9 +171,17 @@ class BoxController: path = self._resolve_path(path_value) if not path.exists() or not path.is_dir(): return [] - files = [p for p in path.iterdir() if p.is_file()] - files_sorted = sorted(files, key=lambda p: p.name) - return [str(p) for p in files_sorted] + return [str(p) for p in self._collect_files(path)] + + def _collect_files(self, root: Path) -> list[Path]: + entries = list(root.iterdir()) + dirs = sorted([p for p in entries if p.is_dir()], key=lambda p: p.name) + files = sorted([p for p in entries if p.is_file()], key=lambda p: p.name) + result: list[Path] = [] + for directory in dirs: + result.extend(self._collect_files(directory)) + result.extend(files) + return result def _resolve_path(self, path_value: str) -> Path: path = Path(path_value) @@ -164,4 +197,94 @@ class BoxController: def _duration_for(self, media_ref: str) -> int: name = Path(media_ref).name durations = {"01.mp3": 180, "02.mp3": 200, "03.mp3": 220} - return durations.get(name, 0) + if name in durations: + return durations[name] + if not name.lower().endswith(".mp3"): + return 0 + digest = hashlib.md5(name.encode("utf-8")).hexdigest() + seed = int(digest[:8], 16) + return 120 + (seed % 241) + + def play_pause(self) -> None: + if self._state.state in {BoxState.IDLE, BoxState.ERROR}: + return None + if self._state.state == BoxState.PLAYING: + self._state.state = BoxState.PAUSED + return None + if self._state.state == BoxState.PAUSED: + self._state.state = BoxState.PLAYING + return None + + def next_track(self) -> None: + if self._state.state in {BoxState.IDLE, BoxState.ERROR}: + return None + if not self._state._playlist: + return None + if self._state.file_index is None: + return None + next_index = self._state.file_index + 1 + if next_index >= len(self._state._playlist): + if self._state.active_uid: + self._store_progress(self._state.active_uid) + self.stop() + return None + self._jump_to_index(next_index, 0) + self._state.state = BoxState.PLAYING + + def prev_track(self, threshold_seconds: int = 3) -> None: + if self._state.state in {BoxState.IDLE, BoxState.ERROR}: + return None + if self._state.file_index is None or self._state.position is None: + return None + if self._state.position > threshold_seconds: + self._state.position = 0 + return None + prev_index = max(self._state.file_index - 1, 0) + self._jump_to_index(prev_index, 0) + self._state.state = BoxState.PLAYING + + def volume_up(self, step: int = 5) -> None: + if self._state.state == BoxState.ERROR: + return None + self._state.volume = min(self._state.volume + step, self._state.max_volume) + + def volume_down(self, step: int = 5) -> None: + if self._state.state == BoxState.ERROR: + return None + self._state.volume = max(self._state.volume - step, 0) + + def _jump_to_index(self, file_index: int, position: int) -> None: + if not self._state._playlist: + return None + if file_index < 0 or file_index >= len(self._state._playlist): + return None + media_ref = self._state._playlist[file_index] + self._state.file_index = file_index + self._state.position = position + self._state.current_media = media_ref + self._state.current_file = Path(media_ref).name + self._state.current_duration = self._duration_for(media_ref) + self._player.play(media_ref) + + def _trigger_error(self, code: str) -> None: + if self._state.state in {BoxState.PLAYING, BoxState.PAUSED}: + self._player.pause() + self._state.state = BoxState.ERROR + self._state.last_error = code + self._player.play(f"system:error:{code}") + self._clear_playback_state() + + def _clear_playback_state(self) -> None: + self._state.state = BoxState.IDLE + self._state.active_uid = None + self._state.playback_type = None + self._state.playback_path = None + self._state.current_media = None + self._state.current_file = None + self._state.current_duration = None + self._state.file_index = None + self._state.position = None + self._state._playlist = [] + + def trigger_error(self, code: str) -> None: + self._trigger_error(code) diff --git a/box/core/controller.py b/box/core/controller.py new file mode 100644 index 0000000..375c654 --- /dev/null +++ b/box/core/controller.py @@ -0,0 +1,7 @@ +"""Compatibility re-export for BoxController.""" + +from __future__ import annotations + +from core.box_controller import BoxController + +__all__ = ["BoxController"] diff --git a/box/core/identity.py b/box/core/identity.py new file mode 100644 index 0000000..c640038 --- /dev/null +++ b/box/core/identity.py @@ -0,0 +1,15 @@ +"""Box identity generation and management.""" + +from __future__ import annotations + +import secrets +import string + +PREFIX = "klangkiste-" +ALPHABET = string.ascii_lowercase + string.digits +SUFFIX_LENGTH = 10 + + +def generate_box_id() -> str: + suffix = "".join(secrets.choice(ALPHABET) for _ in range(SUFFIX_LENGTH)) + return f"{PREFIX}{suffix}" diff --git a/box/core/lifecycle.py b/box/core/lifecycle.py new file mode 100644 index 0000000..6e6d004 --- /dev/null +++ b/box/core/lifecycle.py @@ -0,0 +1,52 @@ +"""Network lifecycle state machine (logical).""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from wifi.base import WifiBackend + + +class WifiState(str, Enum): + WIFI_UNCONFIGURED = "WIFI_UNCONFIGURED" + WIFI_CONNECTING = "WIFI_CONNECTING" + WIFI_ONLINE = "WIFI_ONLINE" + WIFI_OFFLINE = "WIFI_OFFLINE" + + +@dataclass +class NetworkSnapshot: + wifi_state: WifiState + ip_address: str + connected_ssid: str | None + wifi_profiles_count: int + + +class NetworkLifecycle: + def __init__(self, wifi_backend: WifiBackend) -> None: + self._wifi = wifi_backend + self._wifi_state = WifiState.WIFI_UNCONFIGURED + self._ip_address = "192.168.4.1" + self.refresh() + + def refresh(self) -> None: + if self._wifi.profiles_count == 0: + self._wifi_state = WifiState.WIFI_UNCONFIGURED + self._ip_address = "192.168.4.1" + return + if self._wifi.connected_ssid: + self._wifi_state = WifiState.WIFI_ONLINE + self._ip_address = "127.0.0.1" + return + self._wifi_state = WifiState.WIFI_OFFLINE + self._ip_address = "127.0.0.1" + + def snapshot(self) -> NetworkSnapshot: + self.refresh() + return NetworkSnapshot( + wifi_state=self._wifi_state, + ip_address=self._ip_address, + connected_ssid=self._wifi.connected_ssid, + wifi_profiles_count=self._wifi.profiles_count, + ) diff --git a/box/core/state.py b/box/core/state.py index 38d8776..b2a9de8 100644 --- a/box/core/state.py +++ b/box/core/state.py @@ -11,6 +11,7 @@ class BoxState(str, Enum): IDLE = "IDLE" PLAYING = "PLAYING" PAUSED = "PAUSED" + ERROR = "ERROR" @dataclass @@ -26,5 +27,7 @@ class RuntimeState: current_duration: Optional[int] = None file_index: Optional[int] = None position: Optional[int] = None + last_error: Optional[str] = None _resume: dict[str, tuple[int, int]] = field(default_factory=dict) _playlist: list[str] = field(default_factory=list) + max_volume: int = 100 diff --git a/box/data/.keep b/box/data/.keep new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/.keep b/box/data/media/.keep new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_1/01.mp3 b/box/data/media/book_1/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_1/02.mp3 b/box/data/media/book_1/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_1/03.mp3 b/box/data/media/book_1/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_2/01.mp3 b/box/data/media/book_2/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_2/02.mp3 b/box/data/media/book_2/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_2/03.mp3 b/box/data/media/book_2/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_3/01.mp3 b/box/data/media/book_3/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_3/02.mp3 b/box/data/media/book_3/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_3/03.mp3 b/box/data/media/book_3/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_4/01.mp3 b/box/data/media/book_4/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_4/02.mp3 b/box/data/media/book_4/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_4/03.mp3 b/box/data/media/book_4/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_5/01.mp3 b/box/data/media/book_5/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_5/02.mp3 b/box/data/media/book_5/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_5/03.mp3 b/box/data/media/book_5/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_6/CD1/Titel A.mp3 b/box/data/media/book_6/CD1/Titel A.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_6/CD1/Titel B.mp3 b/box/data/media/book_6/CD1/Titel B.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_6/CD2/Titel A.mp3 b/box/data/media/book_6/CD2/Titel A.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/data/media/book_6/CD2/Titel B.mp3 b/box/data/media/book_6/CD2/Titel B.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/main.py b/box/main.py index 4a18906..5c89000 100644 --- a/box/main.py +++ b/box/main.py @@ -3,12 +3,28 @@ from __future__ import annotations import argparse +import asyncio +import contextlib +import os +import sys +from pathlib import Path from typing import Any +import uvicorn + +from api.http import create_app from core.box_controller import BoxController -from core.state import RuntimeState +from core.identity import generate_box_id +from core.lifecycle import NetworkLifecycle +from core.state import BoxState, RuntimeState from player.mock_player import MockPlayer +from setup.webui import create_setup_app +from spotify.mock import MockSpotifyBackend +from storage.box_store import BoxStore +from storage.state_store import StateStore +from storage.secret_store import SecretStore from storage.json_storage import JsonStorage +from wifi.mock import MockWifiBackend def build_parser() -> argparse.ArgumentParser: @@ -19,7 +35,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--config", default="config/box_config.json", - help="Path to local box config JSON", + help=\"Path to local box config JSON (legacy seed)\", ) subparsers = parser.add_subparsers(dest="command", required=True) @@ -34,17 +50,24 @@ def build_parser() -> argparse.ArgumentParser: tick_parser = subparsers.add_parser("tick", help="Simulate playback time") tick_parser.add_argument("seconds", type=int, help="Seconds to advance") - subparsers.add_parser("run", help="Start interactive session") + subparsers.add_parser("run", help="Start interactive session + HTTP API") return parser -def _state_from_config(config: dict[str, Any]) -> RuntimeState: - box_id = str(config.get("box_id", "unknown")) - settings = config.get("settings", {}) if isinstance(config.get("settings"), dict) else {} +def _state_from_data( + box_meta: dict[str, Any], state_data: dict[str, Any] +) -> RuntimeState: + box_id = str(box_meta.get("box_id", "unknown")) + settings = box_meta.get("settings", {}) if isinstance(box_meta.get("settings"), dict) else {} default_volume = settings.get("default_volume", 0) - state = RuntimeState(box_id=box_id, volume=int(default_volume)) + max_volume = settings.get("max_volume", 100) + state = RuntimeState( + box_id=box_id, + volume=int(default_volume), + max_volume=int(max_volume), + ) - resume_raw = config.get("resume", {}) + resume_raw = state_data.get("resume", {}) if isinstance(resume_raw, dict): for uid, data in resume_raw.items(): if not isinstance(uid, str) or not isinstance(data, dict): @@ -56,6 +79,19 @@ def _state_from_config(config: dict[str, Any]) -> RuntimeState: return state +def _normalize_tag_paths(tags: dict[str, Any]) -> dict[str, Any]: + normalized = {} + for uid, tag in tags.items(): + if not isinstance(tag, dict): + continue + path = tag.get("path") + if isinstance(path, str) and path.startswith("../media/"): + tag = dict(tag) + tag["path"] = path.replace("../media/", "media/", 1) + normalized[uid] = tag + return normalized + + def _print_status(state: RuntimeState) -> None: current = state.current_media if state.current_media is not None else "-" current_file = state.current_file if state.current_file is not None else "-" @@ -74,20 +110,48 @@ def _print_status(state: RuntimeState) -> None: print(f"duration={current_duration}") print(f"current_media={current}") print(f"volume={state.volume}") + print(f"max_volume={state.max_volume}") def main() -> int: parser = build_parser() args = parser.parse_args() - storage = JsonStorage(args.config) - config = storage.load_config() - state = _state_from_config(config) + config_path = Path(args.config) + if not config_path.is_absolute(): + config_path = (Path(__file__).resolve().parent / config_path).resolve() + base_dir = Path(__file__).resolve().parent / "data" + base_dir.mkdir(parents=True, exist_ok=True) + (base_dir / "media").mkdir(parents=True, exist_ok=True) + + box_store = BoxStore(base_dir) + box_meta = box_store.load() + if "box_id" not in box_meta: + box_meta["box_id"] = generate_box_id() + if "settings" not in box_meta: + box_meta["settings"] = {} + if "tags" not in box_meta: + legacy_storage = JsonStorage(str(config_path)) + legacy_config = legacy_storage.load_config() + if isinstance(legacy_config.get("tags"), dict): + box_meta["tags"] = _normalize_tag_paths(legacy_config.get("tags", {})) + if isinstance(box_meta.get("tags"), dict): + box_meta["tags"] = _normalize_tag_paths(box_meta.get("tags", {})) + box_store.save(box_meta) + + state_store = StateStore(base_dir) + state_data = state_store.load() + + state = _state_from_data(box_meta, state_data) + secrets = SecretStore(base_dir, state.box_id) + wifi_backend = MockWifiBackend(state_store, secrets) + spotify_backend = MockSpotifyBackend(secrets) + lifecycle = NetworkLifecycle(wifi_backend) controller = BoxController( state=state, player=MockPlayer(), - config=config, - config_path=args.config, + config=box_meta, + config_path=str(box_store.path), ) if args.command == "status": @@ -97,26 +161,31 @@ def main() -> int: if args.command == "simulate-nfc": controller.handle_nfc_on(args.uid) _print_status(controller.state) - config["resume"] = _resume_to_json(controller.state) - storage.save_config(config) + _persist_resume(controller, state_store) return 0 if args.command == "simulate-nfc-off": controller.handle_nfc_off(args.uid) _print_status(controller.state) - config["resume"] = _resume_to_json(controller.state) - storage.save_config(config) + _persist_resume(controller, state_store) return 0 if args.command == "tick": controller.tick(args.seconds) _print_status(controller.state) - config["resume"] = _resume_to_json(controller.state) - storage.save_config(config) + _persist_resume(controller, state_store) return 0 if args.command == "run": - _run_session(controller, storage, config) + asyncio.run( + _run_with_api( + controller, + state_store, + lifecycle, + wifi_backend, + spotify_backend, + ) + ) return 0 return 0 @@ -129,12 +198,85 @@ def _resume_to_json(state: RuntimeState) -> dict[str, dict[str, int]]: } -def _run_session(controller: BoxController, storage: JsonStorage, config: dict[str, Any]) -> None: +def _stdin_is_foreground() -> bool: + try: + if not sys.stdin.isatty(): + return False + return os.getpgrp() == os.tcgetpgrp(sys.stdin.fileno()) + except OSError: + return False + + +async def _run_with_api( + controller: BoxController, + storage: StateStore, + lifecycle: NetworkLifecycle, + wifi_backend: MockWifiBackend, + spotify_backend: MockSpotifyBackend, +) -> None: + app = create_app( + controller, + lifecycle, + wifi_backend, + spotify_backend, + lambda: _persist_resume(controller, storage), + ) + api_server = uvicorn.Server( + uvicorn.Config( + app, + host="0.0.0.0", + port=8000, + log_level="warning", + access_log=False, + ) + ) + setup_app = create_setup_app(lifecycle, wifi_backend) + setup_server = uvicorn.Server( + uvicorn.Config( + setup_app, + host="127.0.0.1", + port=9000, + log_level="warning", + access_log=False, + ) + ) + stop_event = asyncio.Event() + tick_task = asyncio.create_task( + _auto_tick_loop(controller, storage, stop_event) + ) + api_task = asyncio.create_task(api_server.serve()) + setup_task = asyncio.create_task(setup_server.serve()) + + try: + if _stdin_is_foreground(): + await _run_session(controller, storage) + api_server.should_exit = True + setup_server.should_exit = True + await api_task + except asyncio.CancelledError: + api_server.should_exit = True + setup_server.should_exit = True + finally: + stop_event.set() + tick_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await tick_task + setup_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await setup_task + + +async def _run_session( + controller: BoxController, storage: StateStore +) -> None: print("Klangkiste Box - Interactive Mode") print("Type 'help' for commands.") while True: try: - raw = input("> ").strip() + raw = await asyncio.to_thread(input, "> ") + raw = raw.strip() + except asyncio.CancelledError: + break except EOFError: print() break @@ -145,6 +287,9 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s args = parts[1:] if command in {"exit", "quit"}: + if controller.state.active_uid is not None: + controller.handle_nfc_off(controller.state.active_uid) + _persist_resume(controller, storage) break if command == "help": _print_help() @@ -158,7 +303,7 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s continue controller.handle_nfc_on(args[0]) _print_status(controller.state) - _persist_resume(controller, storage, config) + _persist_resume(controller, storage) continue if command == "nfc-off": if len(args) != 1: @@ -166,7 +311,7 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s continue controller.handle_nfc_off(args[0]) _print_status(controller.state) - _persist_resume(controller, storage, config) + _persist_resume(controller, storage) continue if command == "tick": if len(args) != 1: @@ -179,17 +324,52 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s continue controller.tick(seconds) _print_status(controller.state) - _persist_resume(controller, storage, config) + _persist_resume(controller, storage) + continue + if command in {"play", "pause"}: + controller.play_pause() + _print_status(controller.state) + continue + if command in {"next", "vor"}: + controller.next_track() + _print_status(controller.state) + _persist_resume(controller, storage) + continue + if command in {"prev", "zurueck"}: + controller.prev_track() + _print_status(controller.state) + _persist_resume(controller, storage) + continue + if command in {"vol-up", "lauter"}: + controller.volume_up() + _print_status(controller.state) + continue + if command in {"vol-down", "leiser"}: + controller.volume_down() + _print_status(controller.state) continue print(f"error: unknown command '{command}'") -def _persist_resume( - controller: BoxController, storage: JsonStorage, config: dict[str, Any] +async def _auto_tick_loop( + controller: BoxController, + storage: StateStore, + stop_event: asyncio.Event, ) -> None: - config["resume"] = _resume_to_json(controller.state) - storage.save_config(config) + while not stop_event.is_set(): + await asyncio.sleep(1) + if controller.state.state == BoxState.PLAYING: + controller.tick(1) + _persist_resume(controller, storage) + + +def _persist_resume( + controller: BoxController, storage: StateStore +) -> None: + data = storage.load() + data["resume"] = _resume_to_json(controller.state) + storage.save(data) def _print_help() -> None: @@ -197,6 +377,11 @@ def _print_help() -> None: print(" nfc simulate NFC tag detection") print(" nfc-off simulate NFC tag removal") print(" tick simulate time progression") + print(" play | pause toggle PLAYING/PAUSED") + print(" next | vor jump to next file") + print(" prev | zurueck jump to previous file") + print(" vol-up | lauter increase volume") + print(" vol-down | leiser decrease volume") print(" status show current status") print(" help show this help") print(" exit | quit leave interactive mode") diff --git a/box/media/book_6/CD1/Titel A.mp3 b/box/media/book_6/CD1/Titel A.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_6/CD1/Titel B.mp3 b/box/media/book_6/CD1/Titel B.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_6/CD2/Titel A.mp3 b/box/media/book_6/CD2/Titel A.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_6/CD2/Titel B.mp3 b/box/media/book_6/CD2/Titel B.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/requirements.txt b/box/requirements.txt new file mode 100644 index 0000000..ac8d625 --- /dev/null +++ b/box/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn==0.30.6 +pydantic==2.9.2 +cryptography==43.0.3 diff --git a/box/schemas/box_config.schema.json b/box/schemas/box_config.schema.json index dc50b56..05e7a81 100644 --- a/box/schemas/box_config.schema.json +++ b/box/schemas/box_config.schema.json @@ -41,6 +41,9 @@ }, "additionalProperties": false } + }, + "server_reachable": { + "type": "boolean" } }, "additionalProperties": false diff --git a/box/setup/__init__.py b/box/setup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/box/setup/webui.py b/box/setup/webui.py new file mode 100644 index 0000000..3034eb3 --- /dev/null +++ b/box/setup/webui.py @@ -0,0 +1,71 @@ +"""Setup Web UI for WiFi configuration (mock).""" + +from __future__ import annotations + +from fastapi import FastAPI, Form +from fastapi.responses import HTMLResponse + +from core.lifecycle import NetworkLifecycle, WifiState +from wifi.base import WifiBackend + + +def create_setup_app(lifecycle: NetworkLifecycle, wifi_backend: WifiBackend) -> FastAPI: + app = FastAPI() + + @app.get("/setup", response_class=HTMLResponse) + def setup_page() -> str: + snapshot = lifecycle.snapshot() + networks = wifi_backend.scan_available_networks() + options = "".join(f"" for n in networks) + status = snapshot.wifi_state.value + connected = snapshot.connected_ssid or "-" + if snapshot.wifi_state != WifiState.WIFI_UNCONFIGURED: + return _render_readonly(status, connected) + return _render_form(status, connected, options) + + @app.post("/setup", response_class=HTMLResponse) + def setup_submit(ssid: str = Form(...), password: str = Form("")) -> str: + wifi_backend.add_profile(ssid=ssid, password=password, priority=10) + snapshot = lifecycle.snapshot() + status = snapshot.wifi_state.value + connected = snapshot.connected_ssid or "-" + return _render_readonly(status, connected) + + return app + + +def _render_form(status: str, connected: str, options: str) -> str: + return f""" + + + Klangkiste Setup + +

WLAN Setup

+

Status: {status}

+

Connected: {connected}

+
+ + +
+ + +
+ +
+ + +""" + + +def _render_readonly(status: str, connected: str) -> str: + return f""" + + + Klangkiste Setup + +

WLAN Status

+

Status: {status}

+

Connected: {connected}

+ + +""" diff --git a/box/spotify/__init__.py b/box/spotify/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/box/spotify/base.py b/box/spotify/base.py new file mode 100644 index 0000000..203c0ee --- /dev/null +++ b/box/spotify/base.py @@ -0,0 +1,23 @@ +"""Spotify backend interface (logical).""" + +from __future__ import annotations + +from typing import Protocol + + +class SpotifyBackend(Protocol): + def set_tokens( + self, + access_token: str, + refresh_token: str, + expires_at: int, + account_id: str | None = None, + ) -> None: + raise NotImplementedError + + def clear(self) -> None: + raise NotImplementedError + + @property + def status(self) -> str: + raise NotImplementedError diff --git a/box/spotify/manager.py b/box/spotify/manager.py new file mode 100644 index 0000000..346524c --- /dev/null +++ b/box/spotify/manager.py @@ -0,0 +1,54 @@ +"""Logical Spotify token manager (mock, no server dependency).""" + +from __future__ import annotations + +from typing import Any + +from storage.secrets_store import SecretsStore + + +class SpotifyManager: + def __init__(self, secrets: SecretsStore) -> None: + self._secrets = secrets + self._tokens: dict[str, Any] = {} + self._load() + + def set_tokens( + self, + access_token: str, + refresh_token: str, + expires_at: int, + account_id: str | None = None, + ) -> None: + self._tokens = { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + } + if account_id is not None: + self._tokens["account_id"] = account_id + self._save() + + def clear(self) -> None: + self._tokens = {} + secrets = self._secrets.load_secrets() + if "spotify" in secrets: + secrets.pop("spotify") + self._secrets.save_secrets(secrets) + + @property + def status(self) -> str: + if not self._tokens: + return "NOT_CONFIGURED" + return "READY" + + def _load(self) -> None: + secrets = self._secrets.load_secrets() + tokens = secrets.get("spotify") + if isinstance(tokens, dict): + self._tokens = dict(tokens) + + def _save(self) -> None: + secrets = self._secrets.load_secrets() + secrets["spotify"] = dict(self._tokens) + self._secrets.save_secrets(secrets) diff --git a/box/spotify/mock.py b/box/spotify/mock.py new file mode 100644 index 0000000..690626b --- /dev/null +++ b/box/spotify/mock.py @@ -0,0 +1,55 @@ +"""Mock Spotify backend (logical).""" + +from __future__ import annotations + +from typing import Any + +from storage.secret_store import SecretStore +from spotify.base import SpotifyBackend + + +class MockSpotifyBackend(SpotifyBackend): + def __init__(self, secret_store: SecretStore) -> None: + self._secret_store = secret_store + self._tokens: dict[str, Any] = {} + self._load() + + def set_tokens( + self, + access_token: str, + refresh_token: str, + expires_at: int, + account_id: str | None = None, + ) -> None: + self._tokens = { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + } + if account_id is not None: + self._tokens["account_id"] = account_id + self._save() + + def clear(self) -> None: + self._tokens = {} + secrets = self._secret_store.load_secrets() + if "spotify" in secrets: + secrets.pop("spotify") + self._secret_store.save_secrets(secrets) + + @property + def status(self) -> str: + if not self._tokens: + return "NOT_CONFIGURED" + return "READY" + + def _load(self) -> None: + secrets = self._secret_store.load_secrets() + tokens = secrets.get("spotify") + if isinstance(tokens, dict): + self._tokens = dict(tokens) + + def _save(self) -> None: + secrets = self._secret_store.load_secrets() + secrets["spotify"] = dict(self._tokens) + self._secret_store.save_secrets(secrets) diff --git a/box/storage/box_storage.py b/box/storage/box_storage.py new file mode 100644 index 0000000..318f823 --- /dev/null +++ b/box/storage/box_storage.py @@ -0,0 +1,52 @@ +"""Persistent storage for Box metadata and runtime state.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +class BoxStorage: + def __init__(self, base_dir: str | None = None) -> None: + default_dir = Path(__file__).resolve().parents[1] / "data" + self._base_dir = Path(base_dir) if base_dir else default_dir + self._box_path = self._base_dir / "box.json" + self._state_path = self._base_dir / "state.json" + self._media_dir = self._base_dir / "media" + + @property + def base_dir(self) -> Path: + return self._base_dir + + @property + def media_dir(self) -> Path: + return self._media_dir + + @property + def box_path(self) -> Path: + return self._box_path + + @property + def state_path(self) -> Path: + return self._state_path + + def ensure_layout(self) -> None: + self._base_dir.mkdir(parents=True, exist_ok=True) + self._media_dir.mkdir(parents=True, exist_ok=True) + + def load_box_meta(self) -> dict[str, Any]: + if not self._box_path.exists(): + return {} + return json.loads(self._box_path.read_text(encoding="utf-8")) + + def save_box_meta(self, data: dict[str, Any]) -> None: + self._box_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def load_state(self) -> dict[str, Any]: + if not self._state_path.exists(): + return {} + return json.loads(self._state_path.read_text(encoding="utf-8")) + + def save_state(self, data: dict[str, Any]) -> None: + self._state_path.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/box/storage/box_store.py b/box/storage/box_store.py new file mode 100644 index 0000000..c30128d --- /dev/null +++ b/box/storage/box_store.py @@ -0,0 +1,34 @@ +"""Box metadata storage (app-level, within box/data).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.identity import generate_box_id + + +class BoxStore: + def __init__(self, base_dir: Path) -> None: + self._base_dir = base_dir + self._path = self._base_dir / "box.json" + + @property + def path(self) -> Path: + return self._path + + def load(self) -> dict[str, Any]: + if not self._path.exists(): + return {} + return json.loads(self._path.read_text(encoding="utf-8")) + + def save(self, data: dict[str, Any]) -> None: + self._path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def ensure_box_id(self) -> str: + data = self.load() + if "box_id" not in data: + data["box_id"] = generate_box_id() + self.save(data) + return str(data["box_id"]) diff --git a/box/storage/secret_store.py b/box/storage/secret_store.py new file mode 100644 index 0000000..4b3f1c7 --- /dev/null +++ b/box/storage/secret_store.py @@ -0,0 +1,57 @@ +"""Encrypted secret storage for sensitive Box data (app-level).""" + +from __future__ import annotations + +import base64 +import json +from hashlib import pbkdf2_hmac +from pathlib import Path +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + +STATIC_SALT = b"klangkiste-static-salt-v1" +KDF_ITERATIONS = 200_000 + + +class SecretStore: + def __init__(self, base_dir: Path, box_id: str) -> None: + self._path = base_dir / "secrets.enc" + self._box_id = box_id + + def load_secrets(self) -> dict[str, Any]: + if not self._path.exists(): + return {} + token = self._path.read_bytes() + fernet = Fernet(self._derive_key()) + try: + payload = fernet.decrypt(token) + except InvalidToken: + return {} + return json.loads(payload.decode("utf-8")) + + def save_secrets(self, data: dict[str, Any]) -> None: + fernet = Fernet(self._derive_key()) + payload = json.dumps(data, indent=2).encode("utf-8") + token = fernet.encrypt(payload) + self._path.write_bytes(token) + + def clear_wifi_secrets(self) -> None: + data = self.load_secrets() + if "wifi_profiles" in data: + data.pop("wifi_profiles") + self.save_secrets(data) + + def clear_all(self) -> None: + if self._path.exists(): + self._path.unlink() + + def _derive_key(self) -> bytes: + raw = pbkdf2_hmac( + "sha256", + self._box_id.encode("utf-8"), + STATIC_SALT, + KDF_ITERATIONS, + dklen=32, + ) + return base64.urlsafe_b64encode(raw) diff --git a/box/storage/secrets_store.py b/box/storage/secrets_store.py new file mode 100644 index 0000000..a0c022f --- /dev/null +++ b/box/storage/secrets_store.py @@ -0,0 +1,57 @@ +"""Encrypted secret storage for sensitive Box data.""" + +from __future__ import annotations + +import base64 +import json +from hashlib import pbkdf2_hmac +from pathlib import Path +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + +STATIC_SALT = b"klangkiste-static-salt-v1" +KDF_ITERATIONS = 200_000 + + +class SecretsStore: + def __init__(self, base_dir: Path, box_id: str) -> None: + self._path = base_dir / "secrets.enc" + self._box_id = box_id + + def load_secrets(self) -> dict[str, Any]: + if not self._path.exists(): + return {} + token = self._path.read_bytes() + fernet = Fernet(self._derive_key()) + try: + payload = fernet.decrypt(token) + except InvalidToken: + return {} + return json.loads(payload.decode("utf-8")) + + def save_secrets(self, data: dict[str, Any]) -> None: + fernet = Fernet(self._derive_key()) + payload = json.dumps(data, indent=2).encode("utf-8") + token = fernet.encrypt(payload) + self._path.write_bytes(token) + + def clear_wifi_secrets(self) -> None: + data = self.load_secrets() + if "wifi" in data: + data.pop("wifi") + self.save_secrets(data) + + def clear_all(self) -> None: + if self._path.exists(): + self._path.unlink() + + def _derive_key(self) -> bytes: + raw = pbkdf2_hmac( + "sha256", + self._box_id.encode("utf-8"), + STATIC_SALT, + KDF_ITERATIONS, + dklen=32, + ) + return base64.urlsafe_b64encode(raw) diff --git a/box/storage/state_store.py b/box/storage/state_store.py new file mode 100644 index 0000000..f8e39ab --- /dev/null +++ b/box/storage/state_store.py @@ -0,0 +1,24 @@ +"""Runtime state storage (resume, wifi state).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +class StateStore: + def __init__(self, base_dir: Path) -> None: + self._path = base_dir / "state.json" + + @property + def path(self) -> Path: + return self._path + + def load(self) -> dict[str, Any]: + if not self._path.exists(): + return {} + return json.loads(self._path.read_text(encoding="utf-8")) + + def save(self, data: dict[str, Any]) -> None: + self._path.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/box/wifi/__init__.py b/box/wifi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/box/wifi/base.py b/box/wifi/base.py new file mode 100644 index 0000000..3770f10 --- /dev/null +++ b/box/wifi/base.py @@ -0,0 +1,45 @@ +"""WiFi backend interface (logical, no OS integration).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass +class WifiProfile: + ssid: str + password: str + priority: int = 0 + + +class WifiBackend(Protocol): + def scan_available_networks(self) -> list[str]: + raise NotImplementedError + + def add_profile(self, ssid: str, password: str, priority: int = 0) -> None: + raise NotImplementedError + + def remove_profile(self, ssid: str) -> None: + raise NotImplementedError + + def reset(self) -> None: + raise NotImplementedError + + def select_best_profile(self) -> WifiProfile | None: + raise NotImplementedError + + def connect(self, profile: WifiProfile) -> None: + raise NotImplementedError + + @property + def network_status(self) -> str: + raise NotImplementedError + + @property + def connected_ssid(self) -> str | None: + raise NotImplementedError + + @property + def profiles_count(self) -> int: + raise NotImplementedError diff --git a/box/wifi/manager.py b/box/wifi/manager.py new file mode 100644 index 0000000..e4fdf0d --- /dev/null +++ b/box/wifi/manager.py @@ -0,0 +1,115 @@ +"""Logical WiFi manager (mock, no OS integration).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from storage.box_storage import BoxStorage +from storage.secrets_store import SecretsStore + + +@dataclass +class WifiProfile: + ssid: str + password: str + priority: int = 0 + + +class WifiManager: + def __init__(self, storage: BoxStorage, secrets: SecretsStore) -> None: + self._storage = storage + self._secrets = secrets + self._profiles: list[WifiProfile] = [] + self._connected_ssid: str | None = None + self._load_state() + + def scan_available_networks(self) -> list[str]: + return [] + + def add_profile(self, ssid: str, password: str, priority: int = 0) -> None: + self._profiles = [p for p in self._profiles if p.ssid != ssid] + self._profiles.append(WifiProfile(ssid=ssid, password=password, priority=priority)) + self._save_profiles() + self._auto_connect() + + def remove_profile(self, ssid: str) -> None: + self._profiles = [p for p in self._profiles if p.ssid != ssid] + if self._connected_ssid == ssid: + self._connected_ssid = None + self._save_profiles() + self._save_state() + self._auto_connect() + + def reset(self) -> None: + self._profiles = [] + self._connected_ssid = None + self._secrets.clear_wifi_secrets() + self._save_state() + + def select_best_profile(self) -> WifiProfile | None: + if not self._profiles: + return None + return sorted(self._profiles, key=lambda p: (-p.priority, p.ssid))[0] + + def connect(self, profile: WifiProfile) -> None: + self._connected_ssid = profile.ssid + self._save_state() + + @property + def network_status(self) -> str: + if not self._profiles: + return "NOT_CONFIGURED" + if self._connected_ssid: + return "CONNECTED" + return "DISCONNECTED" + + @property + def connected_ssid(self) -> str | None: + return self._connected_ssid + + @property + def profiles_count(self) -> int: + return len(self._profiles) + + def _auto_connect(self) -> None: + profile = self.select_best_profile() + if profile is not None: + self.connect(profile) + + def _load_state(self) -> None: + secrets = self._secrets.load_secrets() + profiles_raw = secrets.get("wifi_profiles", []) + if isinstance(profiles_raw, list): + for entry in profiles_raw: + if not isinstance(entry, dict): + continue + ssid = entry.get("ssid") + password = entry.get("password") + priority = entry.get("priority", 0) + if isinstance(ssid, str) and isinstance(password, str): + if not isinstance(priority, int): + priority = 0 + self._profiles.append( + WifiProfile(ssid=ssid, password=password, priority=priority) + ) + + state = self._storage.load_state() + wifi_state = state.get("wifi", {}) if isinstance(state.get("wifi"), dict) else {} + connected = wifi_state.get("connected_ssid") + if isinstance(connected, str): + self._connected_ssid = connected + self._auto_connect() + + def _save_profiles(self) -> None: + secrets = self._secrets.load_secrets() + secrets["wifi_profiles"] = [ + {"ssid": p.ssid, "password": p.password, "priority": p.priority} + for p in self._profiles + ] + self._secrets.save_secrets(secrets) + + def _save_state(self) -> None: + state = self._storage.load_state() + state["wifi"] = {"connected_ssid": self._connected_ssid} + self._storage.save_state(state) diff --git a/box/wifi/mock.py b/box/wifi/mock.py new file mode 100644 index 0000000..81587ca --- /dev/null +++ b/box/wifi/mock.py @@ -0,0 +1,109 @@ +"""Mock WiFi backend (logical simulation).""" + +from __future__ import annotations + +from typing import Any + +from storage.state_store import StateStore +from storage.secret_store import SecretStore +from wifi.base import WifiBackend, WifiProfile + + +class MockWifiBackend(WifiBackend): + def __init__(self, state_store: StateStore, secret_store: SecretStore) -> None: + self._state_store = state_store + self._secret_store = secret_store + self._profiles: list[WifiProfile] = [] + self._connected_ssid: str | None = None + self._available_networks = ["HomeWiFi", "Hotspot"] + self._load() + + def scan_available_networks(self) -> list[str]: + return list(self._available_networks) + + def add_profile(self, ssid: str, password: str, priority: int = 0) -> None: + self._profiles = [p for p in self._profiles if p.ssid != ssid] + self._profiles.append(WifiProfile(ssid=ssid, password=password, priority=priority)) + self._save_profiles() + self._auto_connect() + + def remove_profile(self, ssid: str) -> None: + self._profiles = [p for p in self._profiles if p.ssid != ssid] + if self._connected_ssid == ssid: + self._connected_ssid = None + self._save_profiles() + self._save_state() + self._auto_connect() + + def reset(self) -> None: + self._profiles = [] + self._connected_ssid = None + self._secret_store.clear_wifi_secrets() + self._save_state() + + def select_best_profile(self) -> WifiProfile | None: + if not self._profiles: + return None + return sorted(self._profiles, key=lambda p: (-p.priority, p.ssid))[0] + + def connect(self, profile: WifiProfile) -> None: + self._connected_ssid = profile.ssid + self._save_state() + + @property + def network_status(self) -> str: + if not self._profiles: + return "WIFI_UNCONFIGURED" + if self._connected_ssid: + return "WIFI_ONLINE" + return "WIFI_OFFLINE" + + @property + def connected_ssid(self) -> str | None: + return self._connected_ssid + + @property + def profiles_count(self) -> int: + return len(self._profiles) + + def _auto_connect(self) -> None: + profile = self.select_best_profile() + if profile is not None: + self.connect(profile) + + def _load(self) -> None: + secrets = self._secret_store.load_secrets() + profiles_raw = secrets.get("wifi_profiles", []) + if isinstance(profiles_raw, list): + for entry in profiles_raw: + if not isinstance(entry, dict): + continue + ssid = entry.get("ssid") + password = entry.get("password") + priority = entry.get("priority", 0) + if isinstance(ssid, str) and isinstance(password, str): + if not isinstance(priority, int): + priority = 0 + self._profiles.append( + WifiProfile(ssid=ssid, password=password, priority=priority) + ) + + state = self._state_store.load() + wifi_state = state.get("wifi", {}) if isinstance(state.get("wifi"), dict) else {} + connected = wifi_state.get("connected_ssid") + if isinstance(connected, str): + self._connected_ssid = connected + self._auto_connect() + + def _save_profiles(self) -> None: + secrets = self._secret_store.load_secrets() + secrets["wifi_profiles"] = [ + {"ssid": p.ssid, "password": p.password, "priority": p.priority} + for p in self._profiles + ] + self._secret_store.save_secrets(secrets) + + def _save_state(self) -> None: + state = self._state_store.load() + state["wifi"] = {"connected_ssid": self._connected_ssid} + self._state_store.save(state) diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..a7586a2 --- /dev/null +++ b/dev.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "🚀 Starting Klangkiste (Box + GUI)" + +# Ensure both processes are stopped on exit or Ctrl+C. +cleanup() { + echo "" + echo "🛑 Stopping Klangkiste..." + if [[ -n "${GUI_PID:-}" ]]; then + kill -TERM "$GUI_PID" 2>/dev/null || true + wait "$GUI_PID" 2>/dev/null || true + fi +} +trap cleanup INT TERM EXIT + +# GUI starten (Vite) +echo "▶️ Starting GUI..." +( + cd gui + npm run dev +) & + +GUI_PID=$! + +# Box starten (FastAPI + Run-Modus) +echo "▶️ Starting Box..." +cd box +python3 main.py run + +echo "" +echo "✅ GUI PID: $GUI_PID" +echo "🛑 Press Ctrl+C to stop everything" +echo "" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..763706b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,56 @@ +# Klangkiste Dev Playbook + +## Purpose +This playbook documents the "virtual box" development environment. It is a complete, hardware-free simulation that runs entirely inside the repo and lets you test setup, playback, and API flows end-to-end. + +Docs live in the repo-level `docs/` to keep all dev guidance in one predictable place for both Box and GUI contributors. + +## Prerequisites +- Python 3 +- Node.js + npm +- From repo root + +## Step-by-step +Quickstart (3-5 minutes): +1) Start everything: +``` +./dev.sh +``` +2) Open the WiFi setup UI: +``` +http://127.0.0.1:9000/setup +``` +3) Add a WiFi profile and submit. +4) Check status: +``` +curl http://127.0.0.1:8000/status +``` +5) Start playback: +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' +``` + +## Troubleshooting +- Box not starting: + - Ensure dependencies are installed and ports 8000/9000 are free. +- GUI cannot reach API: + - Check `VITE_BOX_API_URL` and Box logs. + +## What Is Mock vs Real +- Mock: WiFi, IP, Spotify, audio, hardware. These are logical simulations only. +- Real: Box process, playback state machine, API, CLI, resume logic, media traversal. + +## Link Index +- Dev workflow: `docs/dev-workflow.md` +- Box lifecycle: `docs/box-lifecycle.md` +- Setup WebGUI: `docs/setup-webgui.md` +- HTTP API contract: `docs/api.md` +- GUI control panel: `docs/gui-control-panel.md` +- Playback testing: `docs/playback-testing.md` +- Offline scenarios: `docs/offline-scenarios.md` +- Security & storage: `docs/security-storage.md` +- CLI reference: `docs/cli.md` +- Virtual box notes: `docs/virtual-box.md` +- Legacy storage notes: `docs/storage.md` diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..8149284 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,114 @@ +# Box HTTP API + +## Purpose +Defines the stable contract between the GUI/CLI and the Box. +The Box is authoritative; the client only sends commands and reads status. + +## Prerequisites +- Box running on `http://127.0.0.1:8000` + +## Step-by-step +### Status +``` +curl http://127.0.0.1:8000/status +``` + +### Command (example) +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' +``` + +## GET /status +Response fields: +- `box_id`: unique device ID +- `wifi_state`: `WIFI_UNCONFIGURED | WIFI_CONNECTING | WIFI_ONLINE | WIFI_OFFLINE` +- `ip_address`: simulated IP (`192.168.4.1` or `127.0.0.1`) +- `connected_ssid`: connected SSID or `null` +- `wifi_profiles_count`: number of stored profiles +- `spotify_status`: `NOT_CONFIGURED | READY` +- `playback_state`: + - `state`: `IDLE | PLAYING | PAUSED` + - `active_uid` + - `playback_root` + - `current_file` + - `file_index` + - `position` + - `duration` + - `volume` + - `last_error` + +Example response: +``` +{ + "box_id": "klangkiste-a9f3k7m2q8", + "wifi_state": "WIFI_ONLINE", + "ip_address": "127.0.0.1", + "connected_ssid": "HomeWiFi", + "wifi_profiles_count": 1, + "spotify_status": "READY", + "playback_state": { + "state": "PLAYING", + "active_uid": "UID_1", + "playback_root": "media/book_1", + "current_file": "/root/klangkiste/box/data/media/book_1/01.mp3", + "file_index": 0, + "position": 42, + "duration": 180, + "volume": 40, + "last_error": null + } +} +``` + +## POST /command +Request body: +``` +{ + "command": "nfc_on", + "payload": { "uid": "UID_1" } +} +``` + +Supported commands: +- `nfc_on` `{ uid }` +- `nfc_off` `{ uid }` +- `play_pause` +- `next` +- `prev` +- `volume_up` +- `volume_down` +- `vol_up` (alias) +- `vol_down` (alias) +- `stop` +- `trigger_error` `{ type }` +- `wifi_add_profile` `{ ssid, password, priority? }` +- `wifi_reset` +- `spotify_set_tokens` `{ access_token, refresh_token, expires_at, account_id? }` +- `spotify_clear` + +Example: add WiFi profile +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' +``` + +Example: set Spotify tokens +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}' +``` + +## Checklist +- ✅ `GET /status` returns JSON +- ✅ `POST /command` returns `{ "ok": true }` +- ✅ No secrets appear in `/status` + +## Troubleshooting +- 400 responses: + - Check required fields in the payload. +- CORS errors: + - Ensure Box API is running on the expected host/port. diff --git a/docs/box-lifecycle.md b/docs/box-lifecycle.md new file mode 100644 index 0000000..a45aad6 --- /dev/null +++ b/docs/box-lifecycle.md @@ -0,0 +1,42 @@ +# Box Lifecycle + +## Purpose +Defines the virtual box lifecycle: identity, WiFi states, and restart behavior. + +## Prerequisites +- Box started with `python3 box/main.py run` + +## Step-by-step +### Box identity +- Generated on first start if `box/data/box.json` has no `box_id`. +- Format: `klangkiste-<10 chars a-z0-9>` +- Stored in `box/data/box.json` + +### WiFi states (logical) +- `WIFI_UNCONFIGURED`: no profiles stored +- `WIFI_CONNECTING`: reserved for future; not emitted in mock +- `WIFI_ONLINE`: profile exists and mock is "connected" +- `WIFI_OFFLINE`: profile exists but not connected + +### IP model (informational) +- Setup/AP mode: `192.168.4.1` +- Normal mode: `127.0.0.1` + +### Restart behavior +- Box reloads `box_id`, resume state, and WiFi profiles from `box/data/` +- Playback resume and WiFi status are preserved + +### Resets +- WiFi reset: clears WiFi profiles only (`wifi_reset` command) +- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` + +## Checklist +- ✅ After first boot, `box/data/box.json` contains `box_id` +- ✅ `GET /status` returns `wifi_state` and `ip_address` +- ✅ WiFi reset clears profiles, `wifi_state=WIFI_UNCONFIGURED` + +## Troubleshooting +- `wifi_state` not changing: + - Add a WiFi profile via setup UI or API. +- Missing `box_id`: + - Ensure `box/data/box.json` is writable. diff --git a/docs/cli.md b/docs/cli.md index 0acb9d6..7305359 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,45 +1,55 @@ -# Klangkiste Box CLI +# Box CLI Reference -This document lists all currently available CLI interactions (Phase 1a). +## Purpose +List all CLI interactions available in the virtual box. -## Commands +## Prerequisites +- Box code available in `box/` -### status -Show the current runtime status. - -Example: +## Step-by-step +### One-shot commands +Status: ``` -python box/main.py status +python3 box/main.py status ``` -### simulate-nfc -Simulate NFC tag detection. Resolves the UID to a playback object from `box/config/box_config.json` and starts playback. - -Example: +Simulate NFC: ``` -python box/main.py simulate-nfc CHIP_1 +python3 box/main.py simulate-nfc UID_1 +python3 box/main.py simulate-nfc-off UID_1 ``` -### simulate-nfc-off -Simulate NFC tag removal. Stops playback and stores progress for that UID. - -Example: +Manual tick: ``` -python box/main.py simulate-nfc-off CHIP_1 +python3 box/main.py tick 30 ``` -### tick -Simulate playback time progression. Advances position and moves to next file when a file ends. - -Example: +### Interactive run mode ``` -python box/main.py tick 90 +python3 box/main.py run +``` +Commands inside run mode: +``` +status +nfc +nfc-off +tick +play | pause +next | vor +prev | zurueck +vol-up | lauter +vol-down | leiser +help +exit | quit ``` -### run -Start an interactive session that keeps state in memory. Use `help` inside the session for available commands. +## Checklist +- ✅ `status` prints runtime state +- ✅ `simulate-nfc` starts playback +- ✅ `run` accepts interactive commands -Example: -``` -python box/main.py run -``` +## Troubleshooting +- Commands not found: + - Ensure you run from repo root or use full path to `box/main.py`. +- No playback: + - Check tags in `box/data/box.json` and media under `box/data/media`. diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md new file mode 100644 index 0000000..4655323 --- /dev/null +++ b/docs/dev-workflow.md @@ -0,0 +1,61 @@ +# Dev Workflow + +## Purpose +How to start/stop the virtual box, how to access logs, and how to point the GUI to the API. + +## Prerequisites +- Python 3 +- Node.js + npm +- Run from repo root + +## Step-by-step +### Start everything (recommended) +``` +./dev.sh +``` +- GUI runs in the background +- Box runs in the foreground (you can type CLI commands) + +### Start separately +Box only: +``` +python3 box/main.py run +``` +GUI only: +``` +cd gui +npm run dev +``` + +## Ports / Hosts +- Box API: `http://127.0.0.1:8000` +- Setup UI: `http://127.0.0.1:9000/setup` +- GUI: `http://127.0.0.1:5174` + +## API URL for GUI +Default is `http://localhost:8000`. Override with: +``` +VITE_BOX_API_URL=http://127.0.0.1:8000 npm run dev +``` + +## Logs +- Box logs appear in the same terminal where you run `python3 box/main.py run`. +- GUI logs appear in the browser console. + +## Stop / Cleanup +- Press `Ctrl+C` in the terminal where the box is running. +- `dev.sh` will also stop the GUI process. + +## Checklist +- ✅ Run `./dev.sh` +- ✅ Box shows interactive prompt +- ✅ GUI loads in browser +- ✅ `curl http://127.0.0.1:8000/status` returns JSON + +## Troubleshooting +- Port already in use: + - 8000 or 9000: stop other processes, or change ports in `box/main.py`. +- CORS error in browser: + - Ensure API is running; CORS is already enabled in the Box API. +- GUI shows "Failed to fetch": + - Check `VITE_BOX_API_URL` and Box API port. diff --git a/docs/gui-control-panel.md b/docs/gui-control-panel.md new file mode 100644 index 0000000..33a828d --- /dev/null +++ b/docs/gui-control-panel.md @@ -0,0 +1,53 @@ +# GUI Control Panel + +## Purpose +How to use the Vite-based GUI to control and observe the virtual box. + +## Prerequisites +- Box running on `http://127.0.0.1:8000` +- GUI started (`npm run dev` or `./dev.sh`) + +## Step-by-step +1) Open GUI: +``` +http://127.0.0.1:5174 +``` +2) Click buttons to send commands. +3) Observe status updates (polling + refresh after command). + +## Controls (current in GUI) +- Play / Pause +- Next +- Prev +- Vol + +- Vol - +- NFC UID_1 ON +- NFC UID_1 OFF +- Refresh Status + +## Controls (available via API only) +- WiFi add/reset +- Spotify set/clear + +Use API for those: +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' +``` + +## Expected results +- After any button click, status refreshes within 1 second. +- `last_error` is visible if an error occurs. + +## Checklist +- ✅ GUI loads and shows API base URL +- ✅ Status updates every 1s +- ✅ Clicking a button updates `/status` + +## Troubleshooting +- GUI shows "Failed to fetch": + - Check `VITE_BOX_API_URL`. + - Verify Box API is running. +- Buttons have no effect: + - Check the API command names in `docs/api.md`. diff --git a/docs/offline-scenarios.md b/docs/offline-scenarios.md new file mode 100644 index 0000000..09046c2 --- /dev/null +++ b/docs/offline-scenarios.md @@ -0,0 +1,57 @@ +# Offline Scenarios + +## Purpose +Simulate online/offline behavior in the virtual box and verify status fields. + +## Prerequisites +- Box running on `http://127.0.0.1:8000` + +## Step-by-step +### WiFi offline (no profiles) +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"wifi_reset"}' +``` +Expected: +- `wifi_state = WIFI_UNCONFIGURED` +- `connected_ssid = null` + +### WiFi online +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' +``` +Expected: +- `wifi_state = WIFI_ONLINE` +- `connected_ssid = HomeWiFi` + +### Spotify unavailable +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"spotify_clear"}' +``` +Expected: +- `spotify_status = NOT_CONFIGURED` + +### Spotify ready +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}' +``` +Expected: +- `spotify_status = READY` + +## Checklist +- ✅ WiFi reset shows UNCONFIGURED +- ✅ WiFi add profile shows ONLINE +- ✅ Spotify clear shows NOT_CONFIGURED +- ✅ Spotify set shows READY + +## Troubleshooting +- Status not changing: + - Ensure `/status` is polled after sending commands. + - Check that Box is running and secrets file is writable. diff --git a/docs/playback-testing.md b/docs/playback-testing.md new file mode 100644 index 0000000..20b97ca --- /dev/null +++ b/docs/playback-testing.md @@ -0,0 +1,65 @@ +# Playback Testing + +## Purpose +Test NFC playback, recursive media order, resume, and playback controls. + +## Prerequisites +- Box running: `python3 box/main.py run` +- Media in `box/data/media/` + +## Step-by-step +### NFC simulation +Start playback: +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' +``` +Stop playback: +``` +curl -X POST http://127.0.0.1:8000/command \ + -H "Content-Type: application/json" \ + -d '{"command":"nfc_off","payload":{"uid":"UID_1"}}' +``` + +### Resume tests +1) `nfc_on UID_1` +2) Wait or use `tick` in CLI +3) `nfc_off UID_1` +4) `nfc_on UID_1` again +Expected: position resumes from saved state. + +### Tag switching +1) `nfc_on UID_1` +2) `nfc_on UID_2` +3) `nfc_on UID_1` +Expected: UID_1 resumes from previous position. + +### Recursive order +- Order is depth-first, folders first, lexicographic. +- Change folder names or nesting to see ordering changes. + +### End of media behavior +- When the last file ends: + - Playback stops + - Position saved at end + - State becomes IDLE + +### Buttons +- `next`: file_index + 1 +- `prev`: if position > 3s then position=0, else file_index - 1 +- `play_pause`: toggle PLAYING/PAUSED +- `volume_up` / `volume_down`: change volume within 0..max + +## Checklist +- ✅ Resume works for same UID +- ✅ Tag switching preserves per-UID resume +- ✅ Recursive order matches folder structure +- ✅ End-of-media stops playback and saves + +## Troubleshooting +- No playback starts: + - Check tags in `box/data/box.json`. + - Ensure files exist under `box/data/media`. +- Duration is 0: + - Filenames not matched; mock uses hash-based durations for unknown names. diff --git a/docs/security-storage.md b/docs/security-storage.md new file mode 100644 index 0000000..ac681f1 --- /dev/null +++ b/docs/security-storage.md @@ -0,0 +1,60 @@ +# Security & Storage + +## Purpose +Explain where data lives, what is encrypted, and how keys are derived. + +## Prerequisites +- Box running at least once to create `box/data/` + +## Step-by-step +1) Start the box: +``` +python3 box/main.py run +``` +2) Inspect data files: +``` +ls -la box/data +``` + +## Data layout +``` +box/data/ +├── box.json # box_id, settings, tags (plaintext) +├── state.json # resume, wifi state (plaintext) +├── media/ # audio files +└── secrets.enc # encrypted secrets (JSON inside) +``` + +## What is plaintext +- box_id +- box name +- volume +- resume positions +- playback state +- media paths +- UI state + +## What is encrypted +- WiFi profiles +- Spotify tokens +- Server tokens (future) + +## Key derivation +- Key = PBKDF2-HMAC-SHA256(box_id + STATIC_SALT) +- Key is never stored on disk +- secrets.enc is not portable between boxes + +## Reset behavior +- WiFi reset: removes only wifi_profiles from secrets.enc +- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` + +## Checklist +- ✅ `secrets.enc` exists after WiFi/Spotify set +- ✅ `box.json` contains `box_id` +- ✅ No secrets appear in `/status` + +## Troubleshooting +- secrets.enc not created: + - Ensure you ran `wifi_add_profile` or `spotify_set_tokens`. +- secrets.enc unreadable after reset: + - Factory reset creates a new box_id and new key. diff --git a/docs/setup-webgui.md b/docs/setup-webgui.md new file mode 100644 index 0000000..dfdf0b7 --- /dev/null +++ b/docs/setup-webgui.md @@ -0,0 +1,40 @@ +# Setup WebGUI + +## Purpose +Configure WiFi profiles in the virtual box without touching the API or CLI. + +## Prerequisites +- Box running: `python3 box/main.py run` + +## Step-by-step +1) Open the Setup UI: +``` +http://127.0.0.1:9000/setup +``` +2) Choose SSID and enter password. +3) Click "Verbinden". +4) Refresh `/status` to see `wifi_state` and `connected_ssid`. + +## Fields +- SSID: drop-down from mock scan +- Password: free text + +## Expected results +- WiFi profile is saved (encrypted) +- `wifi_state` becomes `WIFI_ONLINE` +- `connected_ssid` matches selected SSID + +## Checklist +- ✅ Setup UI loads +- ✅ Submit stores a profile +- ✅ `/status` reflects `WIFI_ONLINE` + +## Troubleshooting +- Setup UI not reachable: + - Ensure Box is running and port `9000` is free. +- Submit does nothing: + - Check Box logs for errors; secrets file must be writable. + +## Notes +- Setup UI is write-enabled only when no profiles exist. +- When profiles exist, the page switches to read-only status. diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..f1eb546 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,13 @@ +# Storage (Legacy Notes) + +## Purpose +This file is kept for backward reference. Current storage documentation lives in `docs/security-storage.md`. + +## Prerequisites +- None + +## Step-by-step +- See `docs/security-storage.md`. + +## Troubleshooting +- If you ended up here from older links, update them to `docs/security-storage.md`. diff --git a/docs/virtual-box.md b/docs/virtual-box.md new file mode 100644 index 0000000..f67296b --- /dev/null +++ b/docs/virtual-box.md @@ -0,0 +1,22 @@ +# Virtual Box Flow (IDE) + +## Purpose +Describe the full, hardware-free box lifecycle used in development. + +## Prerequisites +- Python 3 +- Box started with `python3 box/main.py run` + +## Step-by-step +1) Box starts and creates `box/data/box.json` if missing. +2) WiFi state is derived from stored profiles: + - none -> `WIFI_UNCONFIGURED` +3) Setup UI available at `http://127.0.0.1:9000/setup`. +4) After profile submission, box becomes `WIFI_ONLINE`. +5) API available at `http://127.0.0.1:8000`. + +## Troubleshooting +- Setup UI not reachable: + - Ensure port 9000 is free and Box is running. +- API not reachable: + - Ensure port 8000 is free and Box is running. diff --git a/gui/.gitignore b/gui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/gui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/gui/.vscode/extensions.json b/gui/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/gui/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/gui/README.md b/gui/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/gui/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/gui/package-lock.json b/gui/package-lock.json new file mode 100644 index 0000000..741a942 --- /dev/null +++ b/gui/package-lock.json @@ -0,0 +1,1337 @@ +{ + "name": "gui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gui", + "version": "0.0.0", + "dependencies": { + "vue": "^3.5.24" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "vite": "^7.2.4" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/gui/package.json b/gui/package.json new file mode 100644 index 0000000..72bd075 --- /dev/null +++ b/gui/package.json @@ -0,0 +1,18 @@ +{ + "name": "gui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.24" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "vite": "^7.2.4" + } +} diff --git a/gui/public/vite.svg b/gui/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/gui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gui/src/App.vue b/gui/src/App.vue new file mode 100644 index 0000000..f465c63 --- /dev/null +++ b/gui/src/App.vue @@ -0,0 +1,82 @@ + + + diff --git a/gui/src/api.js b/gui/src/api.js new file mode 100644 index 0000000..344c157 --- /dev/null +++ b/gui/src/api.js @@ -0,0 +1,24 @@ +// src/api.js + +const API_BASE = import.meta.env.VITE_BOX_API_URL || "http://10.10.10.23:8000"; + +export async function getStatus() { + const response = await fetch(`${API_BASE}/status`); + if (!response.ok) { + throw new Error(`Status request failed (${response.status})`); + } + return await response.json(); +} + +export async function sendCommand(command, payload = {}) { + console.log("GUI -> API", command, payload); + const response = await fetch(`${API_BASE}/command`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ command, payload }), + }); + if (!response.ok) { + throw new Error(`Command request failed (${response.status})`); + } + return await response.json(); +} diff --git a/gui/src/assets/vue.svg b/gui/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/gui/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gui/src/components/HelloWorld.vue b/gui/src/components/HelloWorld.vue new file mode 100644 index 0000000..546ebbc --- /dev/null +++ b/gui/src/components/HelloWorld.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/gui/src/main.js b/gui/src/main.js new file mode 100644 index 0000000..2425c0f --- /dev/null +++ b/gui/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/gui/src/style.css b/gui/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/gui/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/gui/vite.config.js b/gui/vite.config.js new file mode 100644 index 0000000..8b55b29 --- /dev/null +++ b/gui/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: [{ find: "@", replacement: "/src" }], + }, + server: { + host: true, // Hört auf allen Netzwerk-Interfaces, kein localhost notwendig + port: 5174 // optional, Standardport für Vite + } +})