commit 04c5db785aa1de1b7b86e8a9d11e9cd1485ad2ef Author: Helva Date: Fri Jan 30 22:21:55 2026 +0100 add face diff --git a/README.md b/README.md new file mode 100644 index 0000000..2145ad2 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Helva Roboter + +We build a robot with raspberry and arduino. + +## Checkout Repo +cd /opt +git checkout https://git.dyn.mcf.at/Max/helva-robot.git + +## Raspberry Face + +### Install Packages +sudo apt update +sudo apt install -y nginx python3 python3-venv + +sudo mkdir -p /opt/face +sudo chown -R $USER:$USER /opt/face + +python3 -m venv /opt/face/venv +/opt/face/venv/bin/pip install --upgrade pip +/opt/face/venv/bin/pip install fastapi uvicorn + +### Exchange Nginx Web-Folder +sudo rm -r /var/www/html +sudo ln -s /opt/helva-robot/face/var/www/html/ /var/www/ + +### Permissions of Webfolder +sudo chown -R www-data:www-data /var/www/html + +### Nginx Vhost +sudo ln -s /opt/helva-robot/face/etc/nginx/sites-available/face /etc/nginx/sites-enabled/ + +### Restart nginx +sudo systemctl restart nginx + +### Test Face Server +In Browser: http:/// + +From Somewhere: +curl -X POST http:///api/emotion/happy +curl -X POST http:///api/emotion/angry +curl -X POST http:///api/emotion/sleepy + +### systemd Service +sudo ln -s /opt/helva-robot/face/etc/systemd/system/face.service /etc/systemd/system/ + +sudo chown -R www-data:www-data /opt/face +sudo chmod -R 755 /opt/face + +Start: +sudo systemctl daemon-reload +sudo systemctl enable --now face.service +sudo systemctl status face.service --no-pager + + + diff --git a/face/etc/nginx/sites-available/face b/face/etc/nginx/sites-available/face new file mode 100644 index 0000000..acd1aa3 --- /dev/null +++ b/face/etc/nginx/sites-available/face @@ -0,0 +1,34 @@ +server { + listen 80; + server_name _; + + root /opt/helva-robot/face/var/www/html; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + # API -> backend + location /api/ { + proxy_pass http://127.0.0.1:8001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # SSE stream -> backend + location /events { + proxy_pass http://127.0.0.1:8001/events; + proxy_http_version 1.1; + proxy_set_header Host $host; + + proxy_buffering off; # IMPORTANT for SSE + proxy_cache off; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + + add_header Cache-Control no-cache; + } +} + diff --git a/face/etc/systemd/system/face.service b/face/etc/systemd/system/face.service new file mode 100644 index 0000000..ee5afca --- /dev/null +++ b/face/etc/systemd/system/face.service @@ -0,0 +1,17 @@ +[Unit] +Description=Robot Face SSE Server +After=network.target + +[Service] +WorkingDirectory=/opt/face +ExecStart=/opt/face/venv/bin/python -m uvicorn face_server:app --host 127.0.0.1 --port 8001 --app-dir /opt/face +Restart=always +RestartSec=1 + +# Tipp: eigener User ist sauber, aber www-data geht auch. +User=www-data +Group=www-data + +[Install] +WantedBy=multi-user.target + diff --git a/face/opt/face/__pycache__/face_server.cpython-313.pyc b/face/opt/face/__pycache__/face_server.cpython-313.pyc new file mode 100755 index 0000000..bf660ca Binary files /dev/null and b/face/opt/face/__pycache__/face_server.cpython-313.pyc differ diff --git a/face/opt/face/face_server.py b/face/opt/face/face_server.py new file mode 100755 index 0000000..dfec0ec --- /dev/null +++ b/face/opt/face/face_server.py @@ -0,0 +1,64 @@ +import asyncio +from fastapi import FastAPI, Request +from fastapi.responses import StreamingResponse, JSONResponse + +app = FastAPI() + +clients: set[asyncio.Queue[str]] = set() +current_emotion = "neutral" + +def sse(event: str, data: str) -> str: + # SSE format: event + data + blank line + return f"event: {event}\ndata: {data}\n\n" + +@app.get("/events") +async def events(request: Request): + """ + Browser connects here via EventSource. We stream emotion updates. + """ + q: asyncio.Queue[str] = asyncio.Queue() + clients.add(q) + + async def gen(): + try: + # send current state immediately + yield sse("emotion", current_emotion) + + while True: + # abort if client disconnected + if await request.is_disconnected(): + break + + msg = await q.get() + yield sse("emotion", msg) + finally: + clients.discard(q) + + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # helpful behind nginx + } + return StreamingResponse(gen(), media_type="text/event-stream", headers=headers) + +@app.post("/api/emotion/{name}") +async def set_emotion(name: str): + global current_emotion + current_emotion = name + + # broadcast to all connected clients + dead = [] + for q in clients: + try: + q.put_nowait(name) + except Exception: + dead.append(q) + for q in dead: + clients.discard(q) + + return JSONResponse({"ok": True, "emotion": current_emotion}) + +@app.get("/api/emotion") +async def get_emotion(): + return {"emotion": current_emotion} + diff --git a/face/var/www/html/app.js b/face/var/www/html/app.js new file mode 100755 index 0000000..1288081 --- /dev/null +++ b/face/var/www/html/app.js @@ -0,0 +1,81 @@ +const label = document.getElementById("label"); +const eyes = Array.from(document.querySelectorAll(".eye")); +const mouthShape = document.querySelector(".mouth-shape"); + +let current = "neutral"; + +function setEmotion(name) { + const safe = String(name || "neutral") + .toLowerCase() + .replace(/[^a-z0-9_-]/g, ""); + + current = safe; + document.body.className = `emotion-${safe}`; + label.textContent = safe; + + // Sonderfall: surprised -> O-Mund aktivieren + if (safe === "surprised") document.body.classList.add("has-omouth"); + else document.body.classList.remove("has-omouth"); + + // sad -> frown pseudo aktivieren (eigene Klasse) + if (safe === "sad" || safe === "angry") mouthShape.classList.add("frown"); + else mouthShape.classList.remove("frown"); +} + +/** Pupillen bewegen sich sanft, damit es "lebendig" wirkt */ +function startPupilWander() { + const tick = () => { + // kleine zufällige Offsets in Pixel (auf großen Displays reicht das) + const x = Math.round((Math.random() * 2 - 1) * 10); + const y = Math.round((Math.random() * 2 - 1) * 8); + + document.documentElement.style.setProperty("--pupil-x", `${x}px`); + document.documentElement.style.setProperty("--pupil-y", `${y}px`); + + // alle 600-1400ms neu + const next = 600 + Math.random() * 800; + setTimeout(tick, next); + }; + tick(); +} + +/** Blinzeln: in zufälligen Abständen, manchmal doppelt */ +function blinkOnce() { + eyes.forEach(e => e.classList.add("blink")); + setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120); +} + +function startBlinking() { + const loop = () => { + // sleepy blinzelt öfter/langsamer, angry etwas "härter" + let base = 3500; + if (current === "sleepy") base = 2200; + if (current === "surprised") base = 4200; + + const next = base + Math.random() * 2200; + setTimeout(() => { + blinkOnce(); + + // 15% chance auf Doppelt-Blink + if (Math.random() < 0.15) setTimeout(blinkOnce, 220); + + loop(); + }, next); + }; + loop(); +} + +function connect() { + const es = new EventSource("/events"); + es.addEventListener("emotion", (e) => setEmotion(e.data)); + es.onmessage = (e) => setEmotion(e.data); // fallback + es.onerror = () => { + es.close(); + setTimeout(connect, 1000); + }; +} + +connect(); +startPupilWander(); +startBlinking(); + diff --git a/face/var/www/html/index.html b/face/var/www/html/index.html new file mode 100755 index 0000000..211cfed --- /dev/null +++ b/face/var/www/html/index.html @@ -0,0 +1,26 @@ + + + + + + Robot Face + + + +
+
+
+
+
+ +
+
+
+ +
neutral
+
+ + + + + diff --git a/face/var/www/html/style.css b/face/var/www/html/style.css new file mode 100755 index 0000000..61a5512 --- /dev/null +++ b/face/var/www/html/style.css @@ -0,0 +1,261 @@ +:root { + --bg: #0b0f14; + --panel: #121925; + --fg: #d7e3f2; + + /* Glow / Stimmung */ + --glow: rgba(0, 255, 180, 0.22); + + /* Pupillen-Offset (wird via JS verändert) */ + --pupil-x: 0px; + --pupil-y: 0px; + + /* Mund-Parameter (Default = neutral) */ + --mouth-w: 38vw; + --mouth-h: 10vh; + --mouth-radius: 999px; + --mouth-line-y: 50%; + --mouth-line-h: 10px; + --mouth-line-opacity: 0.85; + + /* „Smile“-Bogen (0 = aus) */ + --smile: 0; + /* „Frown“-Bogen (0 = aus) */ + --frown: 0; + + /* „O“-Mund (0 = aus, sonst Größe) */ + --omouth: 0; +} + +html, body { + height: 100%; + margin: 0; + background: var(--bg); + overflow: hidden; + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +#face { + height: 100%; + display: grid; + place-items: center; + gap: 3.5vh; +} + +.eyes { + display: flex; + gap: 8vw; + align-items: center; +} + +.eye { + width: 14vw; + height: 14vw; + max-width: 220px; + max-height: 220px; + border-radius: 999px; + background: var(--panel); + box-shadow: 0 0 40px var(--glow); + position: relative; + overflow: hidden; + transition: border-radius 220ms ease, transform 220ms ease, height 220ms ease; +} + +/* Pupille */ +.eye::after { + content: ""; + position: absolute; + inset: 28%; + border-radius: 999px; + background: var(--fg); + opacity: 0.9; + transform: translate(var(--pupil-x), var(--pupil-y)); + transition: transform 180ms ease; +} + +/* Blinzeln: wir "quetschen" das Auge kurz */ +.eye.blink { + transform: scaleY(0.12); +} + +/* Mund-Container */ +.mouth { + width: var(--mouth-w); + height: var(--mouth-h); + max-width: 600px; + max-height: 120px; + border-radius: var(--mouth-radius); + background: var(--panel); + box-shadow: 0 0 40px var(--glow); + position: relative; + overflow: hidden; + transition: width 220ms ease, height 220ms ease, border-radius 220ms ease; +} + +/* Mund-Shape (Linie + Bögen + O-Mund) */ +.mouth-shape { + position: absolute; + inset: 0; +} + +/* Mund-Linie */ +.mouth-shape::after { + content: ""; + position: absolute; + left: 12%; + right: 12%; + top: var(--mouth-line-y); + height: var(--mouth-line-h); + transform: translateY(-50%); + background: var(--fg); + border-radius: 999px; + opacity: var(--mouth-line-opacity); + transition: all 220ms ease; +} + +/* Smile-Bogen */ +.mouth-shape::before { + content: ""; + position: absolute; + left: 16%; + right: 16%; + top: 38%; + height: 55%; + border: calc(6px + 6px * var(--smile)) solid rgba(215,227,242,0.85); + border-top: none; + border-left-color: transparent; + border-right-color: transparent; + border-bottom-left-radius: 999px; + border-bottom-right-radius: 999px; + opacity: calc(0.10 + 0.60 * var(--smile)); + transition: opacity 220ms ease, border-width 220ms ease; +} + +/* Frown-Bogen als extra Element über box-shadow Trick */ +.mouth-shape { + filter: drop-shadow(0 0 0 rgba(0,0,0,0)); +} +.mouth-shape.frown::before { + content: ""; + position: absolute; + left: 16%; + right: 16%; + bottom: 38%; + height: 55%; + border: calc(6px + 6px * var(--frown)) solid rgba(215,227,242,0.85); + border-bottom: none; + border-left-color: transparent; + border-right-color: transparent; + border-top-left-radius: 999px; + border-top-right-radius: 999px; + opacity: calc(0.10 + 0.60 * var(--frown)); +} + +/* O-Mund: wir machen aus dem Mund-Container einen Kreis und verstecken Linie */ +body.has-omouth .mouth { + width: calc(18vw + 8vw * var(--omouth)); + height: calc(18vw + 8vw * var(--omouth)); + max-width: 260px; + max-height: 260px; + border-radius: 999px; +} +body.has-omouth .mouth-shape::after { + left: 28%; + right: 28%; + top: 50%; + height: 42%; + border-radius: 999px; + opacity: 0.9; +} +body.has-omouth .mouth-shape::before { + opacity: 0; /* Smile aus */ +} + +/* Label */ +.label { + position: fixed; + bottom: 18px; + left: 18px; + padding: 10px 14px; + background: rgba(18, 25, 37, 0.75); + color: var(--fg); + border-radius: 14px; + backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,0.08); + letter-spacing: 0.5px; +} + +/* ===== Emotionen über Variablen ===== */ + +body.emotion-neutral { + --glow: rgba(0, 255, 180, 0.22); + --smile: 0; + --frown: 0; + --omouth: 0; + --mouth-line-opacity: 0.85; + --mouth-line-h: 10px; +} +body.emotion-neutral .mouth-shape { } +body.emotion-neutral .mouth-shape.frown { } /* no-op */ + +body.emotion-happy { + --glow: rgba(0, 255, 120, 0.32); + --smile: 1; + --frown: 0; + --omouth: 0; + --mouth-line-y: 58%; + --mouth-line-h: 12px; +} +body.emotion-happy .mouth-shape { } +body.emotion-happy .mouth-shape.frown { } /* no-op */ + +body.emotion-sad { + --glow: rgba(120, 180, 255, 0.32); + --smile: 0; + --frown: 1; + --omouth: 0; + --mouth-line-y: 42%; + --mouth-line-h: 12px; +} +body.emotion-sad .mouth-shape { } +body.emotion-sad .mouth-shape.frown { } /* no-op */ + +body.emotion-angry { + --glow: rgba(255, 70, 70, 0.32); + --smile: 0; + --frown: 0.35; + --omouth: 0; + --mouth-line-opacity: 0.95; + --mouth-line-h: 16px; +} +body.emotion-angry .eye { + border-radius: 26% 74% 60% 40% / 55% 45% 55% 45%; + transform: rotate(-2deg); +} + +body.emotion-surprised { + --glow: rgba(255, 220, 90, 0.34); + --smile: 0; + --frown: 0; + --omouth: 1; + --mouth-line-opacity: 0.95; +} +body.emotion-surprised { } +body.emotion-surprised.has-omouth { } /* marker in JS */ + +body.emotion-sleepy { + --glow: rgba(180, 180, 255, 0.22); + --smile: 0; + --frown: 0; + --omouth: 0; + --mouth-line-opacity: 0.55; + --mouth-line-h: 8px; +} +body.emotion-sleepy .eye { + height: 6vw; + max-height: 90px; +} + +/* Smooth transition for everything */ +* { box-sizing: border-box; } +