studiobrn commited on
Commit
8ed5b77
·
verified ·
1 Parent(s): 298849b

Update model card and add modAI + modHacker banner

Browse files
admin/server.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import shutil
6
+ import subprocess
7
+ import threading
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional, Tuple
10
+
11
+ from flask import Flask, jsonify, render_template, request
12
+
13
+ logger = logging.getLogger("modInteractive.admin")
14
+
15
+ BASE_DIR = Path(__file__).resolve().parents[1]
16
+
17
+ app = Flask(
18
+ __name__,
19
+ template_folder=str(Path(__file__).resolve().parent / "templates"),
20
+ static_folder=str(Path(__file__).resolve().parent / "static"),
21
+ )
22
+
23
+ _config: Optional[Any] = None
24
+ _config_data: Dict[str, Any] = {}
25
+ _config_path: Path = BASE_DIR / "config.json"
26
+
27
+
28
+ def init(config: Any, config_path: str) -> None:
29
+ global _config, _config_data, _config_path
30
+
31
+ _config = config
32
+ _config_path = Path(config_path).expanduser().resolve()
33
+
34
+ try:
35
+ if hasattr(config, "reload"):
36
+ config.reload()
37
+ elif hasattr(config, "load"):
38
+ config.load()
39
+
40
+ data = getattr(config, "data", {})
41
+ _config_data = dict(data) if isinstance(data, dict) else {}
42
+ except Exception:
43
+ logger.exception("Admin config initialization failed")
44
+ _config_data = _read_config_file()
45
+
46
+
47
+ @app.route("/")
48
+ def index() -> str:
49
+ return render_template("index.html")
50
+
51
+
52
+ @app.route("/favicon.ico")
53
+ def favicon() -> tuple[str, int]:
54
+ return "", 204
55
+
56
+
57
+ @app.route("/api/config", methods=["GET"])
58
+ def get_config() -> Any:
59
+ data = _read_config_file()
60
+
61
+ if not data:
62
+ return jsonify({"error": "Config not found or empty"}), 404
63
+
64
+ return jsonify(data)
65
+
66
+
67
+ @app.route("/api/config/update", methods=["POST"])
68
+ def update_config() -> Any:
69
+ global _config_data
70
+
71
+ data = request.get_json(silent=True)
72
+
73
+ if not isinstance(data, dict):
74
+ return jsonify({"error": "JSON object required"}), 400
75
+
76
+ try:
77
+ if _config is not None and hasattr(_config, "update"):
78
+ _config.update(data, save=True)
79
+ _config_data = dict(_config.data)
80
+ else:
81
+ current = _read_config_file()
82
+ merged = _deep_merge(current, data)
83
+ _write_config_file(merged)
84
+ _config_data = merged
85
+
86
+ return jsonify(
87
+ {
88
+ "status": "ok",
89
+ "message": "Configuration updated",
90
+ "config": _config_data,
91
+ }
92
+ )
93
+
94
+ except Exception as exc:
95
+ logger.exception("Config update failed")
96
+ return jsonify({"error": str(exc)}), 500
97
+
98
+
99
+ @app.route("/api/reload-config", methods=["POST"])
100
+ def reload_config() -> Any:
101
+ global _config_data
102
+
103
+ try:
104
+ if _config is not None and hasattr(_config, "reload"):
105
+ _config.reload()
106
+ _config_data = dict(_config.data)
107
+ else:
108
+ _config_data = _read_config_file()
109
+
110
+ return jsonify(
111
+ {
112
+ "status": "ok",
113
+ "message": "Configuration reloaded",
114
+ "config": _config_data,
115
+ }
116
+ )
117
+
118
+ except Exception as exc:
119
+ logger.exception("Config reload failed")
120
+ return jsonify({"error": str(exc)}), 500
121
+
122
+
123
+ @app.route("/api/status", methods=["GET"])
124
+ def get_status() -> Any:
125
+ data = _current_config()
126
+ trigger_source = str(_nested_get(data, "trigger.source", "camera")).lower().strip()
127
+
128
+ status: Dict[str, Any] = {
129
+ "admin": "running",
130
+ "project_root": str(BASE_DIR),
131
+ "config_path": str(_config_path),
132
+ "trigger_source": trigger_source,
133
+ "opencv_available": False,
134
+ "opencv_version": None,
135
+ "camera_available": False,
136
+ "camera_resolution": None,
137
+ "pir_available": False,
138
+ "pir_gpio_pin": _safe_int(_nested_get(data, "pir.gpio_pin", 17), 17, 0, 27),
139
+ "pir_state": None,
140
+ "video_exists": False,
141
+ "video_path": None,
142
+ "mpv_available": shutil.which("mpv") is not None,
143
+ "mpv_path": shutil.which("mpv"),
144
+ }
145
+
146
+ if trigger_source == "camera":
147
+ _fill_camera_status(status, data)
148
+ elif trigger_source == "pir":
149
+ _fill_pir_status(status, data)
150
+
151
+ video_path = _resolve_video_path(str(_nested_get(data, "video.path", "videos/selamlama.mp4")))
152
+ status["video_path"] = str(video_path)
153
+ status["video_exists"] = video_path.is_file()
154
+
155
+ if video_path.is_file():
156
+ status["video_size_mb"] = round(video_path.stat().st_size / (1024 * 1024), 2)
157
+
158
+ return jsonify(status)
159
+
160
+
161
+ @app.route("/api/test-video", methods=["POST"])
162
+ def test_video() -> Any:
163
+ data = _current_config()
164
+ mpv_path = shutil.which(str(_nested_get(data, "video.player", "mpv")))
165
+
166
+ if not mpv_path:
167
+ return jsonify({"error": "mpv not installed. Install: sudo apt install mpv"}), 500
168
+
169
+ requested_video = None
170
+ body = request.get_json(silent=True)
171
+
172
+ if isinstance(body, dict):
173
+ requested_video = body.get("path")
174
+
175
+ video_value = str(requested_video or _nested_get(data, "video.path", "videos/selamlama.mp4"))
176
+ video_path = _resolve_video_path(video_value)
177
+
178
+ allowed, reason = _is_allowed_video_path(video_path)
179
+
180
+ if not allowed:
181
+ return jsonify({"error": reason}), 403
182
+
183
+ if not video_path.is_file():
184
+ return jsonify({"error": f"Video not found: {video_path}"}), 404
185
+
186
+ fullscreen = bool(_nested_get(data, "video.fullscreen", True))
187
+ volume = _safe_int(_nested_get(data, "video.volume", 90), 90, 0, 100)
188
+
189
+ command = [
190
+ mpv_path,
191
+ "--no-terminal",
192
+ "--really-quiet",
193
+ "--keep-open=no",
194
+ "--osd-level=0",
195
+ f"--volume={volume}",
196
+ ]
197
+
198
+ if fullscreen:
199
+ command.extend(["--fs", "--no-border", "--ontop", "--cursor-autohide=always"])
200
+
201
+ command.append(str(video_path))
202
+
203
+ try:
204
+ subprocess.Popen(
205
+ command,
206
+ stdin=subprocess.DEVNULL,
207
+ stdout=subprocess.DEVNULL,
208
+ stderr=subprocess.DEVNULL,
209
+ shell=False,
210
+ start_new_session=True,
211
+ )
212
+ return jsonify(
213
+ {
214
+ "status": "ok",
215
+ "message": "Video playback started",
216
+ "video": str(video_path),
217
+ }
218
+ )
219
+
220
+ except Exception as exc:
221
+ logger.exception("Test video failed")
222
+ return jsonify({"error": str(exc)}), 500
223
+
224
+
225
+ @app.route("/api/logs", methods=["GET"])
226
+ def get_logs() -> Any:
227
+ limit = _safe_int(request.args.get("limit", 100), 100, 1, 1000)
228
+ log_file = BASE_DIR / "logs" / "modinteractive.log"
229
+
230
+ if not log_file.exists():
231
+ return jsonify({"logs": []})
232
+
233
+ try:
234
+ lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
235
+ return jsonify({"logs": lines[-limit:]})
236
+ except Exception as exc:
237
+ logger.exception("Reading logs failed")
238
+ return jsonify({"error": str(exc)}), 500
239
+
240
+
241
+ def run_server(host: str = "0.0.0.0", port: int = 8080) -> None:
242
+ try:
243
+ logger.info("Admin panel starting on http://%s:%d", host, port)
244
+ app.run(
245
+ host=host,
246
+ port=int(port),
247
+ debug=False,
248
+ use_reloader=False,
249
+ threaded=True,
250
+ )
251
+ except Exception:
252
+ logger.exception("Admin panel failed to start")
253
+
254
+
255
+ def start_admin_thread(config: Any, config_path: str) -> threading.Thread:
256
+ init(config, config_path)
257
+ host = str(config.get("admin.host", "0.0.0.0"))
258
+ port = _safe_int(config.get("admin.port", 8080), 8080, 1, 65535)
259
+
260
+ thread = threading.Thread(
261
+ target=run_server,
262
+ args=(host, port),
263
+ daemon=True,
264
+ name="modinteractive-admin-server",
265
+ )
266
+ thread.start()
267
+ logger.info("Admin panel thread started on port %d", port)
268
+ return thread
269
+
270
+
271
+ def _fill_camera_status(status: Dict[str, Any], data: Dict[str, Any]) -> None:
272
+ try:
273
+ import cv2
274
+
275
+ status["opencv_available"] = True
276
+ status["opencv_version"] = cv2.__version__
277
+
278
+ camera_index = _normalize_camera_index(
279
+ _nested_get(data, "camera.index", 0)
280
+ )
281
+ camera_width = _safe_int(_nested_get(data, "camera.width", 640), 640, 1)
282
+ camera_height = _safe_int(_nested_get(data, "camera.height", 480), 480, 1)
283
+ camera_fps = _safe_int(_nested_get(data, "camera.fps", 15), 15, 1)
284
+ camera_backend = str(_nested_get(data, "camera.backend", "v4l2")).lower()
285
+
286
+ if camera_backend == "v4l2" and hasattr(cv2, "CAP_V4L2"):
287
+ cap = cv2.VideoCapture(camera_index, cv2.CAP_V4L2)
288
+ else:
289
+ cap = cv2.VideoCapture(camera_index)
290
+
291
+ try:
292
+ if cap.isOpened():
293
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width)
294
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height)
295
+ cap.set(cv2.CAP_PROP_FPS, camera_fps)
296
+
297
+ ok, frame = cap.read()
298
+
299
+ if ok and frame is not None:
300
+ status["camera_available"] = True
301
+ status["camera_resolution"] = f"{frame.shape[1]}x{frame.shape[0]}"
302
+ else:
303
+ status["camera_available"] = True
304
+ status["camera_resolution"] = "opened but no frame"
305
+ finally:
306
+ cap.release()
307
+
308
+ except Exception as exc:
309
+ status["camera_error"] = str(exc)
310
+
311
+
312
+ def _fill_pir_status(status: Dict[str, Any], data: Dict[str, Any]) -> None:
313
+ """Report PIR status without trying to claim GPIO already owned by the app.
314
+
315
+ The admin panel runs inside the same application process. When PIR mode is active,
316
+ the main loop already owns the GPIO line, so opening the same BCM pin again from
317
+ the status endpoint may produce "GPIO busy". For the dashboard we report the
318
+ configured pin and that the GPIO is controlled by the running app. The standalone
319
+ health check and tools/test_pir.py still perform real GPIO read tests.
320
+ """
321
+ status["pir_available"] = True
322
+ status["pir_state"] = "managed_by_application"
323
+ status["pir_note"] = "GPIO is controlled by the running modInteractive process"
324
+
325
+
326
+ def _current_config() -> Dict[str, Any]:
327
+ if _config is not None:
328
+ try:
329
+ data = getattr(_config, "data", None)
330
+ if isinstance(data, dict):
331
+ return dict(data)
332
+ except Exception:
333
+ logger.debug("Could not read live config object", exc_info=True)
334
+
335
+ return _read_config_file()
336
+
337
+
338
+ def _read_config_file() -> Dict[str, Any]:
339
+ try:
340
+ if not _config_path.exists():
341
+ return {}
342
+
343
+ data = json.loads(_config_path.read_text(encoding="utf-8"))
344
+
345
+ if isinstance(data, dict):
346
+ return data
347
+
348
+ return {}
349
+ except Exception:
350
+ logger.exception("Could not read config file")
351
+ return {}
352
+
353
+
354
+ def _write_config_file(data: Dict[str, Any]) -> None:
355
+ _config_path.parent.mkdir(parents=True, exist_ok=True)
356
+ _config_path.write_text(
357
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n",
358
+ encoding="utf-8",
359
+ )
360
+
361
+
362
+ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
363
+ result = dict(base)
364
+
365
+ for key, value in override.items():
366
+ if isinstance(result.get(key), dict) and isinstance(value, dict):
367
+ result[key] = _deep_merge(result[key], value)
368
+ else:
369
+ result[key] = value
370
+
371
+ return result
372
+
373
+
374
+ def _nested_get(data: Dict[str, Any], key_path: str, default: Any = None) -> Any:
375
+ value: Any = data
376
+
377
+ for key in key_path.split("."):
378
+ if isinstance(value, dict) and key in value:
379
+ value = value[key]
380
+ else:
381
+ return default
382
+
383
+ return value
384
+
385
+
386
+ def _resolve_video_path(value: str) -> Path:
387
+ path = Path(value).expanduser()
388
+
389
+ if not path.is_absolute():
390
+ path = BASE_DIR / path
391
+
392
+ return path.resolve()
393
+
394
+
395
+ def _is_allowed_video_path(path: Path) -> Tuple[bool, str]:
396
+ videos_dir = (BASE_DIR / "videos").resolve()
397
+
398
+ try:
399
+ path.relative_to(videos_dir)
400
+ return True, ""
401
+ except ValueError:
402
+ return False, f"Only files under videos directory are allowed: {videos_dir}"
403
+
404
+
405
+ def _normalize_camera_index(value: Any) -> Any:
406
+ if isinstance(value, int):
407
+ return value
408
+
409
+ if isinstance(value, str):
410
+ stripped = value.strip()
411
+
412
+ if stripped.isdigit():
413
+ return int(stripped)
414
+
415
+ return stripped
416
+
417
+ try:
418
+ return int(value)
419
+ except (TypeError, ValueError):
420
+ return 0
421
+
422
+
423
+ def _safe_int(
424
+ value: Any,
425
+ default: int,
426
+ minimum: Optional[int] = None,
427
+ maximum: Optional[int] = None,
428
+ ) -> int:
429
+ try:
430
+ number = int(value)
431
+ except (TypeError, ValueError):
432
+ number = default
433
+
434
+ if minimum is not None and number < minimum:
435
+ number = minimum
436
+
437
+ if maximum is not None and number > maximum:
438
+ number = maximum
439
+
440
+ return number
admin/static/app.js ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ bindTabs();
3
+ bindForm();
4
+ bindButtons();
5
+ bindSensitivitySlider();
6
+
7
+ loadConfig();
8
+ loadStatus();
9
+ loadLogs();
10
+
11
+ window.setInterval(loadStatus, 8000);
12
+ });
13
+
14
+ function bindTabs() {
15
+ document.querySelectorAll(".tab").forEach((tab) => {
16
+ tab.addEventListener("click", () => {
17
+ const targetId = tab.dataset.tab;
18
+
19
+ document.querySelectorAll(".tab").forEach((item) => item.classList.remove("active"));
20
+ document.querySelectorAll(".tab-content").forEach((content) => content.classList.remove("active"));
21
+
22
+ tab.classList.add("active");
23
+
24
+ const target = document.getElementById(targetId);
25
+ if (target) {
26
+ target.classList.add("active");
27
+ }
28
+
29
+ if (targetId === "status") {
30
+ loadStatus();
31
+ }
32
+
33
+ if (targetId === "logs") {
34
+ loadLogs();
35
+ }
36
+ });
37
+ });
38
+ }
39
+
40
+ function bindForm() {
41
+ const form = document.getElementById("config-form");
42
+
43
+ if (!form) {
44
+ return;
45
+ }
46
+
47
+ form.addEventListener("submit", (event) => {
48
+ event.preventDefault();
49
+ saveConfig();
50
+ });
51
+ }
52
+
53
+ function bindButtons() {
54
+ const refreshStatus = document.getElementById("refresh-status");
55
+ const refreshLogs = document.getElementById("refresh-logs");
56
+ const testVideoButton = document.getElementById("test-video");
57
+ const reloadConfigButton = document.getElementById("reload-config");
58
+
59
+ if (refreshStatus) {
60
+ refreshStatus.addEventListener("click", loadStatus);
61
+ }
62
+
63
+ if (refreshLogs) {
64
+ refreshLogs.addEventListener("click", loadLogs);
65
+ }
66
+
67
+ if (testVideoButton) {
68
+ testVideoButton.addEventListener("click", testVideo);
69
+ }
70
+
71
+ if (reloadConfigButton) {
72
+ reloadConfigButton.addEventListener("click", async () => {
73
+ await loadConfig();
74
+ await loadStatus();
75
+ });
76
+ }
77
+ }
78
+
79
+ function bindSensitivitySlider() {
80
+ const slider = document.getElementById("motion-sensitivity");
81
+ const value = document.getElementById("motion-sensitivity-value");
82
+
83
+ if (!slider || !value) {
84
+ return;
85
+ }
86
+
87
+ slider.addEventListener("input", () => {
88
+ value.textContent = slider.value;
89
+ });
90
+ }
91
+
92
+ function showStatus(message, type = "info") {
93
+ const element = document.getElementById("save-status");
94
+
95
+ if (!element) {
96
+ return;
97
+ }
98
+
99
+ element.textContent = message;
100
+ element.className = `status-msg ${type}`;
101
+
102
+ window.setTimeout(() => {
103
+ element.className = "status-msg";
104
+ }, 6000);
105
+ }
106
+
107
+ async function apiFetch(url, options = {}) {
108
+ const response = await fetch(url, {
109
+ cache: "no-store",
110
+ ...options,
111
+ });
112
+
113
+ let data = {};
114
+
115
+ try {
116
+ data = await response.json();
117
+ } catch (_error) {
118
+ data = {};
119
+ }
120
+
121
+ if (!response.ok) {
122
+ const message = data.error || `${response.status} ${response.statusText}`;
123
+ throw new Error(message);
124
+ }
125
+
126
+ return data;
127
+ }
128
+
129
+ async function loadConfig() {
130
+ try {
131
+ const config = await apiFetch("/api/config");
132
+
133
+ setValue("trigger-source", getNested(config, "trigger.source", "pir"));
134
+
135
+ setValue("camera-index", getNested(config, "camera.index", 0));
136
+ setValue("camera-width", getNested(config, "camera.width", 640));
137
+ setValue("camera-height", getNested(config, "camera.height", 480));
138
+ setValue("camera-fps", getNested(config, "camera.fps", 15));
139
+
140
+ setValue("pir-gpio-pin", getNested(config, "pir.gpio_pin", 17));
141
+ setChecked("pir-active-high", getNested(config, "pir.active_high", true));
142
+ setChecked("pir-pull-up", getNested(config, "pir.pull_up", false));
143
+ setValue("pir-bounce-time", getNested(config, "pir.bounce_time_ms", 500));
144
+ setValue("pir-settle-seconds", getNested(config, "pir.settle_seconds", 30));
145
+ setValue("pir-poll-interval", getNested(config, "pir.poll_interval", 0.05));
146
+
147
+ const sensitivity = getNested(config, "detection.motion_sensitivity", 500);
148
+ setValue("motion-sensitivity", sensitivity);
149
+ setText("motion-sensitivity-value", sensitivity);
150
+
151
+ setValue("min-motion-area", getNested(config, "detection.min_motion_area", 1500));
152
+ setValue("cooldown-seconds", getNested(config, "detection.cooldown_seconds", 10));
153
+ setValue("frame-skip", getNested(config, "detection.frame_skip", 3));
154
+
155
+ setValue("video-path", getNested(config, "video.path", "videos/selamlama.mp4"));
156
+ setValue("video-volume", getNested(config, "video.volume", 90));
157
+ setChecked("video-fullscreen", getNested(config, "video.fullscreen", true));
158
+
159
+ updateHero(config);
160
+ showStatus("Ayarlar yüklendi", "success");
161
+ } catch (error) {
162
+ showStatus(`Ayarlar yüklenemedi: ${error.message}`, "error");
163
+ }
164
+ }
165
+
166
+ async function saveConfig() {
167
+ const config = {
168
+ system: {
169
+ log_level: "INFO",
170
+ project_name: "modInteractive",
171
+ version: "1.1.0",
172
+ },
173
+ trigger: {
174
+ source: readSelect("trigger-source", "pir", ["camera", "pir"]),
175
+ },
176
+ camera: {
177
+ index: readCameraIndex("camera-index", 0),
178
+ width: readInt("camera-width", 640, 160, 3840),
179
+ height: readInt("camera-height", 480, 120, 2160),
180
+ fps: readInt("camera-fps", 15, 1, 60),
181
+ backend: "v4l2",
182
+ },
183
+ pir: {
184
+ gpio_pin: readInt("pir-gpio-pin", 17, 0, 27),
185
+ active_high: readChecked("pir-active-high", true),
186
+ pull_up: readChecked("pir-pull-up", false),
187
+ bounce_time_ms: readInt("pir-bounce-time", 500, 0, 5000),
188
+ settle_seconds: readInt("pir-settle-seconds", 30, 0, 120),
189
+ poll_interval: readFloat("pir-poll-interval", 0.05, 0.01, 5),
190
+ },
191
+ detection: {
192
+ enabled: true,
193
+ mode: "motion",
194
+ motion_sensitivity: readInt("motion-sensitivity", 500, 1, 100000),
195
+ min_motion_area: readInt("min-motion-area", 1500, 1, 100000),
196
+ frame_skip: readInt("frame-skip", 3, 1, 30),
197
+ warmup_seconds: 2,
198
+ cooldown_seconds: readInt("cooldown-seconds", 10, 0, 600),
199
+ },
200
+ video: {
201
+ path: readString("video-path", "videos/selamlama.mp4"),
202
+ fullscreen: readChecked("video-fullscreen", true),
203
+ volume: readInt("video-volume", 90, 0, 100),
204
+ player: "mpv",
205
+ },
206
+ admin: {
207
+ enabled: true,
208
+ host: "0.0.0.0",
209
+ port: 8080,
210
+ },
211
+ };
212
+
213
+ try {
214
+ const data = await apiFetch("/api/config/update", {
215
+ method: "POST",
216
+ headers: {
217
+ "Content-Type": "application/json",
218
+ },
219
+ body: JSON.stringify(config),
220
+ });
221
+
222
+ if (data.status === "ok") {
223
+ updateHero(config);
224
+ showStatus("Ayarlar kaydedildi. Değişikliklerin tamamı için servisi restart et.", "success");
225
+ await loadStatus();
226
+ } else {
227
+ showStatus(data.error || "Ayar kaydetme başarısız", "error");
228
+ }
229
+ } catch (error) {
230
+ showStatus(`Kaydedilemedi: ${error.message}`, "error");
231
+ }
232
+ }
233
+
234
+ async function loadStatus() {
235
+ const container = document.getElementById("status-content");
236
+
237
+ if (!container) {
238
+ return;
239
+ }
240
+
241
+ try {
242
+ const status = await apiFetch("/api/status");
243
+ const source = status.trigger_source || "pir";
244
+ const items = [
245
+ {
246
+ label: "Trigger",
247
+ value: source === "pir" ? "PIR GPIO" : "Camera",
248
+ fail: false,
249
+ },
250
+ {
251
+ label: "PIR",
252
+ value: status.pir_available
253
+ ? `GPIO ${status.pir_gpio_pin} • ${formatPirState(status.pir_state)}`
254
+ : source === "pir"
255
+ ? (status.pir_error || "Hazır değil")
256
+ : "Kullanılmıyor",
257
+ fail: source === "pir" && !status.pir_available,
258
+ },
259
+ {
260
+ label: "Video",
261
+ value: status.video_exists
262
+ ? `Hazır • ${status.video_size_mb || "?"} MB`
263
+ : "Bulunamadı",
264
+ fail: !status.video_exists,
265
+ },
266
+ {
267
+ label: "mpv",
268
+ value: status.mpv_available ? "Hazır" : "Eksik",
269
+ fail: !status.mpv_available,
270
+ },
271
+ {
272
+ label: "Camera",
273
+ value: source === "camera"
274
+ ? (status.camera_available ? "Hazır" : "Bulunamadı")
275
+ : "Kullanılmıyor",
276
+ fail: source === "camera" && !status.camera_available,
277
+ },
278
+ {
279
+ label: "OpenCV",
280
+ value: status.opencv_available
281
+ ? `Hazır ${status.opencv_version || ""}`
282
+ : source === "camera"
283
+ ? "Eksik"
284
+ : "PIR modunda gerekmez",
285
+ fail: source === "camera" && !status.opencv_available,
286
+ },
287
+ {
288
+ label: "Video Path",
289
+ value: status.video_path || "N/A",
290
+ fail: false,
291
+ },
292
+ {
293
+ label: "Config",
294
+ value: status.config_path || "N/A",
295
+ fail: false,
296
+ },
297
+ ];
298
+
299
+ container.innerHTML = items.map((item) => createStatusItem(item.label, item.value, item.fail)).join("");
300
+ } catch (error) {
301
+ container.innerHTML = createStatusItem("Hata", error.message, true);
302
+ }
303
+ }
304
+
305
+ async function loadLogs() {
306
+ const logContent = document.getElementById("log-content");
307
+ const logContainer = document.getElementById("log-container");
308
+
309
+ if (!logContent) {
310
+ return;
311
+ }
312
+
313
+ try {
314
+ const data = await apiFetch("/api/logs?limit=180");
315
+ const logs = Array.isArray(data.logs) ? data.logs : [];
316
+
317
+ logContent.textContent = logs.length > 0 ? logs.join("\n") : "Log bulunamadı.";
318
+
319
+ if (logContainer) {
320
+ logContainer.scrollTop = logContainer.scrollHeight;
321
+ }
322
+ } catch (error) {
323
+ logContent.textContent = `Log okunamadı: ${error.message}`;
324
+ }
325
+ }
326
+
327
+ async function testVideo() {
328
+ const button = document.getElementById("test-video");
329
+
330
+ if (!button) {
331
+ return;
332
+ }
333
+
334
+ const originalText = button.textContent;
335
+ button.textContent = "Test ediliyor...";
336
+ button.disabled = true;
337
+
338
+ try {
339
+ const data = await apiFetch("/api/test-video", {
340
+ method: "POST",
341
+ headers: {
342
+ "Content-Type": "application/json",
343
+ },
344
+ body: JSON.stringify({
345
+ path: readString("video-path", "videos/selamlama.mp4"),
346
+ }),
347
+ });
348
+
349
+ showStatus(data.message || "Video başlatıldı", "success");
350
+ } catch (error) {
351
+ showStatus(`Video testi başarısız: ${error.message}`, "error");
352
+ } finally {
353
+ window.setTimeout(() => {
354
+ button.textContent = originalText || "Test Video";
355
+ button.disabled = false;
356
+ }, 1500);
357
+ }
358
+ }
359
+
360
+ function createStatusItem(label, value, fail = false) {
361
+ const className = fail ? "status-card fail" : "status-card ok";
362
+
363
+ return `
364
+ <div class="${className}">
365
+ <span class="label">${escapeHtml(String(label))}</span>
366
+ <span class="value">${escapeHtml(String(value))}</span>
367
+ </div>
368
+ `;
369
+ }
370
+
371
+ function updateHero(config) {
372
+ setText("hero-source", String(getNested(config, "trigger.source", "pir")).toUpperCase());
373
+ setText("hero-video", getNested(config, "video.path", "videos/selamlama.mp4"));
374
+ }
375
+
376
+ function formatPirState(value) {
377
+ if (value === true) {
378
+ return "HIGH";
379
+ }
380
+
381
+ if (value === false) {
382
+ return "LOW";
383
+ }
384
+
385
+ if (value === "managed_by_application") {
386
+ return "Uygulama yönetiyor";
387
+ }
388
+
389
+ if (value === null || typeof value === "undefined") {
390
+ return "Bilinmiyor";
391
+ }
392
+
393
+ return String(value);
394
+ }
395
+
396
+ function getNested(object, path, fallback) {
397
+ const parts = path.split(".");
398
+ let value = object;
399
+
400
+ for (const key of parts) {
401
+ if (value && Object.prototype.hasOwnProperty.call(value, key)) {
402
+ value = value[key];
403
+ } else {
404
+ return fallback;
405
+ }
406
+ }
407
+
408
+ return value;
409
+ }
410
+
411
+ function setValue(id, value) {
412
+ const element = document.getElementById(id);
413
+
414
+ if (element) {
415
+ element.value = value;
416
+ }
417
+ }
418
+
419
+ function setText(id, value) {
420
+ const element = document.getElementById(id);
421
+
422
+ if (element) {
423
+ element.textContent = value;
424
+ }
425
+ }
426
+
427
+ function setChecked(id, value) {
428
+ const element = document.getElementById(id);
429
+
430
+ if (element) {
431
+ element.checked = Boolean(value);
432
+ }
433
+ }
434
+
435
+ function readString(id, fallback) {
436
+ const element = document.getElementById(id);
437
+
438
+ if (!element) {
439
+ return fallback;
440
+ }
441
+
442
+ const value = String(element.value || "").trim();
443
+ return value || fallback;
444
+ }
445
+
446
+ function readSelect(id, fallback, allowed) {
447
+ const value = readString(id, fallback).toLowerCase();
448
+
449
+ if (Array.isArray(allowed) && allowed.includes(value)) {
450
+ return value;
451
+ }
452
+
453
+ return fallback;
454
+ }
455
+
456
+ function readCameraIndex(id, fallback) {
457
+ const value = readString(id, String(fallback));
458
+
459
+ if (/^\d+$/.test(value)) {
460
+ return Number.parseInt(value, 10);
461
+ }
462
+
463
+ return value;
464
+ }
465
+
466
+ function readChecked(id, fallback) {
467
+ const element = document.getElementById(id);
468
+
469
+ if (!element) {
470
+ return fallback;
471
+ }
472
+
473
+ return Boolean(element.checked);
474
+ }
475
+
476
+ function normalizeNumeric(value) {
477
+ return String(value || "").trim().replace(",", ".");
478
+ }
479
+
480
+ function readInt(id, fallback, minimum, maximum) {
481
+ const element = document.getElementById(id);
482
+
483
+ if (!element) {
484
+ return fallback;
485
+ }
486
+
487
+ let value = Number.parseInt(normalizeNumeric(element.value), 10);
488
+
489
+ if (Number.isNaN(value)) {
490
+ value = fallback;
491
+ }
492
+
493
+ if (typeof minimum === "number" && value < minimum) {
494
+ value = minimum;
495
+ }
496
+
497
+ if (typeof maximum === "number" && value > maximum) {
498
+ value = maximum;
499
+ }
500
+
501
+ return value;
502
+ }
503
+
504
+ function readFloat(id, fallback, minimum, maximum) {
505
+ const element = document.getElementById(id);
506
+
507
+ if (!element) {
508
+ return fallback;
509
+ }
510
+
511
+ let value = Number.parseFloat(normalizeNumeric(element.value));
512
+
513
+ if (Number.isNaN(value)) {
514
+ value = fallback;
515
+ }
516
+
517
+ if (typeof minimum === "number" && value < minimum) {
518
+ value = minimum;
519
+ }
520
+
521
+ if (typeof maximum === "number" && value > maximum) {
522
+ value = maximum;
523
+ }
524
+
525
+ return value;
526
+ }
527
+
528
+ function escapeHtml(value) {
529
+ return value
530
+ .replace(/&/g, "&amp;")
531
+ .replace(/</g, "&lt;")
532
+ .replace(/>/g, "&gt;")
533
+ .replace(/"/g, "&quot;")
534
+ .replace(/'/g, "&#039;");
535
+ }
admin/static/style.css ADDED
@@ -0,0 +1,658 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #08111f;
3
+ --panel: rgba(15, 23, 42, 0.86);
4
+ --panel-strong: rgba(15, 23, 42, 0.96);
5
+ --card: rgba(255, 255, 255, 0.08);
6
+ --card-border: rgba(148, 163, 184, 0.18);
7
+ --text: #f8fafc;
8
+ --muted: #94a3b8;
9
+ --muted-2: #64748b;
10
+ --green: #22c55e;
11
+ --green-soft: rgba(34, 197, 94, 0.14);
12
+ --blue: #38bdf8;
13
+ --red: #fb7185;
14
+ --yellow: #fbbf24;
15
+ --input: rgba(2, 6, 23, 0.54);
16
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
17
+ --radius: 24px;
18
+ --sidebar: 290px;
19
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20
+ }
21
+
22
+ * {
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ html {
27
+ min-height: 100%;
28
+ background: var(--bg);
29
+ }
30
+
31
+ body {
32
+ min-height: 100vh;
33
+ margin: 0;
34
+ color: var(--text);
35
+ background:
36
+ radial-gradient(circle at top left, rgba(34, 197, 94, 0.22), transparent 30rem),
37
+ radial-gradient(circle at top right, rgba(56, 189, 248, 0.18), transparent 28rem),
38
+ linear-gradient(135deg, #020617 0%, #0f172a 50%, #111827 100%);
39
+ }
40
+
41
+ button, input, select {
42
+ font: inherit;
43
+ }
44
+
45
+ .shell {
46
+ width: min(1440px, 100%);
47
+ margin: 0 auto;
48
+ display: grid;
49
+ grid-template-columns: var(--sidebar) minmax(0, 1fr);
50
+ min-height: 100vh;
51
+ }
52
+
53
+ .sidebar {
54
+ position: sticky;
55
+ top: 0;
56
+ height: 100vh;
57
+ padding: 28px 20px;
58
+ border-right: 1px solid rgba(148, 163, 184, 0.16);
59
+ background: rgba(2, 6, 23, 0.42);
60
+ backdrop-filter: blur(20px);
61
+ }
62
+
63
+ .brand {
64
+ display: flex;
65
+ gap: 14px;
66
+ align-items: center;
67
+ color: var(--text);
68
+ text-decoration: none;
69
+ margin-bottom: 30px;
70
+ }
71
+
72
+ .brand-mark {
73
+ width: 48px;
74
+ height: 48px;
75
+ display: grid;
76
+ place-items: center;
77
+ border-radius: 16px;
78
+ background: linear-gradient(135deg, var(--green), var(--blue));
79
+ color: #02130a;
80
+ font-weight: 950;
81
+ font-size: 28px;
82
+ box-shadow: 0 16px 40px rgba(34, 197, 94, 0.25);
83
+ }
84
+
85
+ .brand strong,
86
+ .brand small {
87
+ display: block;
88
+ }
89
+
90
+ .brand small {
91
+ color: var(--muted);
92
+ margin-top: 2px;
93
+ }
94
+
95
+ .nav-tabs {
96
+ display: grid;
97
+ gap: 10px;
98
+ }
99
+
100
+ .tab {
101
+ width: 100%;
102
+ border: 1px solid transparent;
103
+ border-radius: 18px;
104
+ padding: 14px 16px;
105
+ background: transparent;
106
+ color: var(--muted);
107
+ text-align: left;
108
+ cursor: pointer;
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 10px;
112
+ transition: 160ms ease;
113
+ }
114
+
115
+ .tab:hover,
116
+ .tab.active {
117
+ color: var(--text);
118
+ background: rgba(255, 255, 255, 0.08);
119
+ border-color: rgba(255, 255, 255, 0.10);
120
+ }
121
+
122
+ .tab.active {
123
+ box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.25);
124
+ }
125
+
126
+ .sidebar-card {
127
+ margin-top: 28px;
128
+ padding: 16px;
129
+ border-radius: 20px;
130
+ background: var(--green-soft);
131
+ border: 1px solid rgba(34, 197, 94, 0.28);
132
+ display: flex;
133
+ align-items: flex-start;
134
+ gap: 12px;
135
+ }
136
+
137
+ .sidebar-card strong,
138
+ .sidebar-card small {
139
+ display: block;
140
+ }
141
+
142
+ .sidebar-card small {
143
+ color: #bbf7d0;
144
+ margin-top: 4px;
145
+ }
146
+
147
+ .pulse {
148
+ width: 12px;
149
+ height: 12px;
150
+ border-radius: 99px;
151
+ background: var(--green);
152
+ box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
153
+ animation: pulse 1.6s infinite;
154
+ margin-top: 4px;
155
+ }
156
+
157
+ .main {
158
+ padding: 28px;
159
+ min-width: 0;
160
+ }
161
+
162
+ .hero {
163
+ min-height: 210px;
164
+ border-radius: 32px;
165
+ padding: clamp(22px, 4vw, 42px);
166
+ background:
167
+ linear-gradient(135deg, rgba(34, 197, 94, 0.24), rgba(56, 189, 248, 0.08)),
168
+ rgba(15, 23, 42, 0.72);
169
+ border: 1px solid rgba(148, 163, 184, 0.18);
170
+ box-shadow: var(--shadow);
171
+ display: flex;
172
+ align-items: flex-end;
173
+ justify-content: space-between;
174
+ gap: 24px;
175
+ overflow: hidden;
176
+ position: relative;
177
+ }
178
+
179
+ .hero::after {
180
+ content: "";
181
+ position: absolute;
182
+ right: -90px;
183
+ top: -120px;
184
+ width: 360px;
185
+ height: 360px;
186
+ border-radius: 50%;
187
+ background: radial-gradient(circle, rgba(34, 197, 94, 0.20), transparent 70%);
188
+ }
189
+
190
+ .eyebrow {
191
+ color: #86efac;
192
+ letter-spacing: 0.14em;
193
+ text-transform: uppercase;
194
+ font-size: 12px;
195
+ font-weight: 800;
196
+ margin: 0 0 12px;
197
+ }
198
+
199
+ h1, h2, h3, p {
200
+ margin-top: 0;
201
+ }
202
+
203
+ h1 {
204
+ font-size: clamp(34px, 6vw, 64px);
205
+ line-height: 0.95;
206
+ letter-spacing: -0.06em;
207
+ margin-bottom: 16px;
208
+ }
209
+
210
+ .hero-text {
211
+ max-width: 680px;
212
+ color: #dbeafe;
213
+ font-size: 18px;
214
+ margin-bottom: 0;
215
+ }
216
+
217
+ .hero-status {
218
+ position: relative;
219
+ z-index: 1;
220
+ min-width: 230px;
221
+ padding: 20px;
222
+ border-radius: 22px;
223
+ background: rgba(2, 6, 23, 0.42);
224
+ border: 1px solid rgba(255, 255, 255, 0.12);
225
+ }
226
+
227
+ .hero-status span {
228
+ display: block;
229
+ color: var(--green);
230
+ font-size: 34px;
231
+ font-weight: 900;
232
+ }
233
+
234
+ .hero-status small {
235
+ color: var(--muted);
236
+ overflow-wrap: anywhere;
237
+ }
238
+
239
+ .tab-content {
240
+ display: none;
241
+ margin-top: 28px;
242
+ }
243
+
244
+ .tab-content.active {
245
+ display: block;
246
+ }
247
+
248
+ .section-title {
249
+ display: flex;
250
+ justify-content: space-between;
251
+ gap: 18px;
252
+ align-items: center;
253
+ margin-bottom: 18px;
254
+ }
255
+
256
+ .section-title h2 {
257
+ font-size: clamp(28px, 4vw, 42px);
258
+ letter-spacing: -0.04em;
259
+ margin-bottom: 6px;
260
+ }
261
+
262
+ .section-title p {
263
+ color: var(--muted);
264
+ margin-bottom: 0;
265
+ }
266
+
267
+ .form-grid {
268
+ display: grid;
269
+ grid-template-columns: repeat(2, minmax(0, 1fr));
270
+ gap: 18px;
271
+ }
272
+
273
+ .card {
274
+ padding: 22px;
275
+ border-radius: var(--radius);
276
+ background: var(--panel);
277
+ border: 1px solid var(--card-border);
278
+ box-shadow: 0 18px 60px rgba(0, 0, 0, 0.22);
279
+ }
280
+
281
+ .card.wide {
282
+ grid-column: 1 / -1;
283
+ }
284
+
285
+ .card-head {
286
+ display: flex;
287
+ gap: 14px;
288
+ align-items: flex-start;
289
+ margin-bottom: 18px;
290
+ }
291
+
292
+ .icon {
293
+ width: 44px;
294
+ height: 44px;
295
+ display: grid;
296
+ place-items: center;
297
+ border-radius: 16px;
298
+ background: rgba(255, 255, 255, 0.08);
299
+ flex: 0 0 auto;
300
+ }
301
+
302
+ .card h3 {
303
+ margin-bottom: 4px;
304
+ letter-spacing: -0.02em;
305
+ }
306
+
307
+ .card p,
308
+ .hint {
309
+ color: var(--muted);
310
+ margin: 0;
311
+ }
312
+
313
+ .hint {
314
+ margin-top: 14px;
315
+ font-size: 13px;
316
+ line-height: 1.5;
317
+ padding: 12px;
318
+ border-radius: 14px;
319
+ background: rgba(34, 197, 94, 0.08);
320
+ border: 1px solid rgba(34, 197, 94, 0.14);
321
+ }
322
+
323
+ .fields {
324
+ display: grid;
325
+ gap: 14px;
326
+ }
327
+
328
+ .fields.two {
329
+ grid-template-columns: repeat(2, minmax(0, 1fr));
330
+ }
331
+
332
+ .fields.three {
333
+ grid-template-columns: repeat(3, minmax(0, 1fr));
334
+ }
335
+
336
+ .field {
337
+ display: grid;
338
+ gap: 8px;
339
+ color: #dbeafe;
340
+ font-size: 14px;
341
+ font-weight: 700;
342
+ margin-bottom: 14px;
343
+ }
344
+
345
+ .field span {
346
+ display: flex;
347
+ align-items: center;
348
+ justify-content: space-between;
349
+ gap: 10px;
350
+ }
351
+
352
+ .field b {
353
+ color: var(--green);
354
+ font-variant-numeric: tabular-nums;
355
+ }
356
+
357
+ input,
358
+ select {
359
+ width: 100%;
360
+ min-height: 48px;
361
+ border: 1px solid rgba(148, 163, 184, 0.20);
362
+ border-radius: 16px;
363
+ background: var(--input);
364
+ color: var(--text);
365
+ padding: 12px 14px;
366
+ outline: none;
367
+ transition: 140ms ease;
368
+ }
369
+
370
+ select {
371
+ appearance: none;
372
+ background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
373
+ background-position: calc(100% - 18px) 20px, calc(100% - 12px) 20px;
374
+ background-size: 6px 6px, 6px 6px;
375
+ background-repeat: no-repeat;
376
+ padding-right: 38px;
377
+ }
378
+
379
+ input:focus,
380
+ select:focus {
381
+ border-color: rgba(34, 197, 94, 0.70);
382
+ box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
383
+ }
384
+
385
+ input[type="range"] {
386
+ padding: 0;
387
+ min-height: 38px;
388
+ accent-color: var(--green);
389
+ }
390
+
391
+ .switch-row {
392
+ display: flex;
393
+ gap: 12px;
394
+ flex-wrap: wrap;
395
+ }
396
+
397
+ .switch {
398
+ display: inline-flex;
399
+ align-items: center;
400
+ gap: 10px;
401
+ min-height: 42px;
402
+ color: #e2e8f0;
403
+ cursor: pointer;
404
+ user-select: none;
405
+ }
406
+
407
+ .switch input {
408
+ position: absolute;
409
+ opacity: 0;
410
+ pointer-events: none;
411
+ }
412
+
413
+ .switch span {
414
+ width: 48px;
415
+ height: 28px;
416
+ border-radius: 99px;
417
+ background: rgba(148, 163, 184, 0.22);
418
+ border: 1px solid rgba(148, 163, 184, 0.22);
419
+ position: relative;
420
+ transition: 160ms ease;
421
+ }
422
+
423
+ .switch span::after {
424
+ content: "";
425
+ position: absolute;
426
+ width: 22px;
427
+ height: 22px;
428
+ top: 2px;
429
+ left: 2px;
430
+ border-radius: 50%;
431
+ background: #f8fafc;
432
+ transition: 160ms ease;
433
+ }
434
+
435
+ .switch input:checked + span {
436
+ background: var(--green);
437
+ }
438
+
439
+ .switch input:checked + span::after {
440
+ transform: translateX(20px);
441
+ }
442
+
443
+ .video-switch {
444
+ margin-top: 30px;
445
+ }
446
+
447
+ .btn {
448
+ border: 0;
449
+ border-radius: 16px;
450
+ min-height: 48px;
451
+ padding: 0 18px;
452
+ font-weight: 850;
453
+ cursor: pointer;
454
+ color: var(--text);
455
+ background: rgba(255, 255, 255, 0.10);
456
+ transition: 160ms ease;
457
+ }
458
+
459
+ .btn:hover {
460
+ transform: translateY(-1px);
461
+ filter: brightness(1.08);
462
+ }
463
+
464
+ .btn.primary {
465
+ background: linear-gradient(135deg, var(--green), #14b8a6);
466
+ color: #03140b;
467
+ }
468
+
469
+ .btn.secondary {
470
+ background: rgba(56, 189, 248, 0.14);
471
+ color: #dff8ff;
472
+ border: 1px solid rgba(56, 189, 248, 0.28);
473
+ }
474
+
475
+ .btn.ghost {
476
+ background: rgba(255, 255, 255, 0.07);
477
+ border: 1px solid rgba(255, 255, 255, 0.10);
478
+ }
479
+
480
+ .btn.full {
481
+ width: 100%;
482
+ }
483
+
484
+ .save-bar {
485
+ grid-column: 1 / -1;
486
+ position: sticky;
487
+ bottom: 16px;
488
+ z-index: 5;
489
+ display: flex;
490
+ align-items: center;
491
+ justify-content: space-between;
492
+ gap: 14px;
493
+ padding: 14px;
494
+ border-radius: 22px;
495
+ background: rgba(2, 6, 23, 0.82);
496
+ border: 1px solid rgba(148, 163, 184, 0.20);
497
+ backdrop-filter: blur(18px);
498
+ box-shadow: var(--shadow);
499
+ }
500
+
501
+ .status-msg {
502
+ color: var(--muted);
503
+ font-weight: 700;
504
+ }
505
+
506
+ .status-msg.success {
507
+ color: #86efac;
508
+ }
509
+
510
+ .status-msg.error {
511
+ color: #fecdd3;
512
+ }
513
+
514
+ .status-msg.info {
515
+ color: #bae6fd;
516
+ }
517
+
518
+ .status-grid {
519
+ display: grid;
520
+ grid-template-columns: repeat(3, minmax(0, 1fr));
521
+ gap: 16px;
522
+ }
523
+
524
+ .status-card {
525
+ padding: 18px;
526
+ border-radius: 20px;
527
+ background: var(--panel);
528
+ border: 1px solid var(--card-border);
529
+ }
530
+
531
+ .status-card .label {
532
+ color: var(--muted);
533
+ font-size: 13px;
534
+ font-weight: 700;
535
+ }
536
+
537
+ .status-card .value {
538
+ margin-top: 8px;
539
+ display: block;
540
+ color: var(--text);
541
+ font-size: 18px;
542
+ font-weight: 850;
543
+ overflow-wrap: anywhere;
544
+ }
545
+
546
+ .status-card.ok {
547
+ border-color: rgba(34, 197, 94, 0.28);
548
+ }
549
+
550
+ .status-card.fail {
551
+ border-color: rgba(251, 113, 133, 0.35);
552
+ }
553
+
554
+ .status-card.fail .value {
555
+ color: #fecdd3;
556
+ }
557
+
558
+ .log-panel {
559
+ border-radius: 24px;
560
+ border: 1px solid var(--card-border);
561
+ background: #020617;
562
+ overflow: auto;
563
+ max-height: 70vh;
564
+ box-shadow: var(--shadow);
565
+ }
566
+
567
+ #log-content {
568
+ margin: 0;
569
+ padding: 20px;
570
+ color: #bbf7d0;
571
+ font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
572
+ white-space: pre-wrap;
573
+ }
574
+
575
+ @keyframes pulse {
576
+ 70% {
577
+ box-shadow: 0 0 0 14px rgba(34, 197, 94, 0);
578
+ }
579
+ 100% {
580
+ box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
581
+ }
582
+ }
583
+
584
+ @media (max-width: 980px) {
585
+ .shell {
586
+ display: block;
587
+ }
588
+
589
+ .sidebar {
590
+ position: static;
591
+ height: auto;
592
+ padding: 16px;
593
+ border-right: 0;
594
+ border-bottom: 1px solid rgba(148, 163, 184, 0.16);
595
+ }
596
+
597
+ .nav-tabs {
598
+ display: flex;
599
+ overflow-x: auto;
600
+ padding-bottom: 4px;
601
+ }
602
+
603
+ .tab {
604
+ width: auto;
605
+ min-width: 126px;
606
+ justify-content: center;
607
+ white-space: nowrap;
608
+ }
609
+
610
+ .sidebar-card {
611
+ display: none;
612
+ }
613
+
614
+ .main {
615
+ padding: 16px;
616
+ }
617
+
618
+ .hero {
619
+ min-height: auto;
620
+ display: block;
621
+ }
622
+
623
+ .hero-status {
624
+ margin-top: 18px;
625
+ min-width: 0;
626
+ }
627
+
628
+ .form-grid,
629
+ .status-grid {
630
+ grid-template-columns: 1fr;
631
+ }
632
+ }
633
+
634
+ @media (max-width: 620px) {
635
+ .fields.two,
636
+ .fields.three {
637
+ grid-template-columns: 1fr;
638
+ }
639
+
640
+ .card,
641
+ .hero {
642
+ border-radius: 22px;
643
+ }
644
+
645
+ .save-bar {
646
+ left: 12px;
647
+ right: 12px;
648
+ display: grid;
649
+ }
650
+
651
+ .save-bar .btn {
652
+ width: 100%;
653
+ }
654
+
655
+ h1 {
656
+ font-size: 38px;
657
+ }
658
+ }
admin/templates/index.html ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="tr">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0f172a">
7
+ <title>modInteractive Admin</title>
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230f172a'/%3E%3Cpath d='M16 42V22l16 10 16-10v20' fill='none' stroke='%2322c55e' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
9
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=20260627">
10
+ </head>
11
+ <body>
12
+ <div class="shell">
13
+ <aside class="sidebar">
14
+ <a class="brand" href="/">
15
+ <span class="brand-mark">m</span>
16
+ <span>
17
+ <strong>modInteractive</strong>
18
+ <small>Pi5 Kiosk Control</small>
19
+ </span>
20
+ </a>
21
+
22
+ <nav class="nav-tabs" aria-label="Admin tabs">
23
+ <button class="tab active" type="button" data-tab="config">
24
+ <span>⚙️</span> Ayarlar
25
+ </button>
26
+ <button class="tab" type="button" data-tab="status">
27
+ <span>📡</span> Durum
28
+ </button>
29
+ <button class="tab" type="button" data-tab="logs">
30
+ <span>🧾</span> Loglar
31
+ </button>
32
+ </nav>
33
+
34
+ <div class="sidebar-card">
35
+ <span class="pulse"></span>
36
+ <div>
37
+ <strong>PIR-first mode</strong>
38
+ <small>GPIO hareket algılayınca video başlar.</small>
39
+ </div>
40
+ </div>
41
+ </aside>
42
+
43
+ <main class="main">
44
+ <header class="hero">
45
+ <div>
46
+ <p class="eyebrow">Cafeler • Mağazalar • Kiosk ekranlar</p>
47
+ <h1>Motion Triggered HDMI Video Display</h1>
48
+ <p class="hero-text">PIR sensör veya kamera ile Raspberry Pi 5 üzerinde tam ekran video tetikleme paneli.</p>
49
+ </div>
50
+ <div class="hero-status">
51
+ <span id="hero-source">PIR</span>
52
+ <small id="hero-video">videos/selamlama.mp4</small>
53
+ </div>
54
+ </header>
55
+
56
+ <section id="config" class="tab-content active">
57
+ <div class="section-title">
58
+ <div>
59
+ <h2>Ayarlar</h2>
60
+ <p>PIR, kamera, video ve bekleme davranışlarını buradan yönet.</p>
61
+ </div>
62
+ <button type="button" class="btn ghost" id="reload-config">Yenile</button>
63
+ </div>
64
+
65
+ <form id="config-form" class="form-grid">
66
+ <article class="card wide">
67
+ <div class="card-head">
68
+ <span class="icon">🎛️</span>
69
+ <div>
70
+ <h3>Tetik Kaynağı</h3>
71
+ <p>Varsayılan kullanım PIR sensör, kamera opsiyonel.</p>
72
+ </div>
73
+ </div>
74
+
75
+ <label class="field">
76
+ <span>Source</span>
77
+ <select name="trigger.source" id="trigger-source">
78
+ <option value="pir">PIR GPIO sensor</option>
79
+ <option value="camera">Camera motion detection</option>
80
+ </select>
81
+ </label>
82
+ </article>
83
+
84
+ <article class="card">
85
+ <div class="card-head">
86
+ <span class="icon">📡</span>
87
+ <div>
88
+ <h3>PIR Sensor</h3>
89
+ <p>GPIO pin ve sinyal davranışı.</p>
90
+ </div>
91
+ </div>
92
+
93
+ <div class="fields two">
94
+ <label class="field">
95
+ <span>BCM GPIO Pin</span>
96
+ <input type="number" name="pir.gpio_pin" id="pir-gpio-pin" min="0" max="27" step="1" inputmode="numeric">
97
+ </label>
98
+
99
+ <label class="field">
100
+ <span>Bounce Time ms</span>
101
+ <input type="number" name="pir.bounce_time_ms" id="pir-bounce-time" min="0" max="5000" step="50" inputmode="numeric">
102
+ </label>
103
+
104
+ <label class="field">
105
+ <span>Warmup Seconds</span>
106
+ <input type="number" name="pir.settle_seconds" id="pir-settle-seconds" min="0" max="120" step="1" inputmode="numeric">
107
+ </label>
108
+
109
+ <label class="field">
110
+ <span>Poll Interval Seconds</span>
111
+ <input type="number" name="pir.poll_interval" id="pir-poll-interval" min="0.01" max="5" step="0.01" inputmode="decimal">
112
+ </label>
113
+ </div>
114
+
115
+ <div class="switch-row">
116
+ <label class="switch">
117
+ <input type="checkbox" name="pir.active_high" id="pir-active-high">
118
+ <span></span>
119
+ Active high
120
+ </label>
121
+
122
+ <label class="switch">
123
+ <input type="checkbox" name="pir.pull_up" id="pir-pull-up">
124
+ <span></span>
125
+ Internal pull-up
126
+ </label>
127
+ </div>
128
+
129
+ <div class="hint">
130
+ SR602 için genelde Active high açık, Internal pull-up kapalı kalır.
131
+ </div>
132
+ </article>
133
+
134
+ <article class="card">
135
+ <div class="card-head">
136
+ <span class="icon">🎬</span>
137
+ <div>
138
+ <h3>Video</h3>
139
+ <p>HDMI ekrana basılacak dosya.</p>
140
+ </div>
141
+ </div>
142
+
143
+ <label class="field">
144
+ <span>Video Path</span>
145
+ <input type="text" name="video.path" id="video-path" placeholder="videos/selamlama.mp4" autocomplete="off">
146
+ </label>
147
+
148
+ <div class="fields two">
149
+ <label class="field">
150
+ <span>Volume</span>
151
+ <input type="number" name="video.volume" id="video-volume" min="0" max="100" step="1" inputmode="numeric">
152
+ </label>
153
+
154
+ <label class="switch video-switch">
155
+ <input type="checkbox" name="video.fullscreen" id="video-fullscreen">
156
+ <span></span>
157
+ Fullscreen playback
158
+ </label>
159
+ </div>
160
+
161
+ <button type="button" class="btn secondary full" id="test-video">Test Video</button>
162
+ </article>
163
+
164
+ <article class="card">
165
+ <div class="card-head">
166
+ <span class="icon">🧠</span>
167
+ <div>
168
+ <h3>Algılama</h3>
169
+ <p>PIR için cooldown, kamera için hareket eşiği.</p>
170
+ </div>
171
+ </div>
172
+
173
+ <label class="field range-field">
174
+ <span>Motion Sensitivity <b id="motion-sensitivity-value">-</b></span>
175
+ <input type="range" name="detection.motion_sensitivity" id="motion-sensitivity" min="1" max="100000" step="1">
176
+ </label>
177
+
178
+ <div class="fields two">
179
+ <label class="field">
180
+ <span>Minimum Motion Area</span>
181
+ <input type="number" name="detection.min_motion_area" id="min-motion-area" min="1" max="100000" step="1" inputmode="numeric">
182
+ </label>
183
+
184
+ <label class="field">
185
+ <span>Cooldown Seconds</span>
186
+ <input type="number" name="detection.cooldown_seconds" id="cooldown-seconds" min="0" max="600" step="1" inputmode="numeric">
187
+ </label>
188
+
189
+ <label class="field">
190
+ <span>Frame Skip</span>
191
+ <input type="number" name="detection.frame_skip" id="frame-skip" min="1" max="30" step="1" inputmode="numeric">
192
+ </label>
193
+ </div>
194
+ </article>
195
+
196
+ <article class="card">
197
+ <div class="card-head">
198
+ <span class="icon">📷</span>
199
+ <div>
200
+ <h3>Kamera</h3>
201
+ <p>Kamera moduna geçersen kullanılır.</p>
202
+ </div>
203
+ </div>
204
+
205
+ <label class="field">
206
+ <span>Camera Device Index</span>
207
+ <input type="text" name="camera.index" id="camera-index" placeholder="0" autocomplete="off">
208
+ </label>
209
+
210
+ <div class="fields three">
211
+ <label class="field">
212
+ <span>Width</span>
213
+ <input type="number" name="camera.width" id="camera-width" min="160" max="3840" step="1" inputmode="numeric">
214
+ </label>
215
+
216
+ <label class="field">
217
+ <span>Height</span>
218
+ <input type="number" name="camera.height" id="camera-height" min="120" max="2160" step="1" inputmode="numeric">
219
+ </label>
220
+
221
+ <label class="field">
222
+ <span>FPS</span>
223
+ <input type="number" name="camera.fps" id="camera-fps" min="1" max="60" step="1" inputmode="numeric">
224
+ </label>
225
+ </div>
226
+ </article>
227
+
228
+ <div class="save-bar">
229
+ <div id="save-status" class="status-msg" role="status" aria-live="polite">Hazır</div>
230
+ <button type="submit" class="btn primary">Ayarları Kaydet</button>
231
+ </div>
232
+ </form>
233
+ </section>
234
+
235
+ <section id="status" class="tab-content">
236
+ <div class="section-title">
237
+ <div>
238
+ <h2>Sistem Durumu</h2>
239
+ <p>Kamera/PIR, video player, GPIO ve dosya durumu.</p>
240
+ </div>
241
+ <button type="button" class="btn ghost" id="refresh-status">Durumu Yenile</button>
242
+ </div>
243
+
244
+ <div id="status-content" class="status-grid">
245
+ <div class="status-card loading">Yükleniyor...</div>
246
+ </div>
247
+ </section>
248
+
249
+ <section id="logs" class="tab-content">
250
+ <div class="section-title">
251
+ <div>
252
+ <h2>Loglar</h2>
253
+ <p>Son uygulama hareketleri ve hatalar.</p>
254
+ </div>
255
+ <button type="button" class="btn ghost" id="refresh-logs">Logları Yenile</button>
256
+ </div>
257
+
258
+ <div id="log-container" class="log-panel">
259
+ <pre id="log-content">Loading logs...</pre>
260
+ </div>
261
+ </section>
262
+ </main>
263
+ </div>
264
+
265
+ <script src="{{ url_for('static', filename='app.js') }}?v=20260627"></script>
266
+ </body>
267
+ </html>
apply_modern_admin_patch.sh ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ TARGETS=("/opt/modInteractive" "$HOME/modInteractive")
6
+
7
+ sudo systemctl stop modinteractive 2>/dev/null || true
8
+ pkill mpv 2>/dev/null || true
9
+ rm -f /tmp/modinteractive-mpv.sock
10
+
11
+ for target in "${TARGETS[@]}"; do
12
+ if [[ -d "$target" ]]; then
13
+ mkdir -p "$target/admin/templates" "$target/admin/static"
14
+ cp "$SRC_DIR/admin/templates/index.html" "$target/admin/templates/index.html"
15
+ cp "$SRC_DIR/admin/static/app.js" "$target/admin/static/app.js"
16
+ cp "$SRC_DIR/admin/static/style.css" "$target/admin/static/style.css"
17
+ cp "$SRC_DIR/admin/server.py" "$target/admin/server.py"
18
+ echo "patched $target"
19
+ fi
20
+ done
21
+
22
+ python3 -m py_compile /opt/modInteractive/admin/server.py
23
+
24
+ echo "OK: modern admin patch applied"
25
+ echo "Start manually: cd /opt/modInteractive && /opt/modInteractive/venv/bin/python main.py --source pir"
26
+ echo "Or service: sudo systemctl restart modinteractive"