This commit is contained in:
Helva 2026-01-30 22:21:55 +01:00
commit 04c5db785a
8 changed files with 538 additions and 0 deletions

55
README.md Normal file
View File

@ -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://<pi-ip>/
From Somewhere:
curl -X POST http://<pi-ip>/api/emotion/happy
curl -X POST http://<pi-ip>/api/emotion/angry
curl -X POST http://<pi-ip>/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

View File

@ -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;
}
}

View File

@ -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

Binary file not shown.

64
face/opt/face/face_server.py Executable file
View File

@ -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}

81
face/var/www/html/app.js Executable file
View File

@ -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();

26
face/var/www/html/index.html Executable file
View File

@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Robot Face</title>
<link rel="stylesheet" href="/style.css?v=2" />
</head>
<body class="emotion-neutral">
<div id="face" aria-label="Robot face">
<div class="eyes">
<div class="eye"></div>
<div class="eye"></div>
</div>
<div class="mouth">
<div class="mouth-shape"></div>
</div>
<div class="label" id="label">neutral</div>
</div>
<script src="/app.js?v=2"></script>
</body>
</html>

261
face/var/www/html/style.css Executable file
View File

@ -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; }