Compare commits

...
20 Commits
Author SHA1 Message Date
Helva 8ccb7d21dd added redirect service 2026-02-14 11:21:26 +01:00
max d3b274e205 reverted happy face test 2026-02-08 23:38:27 +01:00
max ce60155838 additional new face stuff 2026-02-08 23:29:31 +01:00
max 96a2048cb6 additional new face stuff 2026-02-08 23:27:48 +01:00
max da4cb8a6e6 additional new face stuff 2026-02-08 23:12:04 +01:00
max 19ac56b19b additional new face stuff 2026-02-08 23:08:22 +01:00
max 91f3120f4f additional new face stuff 2026-02-08 23:03:56 +01:00
max 18fe02deb4 additional new face stuff 2026-02-08 23:02:02 +01:00
max caed34956c additional new face stuff 2026-02-08 23:00:57 +01:00
max 9c3c162a82 additional new face stuff 2026-02-08 22:53:54 +01:00
max a7300eba9b additional new face stuff 2026-02-08 22:44:21 +01:00
max afca03954c additional new face stuff 2026-02-08 22:21:48 +01:00
max e3b553696c additional new face stuff 2026-02-08 21:45:11 +01:00
max 3d5b5704f5 additional new face stuff 2026-02-08 21:22:52 +01:00
max 34756be2de additional new face stuff 2026-02-08 21:11:39 +01:00
max c296d4e950 additional new face stuff 2026-02-08 20:57:08 +01:00
max a36981da66 add new face 2026-02-08 20:26:14 +01:00
Helva cd8569318f update doku 2026-02-08 18:53:27 +01:00
Helva 29b1e5c29d added static Access Point and Captive Portal and some small adjustments 2026-02-08 18:51:19 +01:00
max c2bb45ac5a changed drive ramping from arduino to steering for better control 2026-02-08 14:47:23 +01:00
24 changed files with 1448 additions and 1536 deletions
+17 -5
View File
@@ -17,7 +17,7 @@ We build a robot with raspberry and arduino.
python3 -m venv /opt/face/venv
/opt/face/venv/bin/pip install --upgrade pip
/opt/face/venv/bin/pip install fastapi uvicorn
/opt/face/venv/bin/pip install fastapi uvicorn netifaces
### Exchange Nginx Web-Folder
sudo rm -r /var/www/html
@@ -73,18 +73,21 @@ http://pi-ip/
curl -X POST http://<pi>/api/state -H "Content-Type: application/json" \
-d '{"emotion":"happy","blink":true,"intensity":0.85}'
### systemd Service
### systemd Services
sudo ln -s /opt/helva-robot/face/etc/systemd/system/face.service /etc/systemd/system/
sudo ln -s /opt/helva-robot/face/etc/systemd/system/redirect_to_tethering.service /etc/systemd/system/
sudo chown -R www-data:www-data /opt/face
sudo chmod -R 755 /opt/face
sudo chown -R www-data:www-data /opt/face/*.py
sudo chmod -R 755 /opt/face/*.py
Start:
### Start:
sudo systemctl daemon-reload
sudo systemctl enable --now face.service
sudo systemctl status face.service --no-pager
### Face Control UI
http://localhost/control/
@@ -248,3 +251,12 @@ should be -> {"ok": true, ... } -> otherwise look at the logs:
journalctl -u drive-ctl -n 80 --no-pager
## Networking / AccessPoint / Captive Portal
see /network/ folder for config
### Captive Portal
allows you to log into the raspi AP and you get automatically to the page of the roboter
+116 -256
View File
@@ -1,303 +1,163 @@
/*
4WD Robot Motor Control (2x BTS7960: Left + Right)
- Soft start/stop via ramping (slew-rate limiter)
- Differential drive mixing: throttle + turn
- Serial control (USB): f/b/l/r/s or m <throttle> <turn>
- Watchdog: stops if no command received in time
Wiring (per BTS7960 module):
- LPWM -> Arduino PWM pin
- RPWM -> Arduino PWM pin
- LEN/REN (or L_EN/R_EN) -> Arduino digital pins (or permanently HIGH to 5V)
- VCC -> 5V (logic)
- GND -> GND common with Arduino and motor supply
- B+ / B- -> motor supply (e.g. 12V)
- M+ / M- -> motor output
NOTE:
Each side is one BTS7960 module driving two motors in parallel (front+rear) on that side.
Arduino Drive "Safe Executor"
- Receives: L <int> R <int> (-255..255)
- Outputs PWM immediately (no ramping here)
- Safety: watchdog stop if no command
- Deadzone + Min PWM mapping to avoid motor buzzing at low PWM
- Optional direction-change protection (short stop)
*/
#include <Arduino.h>
#include <PS2X_lib.h>
PS2X ps2x;
// ===== Left BTS7960 pins =====
const uint8_t L_RPWM = 5;
const uint8_t L_LPWM = 6;
const uint8_t L_REN = 7;
const uint8_t L_LEN = 8;
// ------ PS2 Controller Pins -------------
const uint8_t PS2_CLK = 2;
const uint8_t PS2_ATT = 3;
const uint8_t PS2_CMD = 4;
const uint8_t PS2_DAT = 12;
// ===== Right BTS7960 pins =====
const uint8_t R_RPWM = 9;
const uint8_t R_LPWM = 10;
const uint8_t R_REN = 11;
const uint8_t R_LEN = 12;
// ------- USE PS2 Controller or Serial Input --------------
#define USE_PS2 1
// ===== Safety / feel =====
const uint16_t CMD_TIMEOUT_MS = 600; // Web sendet regelmäßig; 600ms ist entspannt
const uint8_t DEADZONE = 10; // kleine Werte -> 0
const uint8_t MIN_PWM = 70; // anpassen: 60..110 typisch (gegen "brummen")
// ----------------------------- Pins (ANPASSEN) -----------------------------
// LEFT BTS7960
const uint8_t L_LPWM = 5; // PWM pin
const uint8_t L_RPWM = 6; // PWM pin
const uint8_t L_LEN = 7; // enable pin (LEN)
const uint8_t L_REN = 8; // enable pin (REN)
// Optional: beim Richtungswechsel kurz stoppen (schont Treiber/Getriebe)
const bool PROTECT_DIR_CHANGE = true;
const uint16_t DIR_CHANGE_STOP_MS = 60;
// RIGHT BTS7960
const uint8_t R_LPWM = 9; // PWM pin
const uint8_t R_RPWM = 10; // PWM pin
const uint8_t R_LEN = 11; // enable pin (LEN)
const uint8_t R_REN = 12; // enable pin (REN)
int targetL = 0, targetR = 0;
unsigned long lastCmdMs = 0;
// --------------------------- Tuning / Limits -------------------------------
// PWM range: 0..255
const int PWM_MAX = 255;
int lastOutL = 0, lastOutR = 0;
// Ramp speed: PWM units per second (z.B. 300 => ~0.85s von 0 auf 255)
const float RAMP_UP_PER_SEC = 320.0f;
const float RAMP_DOWN_PER_SEC = 520.0f; // meist darf bremsen/stoppen schneller sein
// Deadband: kleine Werte ignorieren (gegen "Zittern")
const int INPUT_DEADBAND = 10;
// Watchdog: wenn so lange kein Command kommt -> STOP
const uint32_t COMMAND_TIMEOUT_MS = 600;
// Loop interval (Ramping arbeitet zeitbasiert; häufig genug aufrufen)
const uint32_t CONTROL_INTERVAL_MS = 10;
// ---------------------------- State ----------------------------------------
volatile int targetLeft = 0; // -255..255
volatile int targetRight = 0; // -255..255
float currentLeft = 0.0f; // ramped output -255..255
float currentRight = 0.0f;
uint32_t lastCmdMs = 0;
uint32_t lastControlMs = 0;
// ------------------------- Helpers -----------------------------------------
static int clampInt(int v, int lo, int hi) {
if (v < lo) return lo;
if (v > hi) return hi;
static int clamp255(int v) {
if (v > 255) return 255;
if (v < -255) return -255;
return v;
}
static int applyDeadband(int v, int db) {
if (abs(v) < db) return 0;
return v;
int applyMinPwm(int v) {
v = clamp255(v);
int a = abs(v);
if (a <= DEADZONE) return 0;
int s = (v >= 0) ? 1 : -1;
// map: DEADZONE..255 -> MIN_PWM..255
long mapped = MIN_PWM + (long)(a - DEADZONE) * (255 - MIN_PWM) / (255 - DEADZONE);
if (mapped > 255) mapped = 255;
return s * (int)mapped;
}
// Ramp current towards target based on dt
static float rampTo(float current, float target, float dtSec) {
float diff = target - current;
if (diff == 0.0f) return current;
const float rate = (abs(target) > abs(current)) ? RAMP_UP_PER_SEC : RAMP_DOWN_PER_SEC;
float step = rate * dtSec;
if (abs(diff) <= step) return target;
return current + (diff > 0 ? step : -step);
}
// Send signed speed (-255..255) to one BTS7960
static void driveBTS7960(uint8_t lpwm, uint8_t rpwm, uint8_t len, uint8_t ren, int speed) {
speed = clampInt(speed, -PWM_MAX, PWM_MAX);
// Enable driver
digitalWrite(len, HIGH);
digitalWrite(ren, HIGH);
int pwm = abs(speed);
// IMPORTANT: never drive both PWM pins at the same time
void setBTS7960(int speed, uint8_t rpwm, uint8_t lpwm) {
speed = clamp255(speed);
if (speed > 0) {
analogWrite(lpwm, pwm);
analogWrite(rpwm, 0);
analogWrite(rpwm, (uint8_t)speed);
analogWrite(lpwm, 0);
} else if (speed < 0) {
analogWrite(lpwm, 0);
analogWrite(rpwm, pwm);
} else {
analogWrite(lpwm, 0);
analogWrite(rpwm, 0);
analogWrite(lpwm, (uint8_t)(-speed));
} else {
analogWrite(rpwm, 0);
analogWrite(lpwm, 0);
}
}
static void stopAll() {
targetLeft = 0;
targetRight = 0;
bool parseLine(const String& line, int &outL, int &outR) {
int idxL = line.indexOf('L');
int idxR = line.indexOf('R');
if (idxL < 0 || idxR < 0) return false;
String partL = line.substring(idxL + 1, idxR);
String partR = line.substring(idxR + 1);
partL.trim(); partR.trim();
outL = clamp255(partL.toInt());
outR = clamp255(partR.toInt());
return true;
}
// Differential drive mixing:
// throttle: -255..255, turn: -255..255
static void setMix(int throttle, int turn) {
throttle = clampInt(throttle, -PWM_MAX, PWM_MAX);
turn = clampInt(turn, -PWM_MAX, PWM_MAX);
void setupPins() {
pinMode(L_RPWM, OUTPUT); pinMode(L_LPWM, OUTPUT);
pinMode(L_REN, OUTPUT); pinMode(L_LEN, OUTPUT);
throttle = applyDeadband(throttle, INPUT_DEADBAND);
turn = applyDeadband(turn, INPUT_DEADBAND);
pinMode(R_RPWM, OUTPUT); pinMode(R_LPWM, OUTPUT);
pinMode(R_REN, OUTPUT); pinMode(R_LEN, OUTPUT);
// Classic mix
int left = throttle + turn;
int right = throttle - turn;
digitalWrite(L_REN, HIGH); digitalWrite(L_LEN, HIGH);
digitalWrite(R_REN, HIGH); digitalWrite(R_LEN, HIGH);
// Normalize if exceeds range
int maxMag = max(abs(left), abs(right));
if (maxMag > PWM_MAX) {
// scale down proportionally
left = (int)((float)left * ((float)PWM_MAX / (float)maxMag));
right = (int)((float)right * ((float)PWM_MAX / (float)maxMag));
setBTS7960(0, L_RPWM, L_LPWM);
setBTS7960(0, R_RPWM, R_LPWM);
}
targetLeft = left;
targetRight = right;
}
void outputLR(int l, int r) {
l = applyMinPwm(l);
r = applyMinPwm(r);
// ------------------------- Serial command parser ---------------------------
// Commands:
// f <pwm> -> forward
// b <pwm> -> backward
// l <pwm> -> turn left (in place)
// r <pwm> -> turn right (in place)
// s -> stop
// m <throttle> <turn> -> mix mode (-255..255 each)
// Examples:
// f 140
// m 120 -40
// s
static void handleSerialLine(String line) {
line.trim();
if (line.length() == 0) return;
if (PROTECT_DIR_CHANGE) {
// if sign changes across 0 while moving -> brief stop
auto sign = [](int v) -> int { return (v > 0) - (v < 0); };
char cmd = tolower(line.charAt(0));
bool lFlip = (sign(lastOutL) != 0) && (sign(l) != 0) && (sign(lastOutL) != sign(l));
bool rFlip = (sign(lastOutR) != 0) && (sign(r) != 0) && (sign(lastOutR) != sign(r));
// Update watchdog timestamp on any valid-looking input
lastCmdMs = millis();
if (cmd == 's') {
stopAll();
return;
}
// Split by spaces
// Simple parsing:
// cmd + integers
int a = 0, b = 0;
int n = 0;
// Try parse "m a b"
if (cmd == 'm') {
n = sscanf(line.c_str(), "m %d %d", &a, &b);
if (n == 2) {
setMix(a, b);
}
return;
}
// Parse "f a" / "b a" / "l a" / "r a"
n = sscanf(line.c_str(), "%c %d", &cmd, &a);
if (n < 2) return;
a = clampInt(a, 0, PWM_MAX);
switch (tolower(cmd)) {
case 'f': setMix(+a, 0); break;
case 'b': setMix(-a, 0); break;
case 'l': setMix(0, +a); break; // left turn in place
case 'r': setMix(0, -a); break; // right turn in place
default: break;
if (lFlip || rFlip) {
setBTS7960(0, L_RPWM, L_LPWM);
setBTS7960(0, R_RPWM, R_LPWM);
delay(DIR_CHANGE_STOP_MS);
}
}
// ------------------------------ Arduino ------------------------------------
setBTS7960(l, L_RPWM, L_LPWM);
setBTS7960(r, R_RPWM, R_LPWM);
lastOutL = l;
lastOutR = r;
}
void setup() {
int err = ps2x.config_gamepad(PS2_CLK, PS2_CMD, PS2_ATT, PS2_DAT, false, false);
Serial.print("PS2 init: "); Serial.println(err);
pinMode(L_LPWM, OUTPUT);
pinMode(L_RPWM, OUTPUT);
pinMode(L_LEN, OUTPUT);
pinMode(L_REN, OUTPUT);
pinMode(R_LPWM, OUTPUT);
pinMode(R_RPWM, OUTPUT);
pinMode(R_LEN, OUTPUT);
pinMode(R_REN, OUTPUT);
// Init: disabled PWM = 0, enables on
analogWrite(L_LPWM, 0); analogWrite(L_RPWM, 0);
analogWrite(R_LPWM, 0); analogWrite(R_RPWM, 0);
digitalWrite(L_LEN, HIGH); digitalWrite(L_REN, HIGH);
digitalWrite(R_LEN, HIGH); digitalWrite(R_REN, HIGH);
Serial.begin(115200);
Serial.println(F("BTS7960 Robot Control ready."));
Serial.println(F("Commands: f/b/l/r <0..255>, m <throttle -255..255> <turn -255..255>, s"));
setupPins();
delay(200);
Serial.println(F("Drive Ready. Send: L <val> R <val> (-255..255)"));
Serial.print(F("DEADZONE=")); Serial.print(DEADZONE);
Serial.print(F(" MIN_PWM=")); Serial.println(MIN_PWM);
lastCmdMs = millis();
lastControlMs = millis();
}
void loop() {
#if USE_PS2
// -------- PS2 Controller Input -------
ps2x.read_gamepad(false, 0);
// Not-Stop z.B. START
if (ps2x.ButtonPressed(PSB_START)) {
stopAll();
lastCmdMs = millis(); // watchdog “füttern”
}
// Sticks lesen
int ly = ps2x.Analog(PSS_LY); // 0..255
int rx = ps2x.Analog(PSS_RX); // 0..255
// 0..255 -> -255..255
auto stickToSigned = [](int v) {
int x = (v - 128) * 2;
if (x > 255) x = 255;
if (x < -255) x = -255;
return x;
};
// Mapping: LY nach oben = vorwärts (invertieren)
int throttle = -stickToSigned(ly);
int turn = stickToSigned(rx);
lastCmdMs = millis(); // watchdog “füttern”
setMix(throttle, turn); // das ist dein Original-Mixer
#else
// -------- Serial input (line based) --------
static String buf;
while (Serial.available()) {
char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
if (buf.length() > 0) {
handleSerialLine(buf);
buf = "";
}
String line = Serial.readStringUntil('\n');
line.trim();
if (line.length() == 0) continue;
int l, r;
if (parseLine(line, l, r)) {
targetL = l;
targetR = r;
lastCmdMs = millis();
outputLR(targetL, targetR);
Serial.print(F("RX/OUT L=")); Serial.print(lastOutL);
Serial.print(F(" R=")); Serial.println(lastOutR);
} else {
// avoid huge line
if (buf.length() < 80) buf += c;
Serial.println(F("Parse error -> STOP"));
targetL = 0; targetR = 0;
lastCmdMs = millis();
outputLR(0, 0);
}
}
#ENDIF
// -------- Watchdog --------
if (millis() - lastCmdMs > COMMAND_TIMEOUT_MS) {
stopAll();
}
// -------- Control update (ramping + output) --------
uint32_t now = millis();
if (now - lastControlMs >= CONTROL_INTERVAL_MS) {
float dt = (now - lastControlMs) / 1000.0f;
lastControlMs = now;
// ramp current values towards target
currentLeft = rampTo(currentLeft, (float)targetLeft, dt);
currentRight = rampTo(currentRight, (float)targetRight, dt);
// apply to drivers
driveBTS7960(L_LPWM, L_RPWM, L_LEN, L_REN, (int)round(currentLeft));
driveBTS7960(R_LPWM, R_RPWM, R_LEN, R_REN, (int)round(currentRight));
// Watchdog stop
if (millis() - lastCmdMs > CMD_TIMEOUT_MS) {
outputLR(0, 0);
}
}
@@ -1,132 +0,0 @@
// ===== Arduino UNO: Differential Drive über 2x BTS7960 =====
// Serial command format: "L <int> R <int>\n" where int in [-255..255]
// Soft-start ramping + watchdog stop
// --- Left BTS7960 pins ---
const uint8_t L_RPWM = 5; // PWM
const uint8_t L_LPWM = 6; // PWM
const uint8_t L_REN = 7; // enable
const uint8_t L_LEN = 8; // enable
// --- Right BTS7960 pins ---
const uint8_t R_RPWM = 9; // PWM
const uint8_t R_LPWM = 10; // PWM
const uint8_t R_REN = 11; // enable
const uint8_t R_LEN = 12; // enable
// Ramping
const uint8_t RAMP_STEP = 6; // speed change per loop (0..255)
const uint16_t LOOP_MS = 15; // ramp update interval
const uint16_t CMD_TIMEOUT_MS = 300; // stop if no command within x ms
int targetL = 0, targetR = 0;
int currentL = 0, currentR = 0;
unsigned long lastCmdMs = 0;
unsigned long lastLoopMs = 0;
void setupPins() {
pinMode(L_RPWM, OUTPUT); pinMode(L_LPWM, OUTPUT);
pinMode(L_REN, OUTPUT); pinMode(L_LEN, OUTPUT);
pinMode(R_RPWM, OUTPUT); pinMode(R_LPWM, OUTPUT);
pinMode(R_REN, OUTPUT); pinMode(R_LEN, OUTPUT);
digitalWrite(L_REN, HIGH); digitalWrite(L_LEN, HIGH);
digitalWrite(R_REN, HIGH); digitalWrite(R_LEN, HIGH);
analogWrite(L_RPWM, 0); analogWrite(L_LPWM, 0);
analogWrite(R_RPWM, 0); analogWrite(R_LPWM, 0);
}
static int clamp255(int v) {
if (v > 255) return 255;
if (v < -255) return -255;
return v;
}
void driveOne(int speed, uint8_t rpwm, uint8_t lpwm) {
speed = clamp255(speed);
if (speed > 0) {
analogWrite(rpwm, (uint8_t)speed);
analogWrite(lpwm, 0);
} else if (speed < 0) {
analogWrite(rpwm, 0);
analogWrite(lpwm, (uint8_t)(-speed));
} else {
analogWrite(rpwm, 0);
analogWrite(lpwm, 0);
}
}
static int rampTowards(int current, int target, uint8_t step) {
if (current < target) {
int n = current + step;
return (n > target) ? target : n;
}
if (current > target) {
int n = current - step;
return (n < target) ? target : n;
}
return current;
}
bool parseLine(const String& line, int &outL, int &outR) {
// expected: "L <int> R <int>"
int idxL = line.indexOf('L');
int idxR = line.indexOf('R');
if (idxL < 0 || idxR < 0) return false;
// crude parse; robust enough for our simple format
String partL = line.substring(idxL + 1, idxR);
String partR = line.substring(idxR + 1);
partL.trim();
partR.trim();
outL = partL.toInt();
outR = partR.toInt();
outL = clamp255(outL);
outR = clamp255(outR);
return true;
}
void setup() {
Serial.begin(115200);
setupPins();
lastCmdMs = millis();
lastLoopMs = millis();
}
void loop() {
// --- read serial lines ---
while (Serial.available()) {
String line = Serial.readStringUntil('\n');
line.trim();
if (line.length() == 0) continue;
int l, r;
if (parseLine(line, l, r)) {
targetL = l;
targetR = r;
lastCmdMs = millis();
}
}
// --- watchdog stop ---
if (millis() - lastCmdMs > CMD_TIMEOUT_MS) {
targetL = 0;
targetR = 0;
}
// --- ramp update ---
if (millis() - lastLoopMs >= LOOP_MS) {
lastLoopMs = millis();
currentL = rampTowards(currentL, targetL, RAMP_STEP);
currentR = rampTowards(currentR, targetR, RAMP_STEP);
driveOne(currentL, L_RPWM, L_LPWM);
driveOne(currentR, R_RPWM, R_LPWM);
}
}
@@ -0,0 +1,16 @@
# --- Captive Portal Trigger (Android/iOS/Windows) ---
# Android / Google connectivity check
location = /generate_204 { return 302 /; }
location = /gen_204 { return 302 /; }
# iOS/macOS captive check
location = /hotspot-detect.html { return 200 '<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>OK</BODY></HTML>'; add_header Content-Type text/html; }
location = /library/test/success.html { return 302 /; }
# Windows NCSI check
location = /ncsi.txt { return 302 /; }
location = /connecttest.txt { return 302 /; }
# Optional: Android “portal” URL
location = /captiveportal { return 302 /; }
+2 -2
View File
@@ -1,6 +1,6 @@
location ^~ /drive/ {
alias /opt/helva-robot/drive/var/www/drive/;
location ^~ /drive {
alias /opt/helva-robot/drive/var/www/drive;
try_files $uri $uri/ /drive/index.html;
}
+23
View File
@@ -5,6 +5,28 @@ import serial
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
import time
import logging
log = logging.getLogger("drive")
logging.basicConfig(level=logging.INFO)
_last_send_t = None
def log_send_timing(note: str = ""):
global _last_send_t
now = time.perf_counter() # monotonic + hochauflösend
if _last_send_t is None:
_last_send_t = now
return
dt_ms = (now - _last_send_t) * 1000.0
_last_send_t = now
# Nur loggen wenn "auffällig"
if dt_ms > 120:
log.warning("SERIAL SEND GAP %.0f ms %s", dt_ms, note)
SERIAL_PORT = "/dev/serial/by-id/usb-1a86_USB2.0-Serial-if00-port0" # ggf. /dev/ttyUSB0
BAUD = 115200
@@ -24,6 +46,7 @@ def send_lr(l: int, r: int):
l = clamp255(l)
r = clamp255(r)
line = f"L {l} R {r}\n".encode("ascii")
log_send_timing("(before write)")
ser.write(line)
@app.get("/health")
+269 -168
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Robot Dual Stick</title>
<title>Robot Dual Stick (Ramping)</title>
<style>
html, body { height: 100%; margin: 0; background: #0b0f14; color: #e8eef6; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; }
.wrap { height: 100%; display: grid; grid-template-rows: auto 1fr auto; padding: 14px; gap: 12px; }
.wrap { height: 100%; display: grid; grid-template-rows: auto 1fr auto auto; padding: 14px; gap: 12px; }
.top { display:flex; align-items:center; justify-content:space-between; gap: 10px; }
.badge { padding: 8px 12px; border-radius: 999px; background: #152033; font-weight: 700; }
.badge { padding: 8px 12px; border-radius: 999px; background: #152033; font-weight: 800; }
.status { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.panel { background:#101826; border-radius: 18px; padding: 12px; }
@@ -22,17 +22,22 @@
user-select: none;
}
.stickBox { width: min(46vw, 340px); aspect-ratio: 1/1; }
canvas { width: 100%; height: 100%; background:#0f1726; border-radius: 22px; }
canvas { width: 100%; height: 100%; background:#0f1726; border-radius: 22px; touch-action: none; }
.row { display:flex; gap: 12px; align-items:center; justify-content:space-between; flex-wrap: wrap; }
.btn { padding: 14px 16px; border-radius: 14px; border: 0; background:#1e2b44; color:#fff; font-weight: 800; font-size: 16px; }
.btn { padding: 14px 16px; border-radius: 14px; border: 0; background:#1e2b44; color:#fff; font-weight: 900; font-size: 16px; }
.btn:active { transform: scale(0.98); }
.btnStop { background:#7a1f2a; }
.btnOn { outline: 3px solid rgba(59,130,246,0.7); }
input[type="range"]{ width: 220px; }
.small { opacity: 0.85; font-size: 14px; }
input[type="range"]{ width: 240px; }
.small { opacity: 0.86; font-size: 14px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.hint { margin-top: 8px; opacity: 0.85; font-size: 14px; text-align: center; }
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.kv { display:flex; gap:10px; align-items:center; justify-content:space-between; }
.kv label { font-weight:800; }
</style>
</head>
<body>
@@ -56,46 +61,180 @@
</div>
<div class="panel row">
<div>
<div style="font-weight:900">Max Speed: <span id="maxv">180</span></div>
<input id="max" type="range" min="60" max="255" value="180">
<div class="small mono" id="readout">T 0 | Turn 0 → L 0 | R 0</div>
<div style="min-width: 320px;">
<div class="kv">
<label>Max Speed:</label>
<span class="mono"><span id="maxv">180</span></span>
</div>
<div style="display:flex; gap:10px">
<input id="max" type="range" min="60" max="255" value="180">
<div class="grid2" style="margin-top:12px">
<div class="kv">
<label>Accel:</label>
<span class="mono"><span id="accv">2.2</span>/s</span>
</div>
<input id="acc" type="range" min="0.8" max="6.0" step="0.1" value="2.2">
<div class="kv">
<label>Decel:</label>
<span class="mono"><span id="decv">3.6</span>/s</span>
</div>
<input id="dec" type="range" min="0.8" max="8.0" step="0.1" value="3.6">
<div class="kv">
<label>Turn:</label>
<span class="mono"><span id="turnv">6.0</span>/s</span>
</div>
<input id="turn" type="range" min="1.0" max="12.0" step="0.1" value="6.0">
<div class="kv">
<label>Expo:</label>
<span class="mono"><span id="expov">0.35</span></span>
</div>
<input id="expo" type="range" min="0.0" max="0.8" step="0.05" value="0.35">
</div>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<button class="btn" id="btnConnect">Connect</button>
<button class="btn" id="btnSlow">SLOW</button>
<button class="btn btnStop" id="btnStop">STOP</button>
</div>
</div>
<div class="panel small mono" id="readout">
target: T 0.00 | Turn 0.00 || smooth: T 0.00 | Turn 0.00 || L 0 | R 0
</div>
<div class="small" style="text-align:center; opacity:0.8">
Loslassen = STOP. Wenn Verbindung/Tab weg: STOP (Watchdog).
Loslassen = STOP. Tab/Verbindung weg = STOP. (Web sendet laufend, damit Arduino-Watchdog nicht stoppt.)
</div>
</div>
<script>
(() => {
// ===== UI refs =====
const statusEl = document.getElementById('status');
const readoutEl = document.getElementById('readout');
const maxEl = document.getElementById('max');
const maxvEl = document.getElementById('maxv');
const readoutEl = document.getElementById('readout');
const accEl = document.getElementById('acc');
const accvEl = document.getElementById('accv');
const decEl = document.getElementById('dec');
const decvEl = document.getElementById('decv');
const turnRateEl = document.getElementById('turn');
const turnvEl = document.getElementById('turnv');
const expoEl = document.getElementById('expo');
const expovEl = document.getElementById('expov');
const btnConnect = document.getElementById('btnConnect');
const btnStop = document.getElementById('btnStop');
const btnSlow = document.getElementById('btnSlow');
const cL = document.getElementById('leftStick');
const cR = document.getElementById('rightStick');
const gL = cL.getContext('2d');
const gR = cR.getContext('2d');
// ===== WebSocket =====
let ws = null;
let connected = false;
// Stick states (normalized)
// Left stick: throttle in [-1..1] (up = +1)
// Right stick: turn in [-1..1] (right = +1)
function setStatus(txt, ok=false){
statusEl.textContent = txt;
statusEl.style.background = ok ? "#16331d" : "#3a1a1a";
}
function connect(){
if (connected) return;
const proto = (location.protocol === "https:") ? "wss" : "ws";
const url = `${proto}://${location.host}/drive-ws`; // <— NGINX path
ws = new WebSocket(url);
ws.onopen = () => { connected = true; setStatus("online", true); };
ws.onclose = () => { connected = false; setStatus("offline", false); hardStop(); };
ws.onerror = () => { connected = false; setStatus("error", false); hardStop(); };
ws.onmessage = () => {};
}
btnConnect.addEventListener('click', connect);
// ===== Control state =====
// Targets come directly from sticks
let throttleTarget = 0; // -1..+1 (up = +)
let turnTarget = 0; // -1..+1 (right = +)
// Smoothed (ramped) values
let throttle = 0;
let turn = 0;
// For touch handling
// last motor outputs
let lastL = 0, lastR = 0;
// slow mode
let slowMode = false;
btnSlow.addEventListener('click', () => {
slowMode = !slowMode;
btnSlow.classList.toggle('btnOn', slowMode);
});
// ===== Helpers =====
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
// Expo curve: 0 = linear, higher = finer around center
// Returns in [-1..1]
function expo(v, e){
v = clamp(v, -1, 1);
e = clamp(e, 0, 0.95);
// cubic blend
return (1 - e) * v + e * v * v * v;
}
// Differential mix: throttle + turn -> left/right in [-1..1]
function mix(th, tr){
let l = th + tr;
let r = th - tr;
const m = Math.max(1, Math.abs(l), Math.abs(r));
return { l: l / m, r: r / m };
}
function stepTowards(cur, target, maxDelta){
const d = target - cur;
if (Math.abs(d) <= maxDelta) return target;
return cur + Math.sign(d) * maxDelta;
}
// Send helper with simple rate limit (force bypasses)
let lastSendMs = 0;
function sendLR(l, r, force=false){
if (!connected) return;
const now = performance.now();
if (!force && (now - lastSendMs < 35)) return; // ~28Hz max
lastSendMs = now;
try { ws.send(JSON.stringify({ l, r })); } catch(e) {}
}
function hardStop(){
throttleTarget = 0; turnTarget = 0;
throttle = 0; turn = 0;
lastL = 0; lastR = 0;
sendLR(0, 0, true);
leftStick.reset();
rightStick.reset();
renderReadout();
}
btnStop.addEventListener('click', hardStop);
document.addEventListener("visibilitychange", () => {
if (document.hidden) hardStop();
});
// ===== Stick widgets =====
function makeStick(canvas, mode /* 'throttle' or 'turn' */) {
const ctx = canvas.getContext('2d');
const cx = canvas.width / 2;
@@ -105,8 +244,6 @@
let active = false;
let px = cx, py = cy;
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
function pointerPos(evt){
const r = canvas.getBoundingClientRect();
return {
@@ -115,23 +252,61 @@
};
}
function apply(nx, ny) {
// nx, ny in [-1..1] where ny is +down
function draw(){
ctx.clearRect(0,0,canvas.width,canvas.height);
// base circle
ctx.lineWidth = 10;
ctx.strokeStyle = "#24324d";
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI*2);
ctx.stroke();
// crosshair
ctx.lineWidth = 4;
ctx.strokeStyle = "#1c263b";
ctx.beginPath();
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
ctx.stroke();
// mode line
ctx.lineWidth = 6;
ctx.strokeStyle = "#152033";
ctx.beginPath();
if (mode === 'throttle') {
// Up should be +throttle, so invert ny
throttle = clamp(-ny, -1, 1);
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
} else {
turn = clamp(nx, -1, 1);
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
}
ctx.stroke();
// knob
ctx.fillStyle = "#3b82f6";
ctx.beginPath();
ctx.arc(px, py, canvas.width*0.065, 0, Math.PI*2);
ctx.fill();
// inner dot
ctx.fillStyle = "#0b0f14";
ctx.beginPath();
ctx.arc(px, py, canvas.width*0.03, 0, Math.PI*2);
ctx.fill();
}
function apply(nx, ny){
// nx,ny in [-1..1], ny is +down
if (mode === 'throttle') {
throttleTarget = clamp(-ny, -1, 1); // up = +
} else {
turnTarget = clamp(nx, -1, 1); // right = +
}
}
function stopSelf() {
function reset(){
active = false;
px = cx; py = cy;
if (mode === 'throttle') throttle = 0;
if (mode === 'turn') turn = 0;
draw();
updateAndSend(true);
}
function onDown(evt){
@@ -144,9 +319,11 @@
function onMove(evt){
if (!active) return;
evt.preventDefault();
const p = pointerPos(evt);
const dx = p.x - cx;
const dy = p.y - cy;
const dist = Math.hypot(dx, dy);
const k = dist > radius ? (radius / dist) : 1;
@@ -158,12 +335,16 @@
apply(nx, ny);
draw();
updateAndSend(false);
}
function onUp(evt){
evt.preventDefault();
stopSelf();
active = false;
px = cx; py = cy;
// Release = target back to 0 for that axis
if (mode === 'throttle') throttleTarget = 0;
if (mode === 'turn') turnTarget = 0;
draw();
}
canvas.addEventListener('pointerdown', onDown);
@@ -172,157 +353,77 @@
canvas.addEventListener('pointercancel', onUp);
canvas.addEventListener('contextmenu', e => e.preventDefault());
function drawBase() {
ctx.clearRect(0,0,canvas.width,canvas.height);
// Outer circle
ctx.lineWidth = 10;
ctx.strokeStyle = "#24324d";
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI*2);
ctx.stroke();
// Crosshair
ctx.lineWidth = 4;
ctx.strokeStyle = "#1c263b";
ctx.beginPath();
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
ctx.stroke();
// Mode hint line
ctx.lineWidth = 6;
ctx.strokeStyle = "#152033";
ctx.beginPath();
if (mode === 'throttle') {
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
} else {
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
}
ctx.stroke();
}
function drawKnob() {
// Knob
ctx.fillStyle = "#3b82f6";
ctx.beginPath();
ctx.arc(px, py, canvas.width*0.065, 0, Math.PI*2);
ctx.fill();
// Inner dot
ctx.fillStyle = "#0b0f14";
ctx.beginPath();
ctx.arc(px, py, canvas.width*0.03, 0, Math.PI*2);
ctx.fill();
}
function draw(){
drawBase();
drawKnob();
}
draw();
return { draw, stopSelf };
return { reset };
}
function setStatus(txt, ok=false){
statusEl.textContent = txt;
statusEl.style.background = ok ? "#16331d" : "#3a1a1a";
const leftStick = makeStick(cL, 'throttle');
const rightStick = makeStick(cR, 'turn');
// ===== UI sliders text =====
function refreshLabels(){
maxvEl.textContent = maxEl.value;
accvEl.textContent = accEl.value;
decvEl.textContent = decEl.value;
turnvEl.textContent = turnRateEl.value;
expovEl.textContent = expoEl.value;
}
[maxEl, accEl, decEl, turnRateEl, expoEl].forEach(el => el.addEventListener('input', refreshLabels));
refreshLabels();
// ===== Main control loop (RAMPING happens HERE) =====
function renderReadout(){
readoutEl.textContent =
`target: T ${throttleTarget.toFixed(2)} | Turn ${turnTarget.toFixed(2)} || ` +
`smooth: T ${throttle.toFixed(2)} | Turn ${turn.toFixed(2)} || ` +
`L ${lastL} | R ${lastR}`;
}
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
let lastTick = performance.now();
// Mix throttle + turn to left/right [-1..1]
function mix(throttle, turn) {
let l = throttle + turn;
let r = throttle - turn;
// normalize to keep within [-1..1]
const m = Math.max(1, Math.abs(l), Math.abs(r));
l /= m; r /= m;
return { l, r };
}
let lastSend = 0;
let lastL = 0, lastR = 0;
function sendLR(l, r, force=false){
setInterval(() => {
const now = performance.now();
if (!connected) return;
if (!force && (now - lastSend < 35)) return; // ~28Hz
lastSend = now;
const dt = Math.max(0.001, (now - lastTick) / 1000);
lastTick = now;
try { ws.send(JSON.stringify({l, r})); } catch(e) {}
}
// rates from UI
const ACCEL_PER_SEC = parseFloat(accEl.value);
const DECEL_PER_SEC = parseFloat(decEl.value);
const TURN_PER_SEC = parseFloat(turnRateEl.value);
const EXPO = parseFloat(expoEl.value);
function updateAndSend(force) {
const maxSpeed = parseInt(maxEl.value, 10);
const m = mix(throttle, turn);
// ramp throttle
const rateT = (Math.abs(throttleTarget) > Math.abs(throttle)) ? ACCEL_PER_SEC : DECEL_PER_SEC;
throttle = stepTowards(throttle, throttleTarget, rateT * dt);
const L = Math.round(clamp(m.l, -1, 1) * maxSpeed);
const R = Math.round(clamp(m.r, -1, 1) * maxSpeed);
// ramp turn
turn = stepTowards(turn, turnTarget, TURN_PER_SEC * dt);
// apply expo AFTER ramp (feels nicer)
const th = expo(throttle, EXPO);
const tr = expo(turn, EXPO);
// mix
let { l, r } = mix(th, tr);
// scale
let maxSpeed = parseInt(maxEl.value, 10);
if (slowMode) maxSpeed = Math.round(maxSpeed * 0.45);
const L = Math.round(clamp(l, -1, 1) * maxSpeed);
const R = Math.round(clamp(r, -1, 1) * maxSpeed);
lastL = L; lastR = R;
readoutEl.textContent = `T ${throttle.toFixed(2)} | Turn ${turn.toFixed(2)} → L ${L} | R ${R}`;
renderReadout();
// sendLR(L, R, force);
// always send with a gentle cap (force keeps Arduino alive)
// This is both "control output" and heartbeat.
sendLR(L, R, true);
}, 20); // 50 Hz smoothing + sending
// ---- Heartbeat: keep sending current values while driving ----
setInterval(() => {
// wenn nicht verbunden -> nix
if (!connected) return;
// Wenn du NUR beim aktiven Fahren senden willst:
const driving = (Math.abs(throttle) > 0.02) || (Math.abs(turn) > 0.02);
if (driving) {
// erzwinge Senden auch wenn sich nichts geändert hat
sendLR(lastL, lastR, true);
}
}, 400); // 40 Hz
}
function hardStop() {
throttle = 0; turn = 0;
updateAndSend(true);
left.stopSelf();
right.stopSelf();
}
function connect(){
if (connected) return;
const proto = (location.protocol === "https:") ? "wss" : "ws";
const url = `${proto}://${location.host}/drive-ws`;
ws = new WebSocket(url);
ws.onopen = () => { connected = true; setStatus("online", true); updateAndSend(true); };
ws.onclose = () => { connected = false; setStatus("offline", false); hardStop(); };
ws.onerror = () => { connected = false; setStatus("error", false); hardStop(); };
ws.onmessage = () => {};
}
btnConnect.addEventListener('click', connect);
btnStop.addEventListener('click', hardStop);
maxEl.addEventListener('input', () => {
maxvEl.textContent = maxEl.value;
updateAndSend(true);
});
// Create sticks
const left = makeStick(cL, 'throttle');
const right = makeStick(cR, 'turn');
// Auto-connect
// ===== Start =====
connect();
setStatus("connecting...", false);
// Safety: stop when page hidden
document.addEventListener("visibilitychange", () => {
if (document.hidden) hardStop();
});
})();
</script>
</body>
+12
View File
@@ -2,6 +2,18 @@ server {
listen 80;
server_name _;
# redirect service to tethering IP for 'webview kiosk' -> mobile phone to face
location /redirect {
proxy_pass http://127.0.0.1:8000/redirect;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# captive portal to face
include /opt/helva-robot/drive/etc/nginx/snippets/captive-portal.conf;
# gives access to drive control
include /opt/helva-robot/drive/etc/nginx/snippets/drive.conf;
root /opt/helva-robot/face/var/www/html;
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Redirect Tethering IP Service
After=network.target
[Service]
WorkingDirectory=/opt/face
ExecStart=/opt/face/venv/bin/python -m uvicorn redirect_tethering_ip:app --host 127.0.0.1 --port 8000 --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
+80 -106
View File
@@ -1,151 +1,125 @@
from __future__ import annotations
import asyncio
import json
from typing import Any, Dict, Optional
from typing import Any, Dict, Set
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse, JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
app = FastAPI()
clients: set[asyncio.Queue[str]] = set()
# Global state sent to clients
state: Dict[str, Any] = {
STATE: Dict[str, Any] = {
"emotion": "neutral",
"intensity": 0.7, # 0..1
"look": None, # {"x": -1..1, "y": -1..1} or None
"mouth": {"open": False, "amount": 0.0, "duration_ms": 0},
"talk": {"enabled": False, "rate_hz": 3.2, "amount": 0.9, "jitter": 0.25},
"intensity": 0.85,
"look": {"x": 0.0, "y": 0.0},
"speaking": False,
"eyesMoving": True,
}
def clamp(v: float, lo: float, hi: float) -> float:
return max(lo, min(hi, v))
CLIENTS: Set[asyncio.Queue[str]] = set()
LOCK = asyncio.Lock()
def sse(event: str, data: str) -> str:
return f"event: {event}\ndata: {data}\n\n"
def normalize_patch(patch: Dict[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
if "emotion" in patch:
out["emotion"] = str(patch["emotion"])
if "intensity" in patch:
def clamp(v: Any, lo: float, hi: float) -> float:
try:
out["intensity"] = clamp(float(patch["intensity"]), 0.0, 1.0)
x = float(v)
except Exception:
pass
return lo
return max(lo, min(hi, x))
if "look" in patch:
look = patch["look"]
if look is None:
out["look"] = None
elif isinstance(look, dict):
try:
x = clamp(float(look.get("x", 0.0)), -1.0, 1.0)
y = clamp(float(look.get("y", 0.0)), -1.0, 1.0)
out["look"] = {"x": x, "y": y}
except Exception:
pass
if "mouth" in patch and isinstance(patch["mouth"], dict):
m = patch["mouth"]
try:
open_ = bool(m.get("open", False))
amount = clamp(float(m.get("amount", 0.0)), 0.0, 1.0)
duration_ms = int(m.get("duration_ms", 0))
duration_ms = max(0, min(duration_ms, 10_000))
out["mouth"] = {"open": open_, "amount": amount, "duration_ms": duration_ms}
except Exception:
pass
def merge_state(payload: Dict[str, Any]) -> None:
if isinstance(payload.get("emotion"), str):
STATE["emotion"] = payload["emotion"]
# one-shot flags are allowed but not stored in state
if "blink" in patch:
out["blink"] = bool(patch["blink"])
if "intensity" in payload:
STATE["intensity"] = clamp(payload["intensity"], 0.0, 1.0)
if "talk" in patch:
t = patch["talk"]
if isinstance(t, dict):
try:
enabled = bool(t.get("enabled", False))
rate_hz = float(t.get("rate_hz", 3.2))
amount = float(t.get("amount", 0.9))
jitter = float(t.get("jitter", 0.25))
if isinstance(payload.get("look"), dict):
lx = clamp(payload["look"].get("x", 0.0), -1.0, 1.0)
ly = clamp(payload["look"].get("y", 0.0), -1.0, 1.0)
STATE["look"] = {"x": lx, "y": ly}
rate_hz = clamp(rate_hz, 0.5, 10.0)
amount = clamp(amount, 0.0, 1.0)
jitter = clamp(jitter, 0.0, 1.0)
if isinstance(payload.get("speaking"), bool):
STATE["speaking"] = payload["speaking"]
out["talk"] = {"enabled": enabled, "rate_hz": rate_hz, "amount": amount, "jitter": jitter}
except Exception:
pass
if isinstance(payload.get("eyesMoving"), bool):
STATE["eyesMoving"] = payload["eyesMoving"]
return out
async def broadcast(payload: Dict[str, Any]) -> None:
msg = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
dead = []
for q in clients:
async def broadcast() -> None:
msg = json.dumps(STATE, separators=(",", ":"))
dead: list[asyncio.Queue[str]] = []
async with LOCK:
for q in CLIENTS:
try:
q.put_nowait(msg)
except Exception:
dead.append(q)
for q in dead:
clients.discard(q)
CLIENTS.discard(q)
@app.get("/state")
async def get_state():
return JSONResponse(STATE)
@app.get("/api/state")
async def get_state():
return JSONResponse(STATE)
@app.post("/state")
async def set_state(payload: Dict[str, Any]):
merge_state(payload)
await broadcast()
return JSONResponse({"ok": True, "state": STATE})
@app.post("/api/state")
async def set_state(payload: Dict[str, Any]):
merge_state(payload)
await broadcast()
return JSONResponse({"ok": True, "state": STATE})
@app.get("/events")
async def events(request: Request):
q: asyncio.Queue[str] = asyncio.Queue()
clients.add(q)
"""
SSE stream for browser:
const es = new EventSource("/events");
nginx should proxy /events -> http://127.0.0.1:8001/events
"""
q: asyncio.Queue[str] = asyncio.Queue(maxsize=50)
async with LOCK:
CLIENTS.add(q)
async def gen():
try:
# Send current state immediately on connect
yield sse("state", json.dumps(state, separators=(",", ":"), ensure_ascii=False))
# initial state immediately
initial = json.dumps(STATE, separators=(",", ":"))
yield f"event: state\ndata: {initial}\n\n"
while True:
if await request.is_disconnected():
break
msg = await q.get()
yield sse("state", msg)
try:
msg = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"event: state\ndata: {msg}\n\n"
except asyncio.TimeoutError:
# keepalive
yield ": keepalive\n\n"
finally:
clients.discard(q)
async with LOCK:
CLIENTS.discard(q)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"X-Accel-Buffering": "no", # important behind nginx
}
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
@app.post("/api/state")
async def set_state(patch: Dict[str, Any]):
global state
normalized = normalize_patch(patch)
# Merge persistent fields
for k in ("emotion", "intensity", "look", "mouth", "talk"):
if k in normalized:
state[k] = normalized[k]
# Broadcast merged state + one-shot flags if any
payload = dict(state)
if "blink" in normalized:
payload["blink"] = normalized["blink"]
await broadcast(payload)
return JSONResponse({"ok": True, "state": state})
# Compatibility endpoint (optional): keeps your old curl calls working
@app.post("/api/emotion/{name}")
async def set_emotion(name: str):
global state
state["emotion"] = name
payload = dict(state)
await broadcast(payload)
return JSONResponse({"ok": True, "state": state})
@app.get("/api/state")
async def get_state():
return {"state": state}
+14
View File
@@ -0,0 +1,14 @@
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
import netifaces
app = FastAPI()
@app.get("/redirect")
def redirect_to_pi():
# gather tethering IP
interface = 'usb0'
ip_address = netifaces.ifaddresses(interface)[netifaces.AF_INET][0]['addr']
# relocate to current tethering-IP
return RedirectResponse(url=f'http://{ip_address}:80')
-194
View File
@@ -1,194 +0,0 @@
const label = document.getElementById("label");
const eyes = Array.from(document.querySelectorAll(".eye"));
const mouthShape = document.querySelector(".mouth-shape");
let current = "neutral";
let wanderEnabled = true;
let wanderTimer = null;
let talkEnabled = false;
let talkCfg = { rate_hz: 3.2, amount: 0.9, jitter: 0.25 };
let talkTimer = null;
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
function setEmotion(name) {
const safe = String(name || "neutral")
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "");
current = safe;
document.body.className = `emotion-${safe}`;
label.textContent = safe;
// surprised -> O-mouth
if (safe === "surprised") document.body.classList.add("has-omouth");
else document.body.classList.remove("has-omouth");
// frown for sad/angry
if (safe === "sad" || safe === "angry") mouthShape.classList.add("frown");
else mouthShape.classList.remove("frown");
}
function blinkOnce() {
eyes.forEach(e => e.classList.add("blink"));
setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120);
}
function setLook(x, y) {
// x/y in -1..1 -> px offsets
const pxX = Math.round(clamp(x, -1, 1) * 12);
const pxY = Math.round(clamp(y, -1, 1) * 10);
document.documentElement.style.setProperty("--pupil-x", `${pxX}px`);
document.documentElement.style.setProperty("--pupil-y", `${pxY}px`);
}
function setIntensity(v) {
const val = clamp(Number(v ?? 0.7), 0, 1);
document.documentElement.style.setProperty("--intensity", String(val));
}
function setMouthOpen(v) {
const val = clamp(Number(v ?? 0), 0, 1);
document.documentElement.style.setProperty("--mouth-open", String(val));
}
function speak(amount = 0.8, durationMs = 600) {
if (talkEnabled) return; // Talk-Mode steuert Mund
setMouthOpen(amount);
setTimeout(() => setMouthOpen(0), Math.max(0, durationMs));
}
function stopTalk() {
talkEnabled = false;
if (talkTimer) {
clearTimeout(talkTimer);
talkTimer = null;
}
setMouthOpen(0);
}
function startTalk(cfg) {
talkEnabled = true;
talkCfg = {
rate_hz: clamp(Number(cfg?.rate_hz ?? 3.2), 0.5, 10),
amount: clamp(Number(cfg?.amount ?? 0.9), 0, 1),
jitter: clamp(Number(cfg?.jitter ?? 0.25), 0, 1),
};
if (talkTimer) clearTimeout(talkTimer);
// “sprech”-Animation: Mund öffnet/schließt schnell, mit bisschen Zufall
const tick = () => {
if (!talkEnabled) return;
const base = talkCfg.amount;
const j = talkCfg.jitter;
// random-ish open amount between ~0.2..1.0, scaled
const r = (0.35 + Math.random() * 0.65);
const open = clamp(base * r * (1 - j + Math.random() * j), 0, 1);
setMouthOpen(open);
// timing from rate_hz (Hz -> ms)
const interval = Math.max(60, Math.round(1000 / talkCfg.rate_hz));
// add a bit of jitter to cadence
const next = interval + Math.round((Math.random() * 2 - 1) * interval * 0.25);
talkTimer = setTimeout(tick, next);
};
tick();
}
/* Pupillen-Wandern nur wenn kein look gesetzt ist */
function startWander() {
if (wanderTimer) clearTimeout(wanderTimer);
const tick = () => {
if (!wanderEnabled) return;
const x = (Math.random() * 2 - 1) * 0.7;
const y = (Math.random() * 2 - 1) * 0.6;
setLook(x, y);
const next = 600 + Math.random() * 900;
wanderTimer = setTimeout(tick, next);
};
tick();
}
function applyState(s) {
if (!s || typeof s !== "object") return;
if (s.emotion) setEmotion(s.emotion);
if (s.intensity !== undefined) setIntensity(s.intensity);
// one-shot blink
if (s.blink) blinkOnce();
// look: if null -> enable wander. if object -> fixed look
if ("look" in s) {
if (s.look === null) {
wanderEnabled = true;
startWander();
} else if (typeof s.look === "object") {
wanderEnabled = false;
setLook(s.look.x ?? 0, s.look.y ?? 0);
}
}
// mouth command
if (s.mouth && typeof s.mouth === "object") {
const open = !!s.mouth.open;
const amount = clamp(Number(s.mouth.amount ?? 0.8), 0, 1);
const dur = Number(s.mouth.duration_ms ?? 600);
if (open) speak(amount, dur);
else setMouthOpen(0);
}
// talk mode
if (s.talk && typeof s.talk === "object") {
const enabled = !!s.talk.enabled;
if (enabled) startTalk(s.talk);
else stopTalk();
}
}
function connect() {
const es = new EventSource("/events");
es.addEventListener("state", (e) => {
try { applyState(JSON.parse(e.data)); } catch {}
});
es.onmessage = (e) => {
// fallback: treat as state json
try { applyState(JSON.parse(e.data)); } catch {}
};
es.onerror = () => {
es.close();
setTimeout(connect, 1000);
};
}
connect();
startWander();
/* zufälliges Blinzeln unabhängig vom Push */
(function startBlinkLoop(){
const loop = () => {
let base = 3500;
if (current === "sleepy") base = 2200;
if (current === "surprised") base = 4200;
const next = base + Math.random() * 2200;
setTimeout(() => {
blinkOnce();
if (Math.random() < 0.12) setTimeout(blinkOnce, 220);
loop();
}, next);
};
loop();
})();
+121
View File
@@ -0,0 +1,121 @@
:root{
--bg:#06070b;
--card:#0f1422;
--text:#e8f0ff;
--muted:#a8b6d8;
--border: rgba(255,255,255,.10);
--accent: rgba(120,220,255,1);
--shadow: 0 12px 40px rgba(0,0,0,.55);
--r: 18px;
}
html,body{ height:100%; margin:0; background:var(--bg); color:var(--text);
font-family:system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; }
.page{ max-width: 880px; margin:0 auto; padding: 18px; }
.top{ display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap; }
h1{ margin:0; font-size:22px; }
h2{ margin:0 0 12px 0; font-size:16px; }
.card{
background: rgba(255,255,255,.04);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 14px;
margin-top: 14px;
box-shadow: var(--shadow);
}
.row{ display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.pill{
font-size: 12px;
color: var(--muted);
border:1px solid var(--border);
border-radius: 999px;
padding: 8px 10px;
background: rgba(255,255,255,.03);
}
.grid{
display:grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.emobtn{
border:1px solid var(--border);
background: rgba(255,255,255,.05);
border-radius: 14px;
padding: 12px;
color: var(--text);
cursor:pointer;
display:flex;
align-items:center;
gap:10px;
justify-content:flex-start;
}
.emobtn.active{ outline: 2px solid rgba(120,220,255,.55); }
.icon{
width:34px; height:34px;
border-radius: 10px;
display:grid; place-items:center;
background: rgba(120,220,255,.12);
border: 1px solid rgba(120,220,255,.25);
color: var(--accent);
font-size: 18px;
}
.controls{ display:grid; gap: 12px; }
.toggle{ display:flex; gap:10px; align-items:center; color:var(--text); }
.toggle input{ transform: scale(1.2); }
.slider{ display:grid; grid-template-columns: 110px 1fr 60px; gap:10px; align-items:center; }
.slider output{ text-align:right; color: var(--muted); }
.lookpad{
width:min(520px, 92vw);
aspect-ratio: 1 / 1;
margin-top: 10px;
border-radius: 20px;
border:1px solid var(--border);
background: rgba(0,0,0,.35);
position:relative;
overflow:hidden;
touch-action: none;
}
.cross::before, .cross::after{
content:"";
position:absolute;
left:50%; top:0; bottom:0;
width:1px;
background: rgba(255,255,255,.08);
}
.cross::after{
left:0; right:0; top:50%; bottom:auto;
height:1px; width:auto;
}
.dot{
width:18px; height:18px;
border-radius: 99px;
background: rgba(120,220,255,.95);
box-shadow: 0 0 0 6px rgba(120,220,255,.18);
position:absolute;
left:50%; top:50%;
transform: translate(-50%,-50%);
}
.btn{
border:1px solid var(--border);
background: rgba(255,255,255,.06);
color: var(--text);
border-radius: 14px;
padding: 10px 12px;
cursor:pointer;
}
.btn:active{ transform: translateY(1px); }
.muted{ margin: 0; color: var(--muted); font-size: 13px; }
.foot{ margin-top: 18px; display:flex; justify-content:space-between; }
.link{ color: rgba(120,220,255,.95); text-decoration:none; }
+229
View File
@@ -0,0 +1,229 @@
// Control UI -> POST /api/state
// Also subscribes to /events to show current state/connection.
(() => {
const EMOTIONS = [
{ id: "neutral", label: "Neutral", icon: "•" },
{ id: "happy", label: "Happy", icon: "😊" },
{ id: "sad", label: "Sad", icon: "☹️" },
{ id: "angry", label: "Angry", icon: "😠" },
{ id: "sleepy", label: "Sleepy", icon: "😴" },
{ id: "surprised", label: "Surprised", icon: "😲" },
{ id: "excited", label: "Excited", icon: "⚡" },
];
const clamp = (n,a,b) => Math.max(a, Math.min(b, n));
const clamp01 = (v) => clamp(Number(v) || 0, 0, 1);
const ui = {
grid: document.getElementById("emotionGrid"),
conn: document.getElementById("conn"),
current: document.getElementById("current"),
speaking: document.getElementById("speaking"),
eyesMoving: document.getElementById("eyesMoving"),
intensity: document.getElementById("intensity"),
intensityVal: document.getElementById("intensityVal"),
lookpad: document.getElementById("lookpad"),
lookdot: document.getElementById("lookdot"),
center: document.getElementById("center"),
stareOff: document.getElementById("stareOff"),
};
const state = {
emotion: "neutral",
intensity: 0.85,
speaking: false,
eyesMoving: true,
look: { x: 0, y: 0 },
// stare/lock is represented by sending/omitting look.
stare: false,
};
function renderEmotionButtons() {
ui.grid.innerHTML = "";
for (const e of EMOTIONS) {
const b = document.createElement("button");
b.className = "emobtn";
b.dataset.emotion = e.id;
b.innerHTML = `
<div class="icon">${e.icon}</div>
<div>
<div style="font-weight:700">${e.label}</div>
<div style="opacity:.6;font-size:12px">${e.id}</div>
</div>
`;
b.addEventListener("click", () => {
state.emotion = e.id;
setActiveEmotion();
sendState({ emotion: state.emotion });
});
ui.grid.appendChild(b);
}
setActiveEmotion();
}
function setActiveEmotion() {
for (const btn of ui.grid.querySelectorAll(".emobtn")) {
btn.classList.toggle("active", btn.dataset.emotion === state.emotion);
}
ui.current.textContent = state.emotion;
}
async function sendState(partial) {
// Build payload. If stare=false -> do NOT send look (so face can drift).
const payload = {
emotion: state.emotion,
intensity: state.intensity,
speaking: state.speaking,
eyesMoving: state.eyesMoving,
...partial,
};
if (state.stare) payload.look = { x: state.look.x, y: state.look.y };
else delete payload.look;
try {
await fetch("/api/state", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} catch (_) {
// ignore; SSE status will show disconnected
}
}
// intensity
ui.intensity.addEventListener("input", () => {
state.intensity = clamp01(ui.intensity.value);
ui.intensityVal.textContent = state.intensity.toFixed(2);
sendState({ intensity: state.intensity });
});
// toggles
ui.speaking.addEventListener("change", () => {
state.speaking = !!ui.speaking.checked;
sendState({ speaking: state.speaking });
});
ui.eyesMoving.addEventListener("change", () => {
state.eyesMoving = !!ui.eyesMoving.checked;
sendState({ eyesMoving: state.eyesMoving });
});
// Look pad (touch/mouse)
function setDotFromLook() {
const r = ui.lookpad.getBoundingClientRect();
const px = (state.look.x + 1) / 2 * r.width;
const py = (state.look.y + 1) / 2 * r.height;
ui.lookdot.style.left = `${px}px`;
ui.lookdot.style.top = `${py}px`;
ui.lookdot.style.transform = "translate(-50%,-50%)";
}
function setLookFromEvent(ev) {
const r = ui.lookpad.getBoundingClientRect();
const x = clamp((ev.clientX - r.left) / r.width, 0, 1);
const y = clamp((ev.clientY - r.top) / r.height, 0, 1);
state.look.x = (x * 2) - 1;
state.look.y = (y * 2) - 1;
state.stare = true; // touching pad implies stare
setDotFromLook();
sendState({}); // will include look because stare=true
}
let pointerDown = false;
ui.lookpad.addEventListener("pointerdown", (ev) => {
pointerDown = true;
ui.lookpad.setPointerCapture(ev.pointerId);
setLookFromEvent(ev);
});
ui.lookpad.addEventListener("pointermove", (ev) => {
if (!pointerDown) return;
setLookFromEvent(ev);
});
ui.lookpad.addEventListener("pointerup", () => { pointerDown = false; });
// dblclick / double tap center
let lastTap = 0;
ui.lookpad.addEventListener("pointerdown", () => {
const now = Date.now();
if (now - lastTap < 280) {
state.look = { x: 0, y: 0 };
state.stare = true;
setDotFromLook();
sendState({});
}
lastTap = now;
});
ui.center.addEventListener("click", () => {
state.look = { x: 0, y: 0 };
state.stare = true;
setDotFromLook();
sendState({});
});
ui.stareOff.addEventListener("click", () => {
state.stare = false; // omit look in next send
sendState({});
});
// SSE subscribe to reflect current state + connection
(function connectSSE(){
let es;
function open(){
ui.conn.textContent = "connecting…";
es = new EventSource("/events");
es.onopen = () => ui.conn.textContent = "online";
const apply = (msg) => {
if (!msg || typeof msg !== "object") return;
if (typeof msg.emotion === "string") state.emotion = msg.emotion;
if (msg.intensity !== undefined) state.intensity = clamp01(msg.intensity);
if (typeof msg.speaking === "boolean") state.speaking = msg.speaking;
if (typeof msg.eyesMoving === "boolean") state.eyesMoving = msg.eyesMoving;
if (msg.look && typeof msg.look === "object") {
state.look = {
x: clamp(Number(msg.look.x ?? 0), -1, 1),
y: clamp(Number(msg.look.y ?? 0), -1, 1),
};
state.stare = true;
}
ui.intensity.value = String(state.intensity);
ui.intensityVal.textContent = state.intensity.toFixed(2);
ui.speaking.checked = state.speaking;
ui.eyesMoving.checked = state.eyesMoving;
setActiveEmotion();
setDotFromLook();
};
es.addEventListener("state", (ev) => {
try { apply(JSON.parse(ev.data)); } catch (_) {}
});
es.onmessage = (ev) => {
try { apply(JSON.parse(ev.data)); } catch (_) {}
};
es.onerror = () => {
ui.conn.textContent = "offline";
try { es.close(); } catch (_) {}
setTimeout(open, 1200);
};
}
open();
})();
// init
renderEmotionButtons();
ui.intensityVal.textContent = state.intensity.toFixed(2);
setDotFromLook();
})();
+51 -374
View File
@@ -2,390 +2,67 @@
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Robot Face Control</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0b0f14;
color: #d7e3f2;
}
.wrap {
max-width: 720px;
margin: 0 auto;
padding: 16px;
display: grid;
gap: 14px;
}
.card {
background: rgba(18, 25, 37, 0.85);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 18px;
padding: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
}
h1 { font-size: 18px; margin: 0 0 10px 0; opacity: 0.95; }
h2 { font-size: 14px; margin: 0 0 10px 0; opacity: 0.85; }
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
button {
background: #121925;
color: #d7e3f2;
border: 1px solid rgba(255,255,255,0.10);
padding: 12px 10px;
border-radius: 14px;
cursor: pointer;
font-size: 14px;
transition: transform 120ms ease, border-color 120ms ease;
user-select: none;
touch-action: manipulation;
}
button:active { transform: scale(0.98); }
button.primary { border-color: rgba(0,255,180,0.35); }
.row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.row3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
input[type="range"] {
width: 100%;
accent-color: #6ee7ff;
}
.value {
font-variant-numeric: tabular-nums;
opacity: 0.85;
min-width: 72px;
text-align: right;
}
.toggle {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
align-items: center;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.10);
background: #121925;
}
.dot {
width: 10px; height: 10px; border-radius: 999px;
background: rgba(255,255,255,0.35);
}
.dot.ok { background: rgba(0,255,180,0.75); }
.dot.bad { background: rgba(255,70,70,0.75); }
.small { font-size: 12px; opacity: 0.75; }
.footer {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
</style>
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Face Control</title>
<link rel="stylesheet" href="/control/control.css" />
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Robot Face Control</h1>
<div class="toggle">
<span class="pill"><span id="connDot" class="dot"></span><span id="connTxt">offline</span></span>
<div class="small" id="stateTxt">–</div>
</div>
<main class="page">
<header class="top">
<h1>Face Control</h1>
<div class="row">
<span class="pill">Status: <strong id="conn">?</strong></span>
<span class="pill">Aktuell: <strong id="current">neutral</strong></span>
</div>
</header>
<div class="card">
<section class="card">
<h2>Emotion</h2>
<div class="grid">
<button data-emotion="neutral" class="primary">neutral</button>
<button data-emotion="happy">happy</button>
<button data-emotion="sad">sad</button>
<button data-emotion="angry">angry</button>
<button data-emotion="surprised">surprised</button>
<button data-emotion="sleepy">sleepy</button>
</div>
<div class="grid" id="emotionGrid"></div>
</section>
<section class="card">
<h2>Parameter</h2>
<div class="controls">
<label class="toggle">
<input id="speaking" type="checkbox" />
<span>🗣️ Sprechen</span>
</label>
<label class="toggle">
<input id="eyesMoving" type="checkbox" checked />
<span>👀 Augen bewegen</span>
</label>
<label class="slider">
<span>Intensity</span>
<input id="intensity" type="range" min="0" max="1" step="0.01" value="0.85" />
<output id="intensityVal">0.85</output>
</label>
</div>
</section>
<section class="card">
<h2>Look</h2>
<p class="muted">Zieh im Feld: links/rechts/oben/unten. Doppeltipp = zentrieren.</p>
<div class="lookpad" id="lookpad">
<div class="cross"></div>
<div class="dot" id="lookdot"></div>
</div>
<div class="row">
<label for="intensity">Intensität</label>
<div class="value" id="intensityVal">0.70</div>
</div>
<input id="intensity" type="range" min="0" max="1" step="0.01" value="0.70" />
<div class="footer">
<button id="blinkBtn">Blinzeln</button>
<button id="wanderBtn" class="primary">Blick: wander</button>
</div>
<button class="btn" id="center">Center</button>
<button class="btn" id="stareOff">Stare OFF</button>
</div>
</section>
<div class="card">
<h2>Blick (fix)</h2>
<div class="row">
<label for="lookX">X (links ↔ rechts)</label>
<div class="value" id="lookXVal">0.00</div>
</div>
<input id="lookX" type="range" min="-1" max="1" step="0.01" value="0" />
<footer class="foot">
<a class="link" href="/">← Face</a>
<a class="link" href="/drive">Drive →</a>
</footer>
</main>
<div class="row">
<label for="lookY">Y (oben ↕ unten)</label>
<div class="value" id="lookYVal">0.00</div>
</div>
<input id="lookY" type="range" min="-1" max="1" step="0.01" value="0" />
<div class="footer">
<button id="applyLookBtn">Blick anwenden</button>
<button id="centerLookBtn">Zentrieren</button>
</div>
</div>
<div class="card">
<h2>Sprechen</h2>
<div class="footer">
<button id="speakBtn">Speak (700ms)</button>
<button id="talkToggleBtn" class="primary">Talk: OFF</button>
</div>
<div class="row">
<label for="talkRate">Rate (Hz)</label>
<div class="value" id="talkRateVal">3.20</div>
</div>
<input id="talkRate" type="range" min="0.5" max="10" step="0.1" value="3.2" />
<div class="row">
<label for="talkAmount">Mund-Öffnung</label>
<div class="value" id="talkAmountVal">0.90</div>
</div>
<input id="talkAmount" type="range" min="0" max="1" step="0.01" value="0.9" />
<div class="row">
<label for="talkJitter">Jitter</label>
<div class="value" id="talkJitterVal">0.25</div>
</div>
<input id="talkJitter" type="range" min="0" max="1" step="0.01" value="0.25" />
</div>
<div class="card small">
Tipp: URL am Handy öffnen: <b>/control/</b> (z. B. http://raspy/control/)
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const connDot = $("connDot");
const connTxt = $("connTxt");
const stateTxt = $("stateTxt");
const intensity = $("intensity");
const intensityVal = $("intensityVal");
const lookX = $("lookX"), lookXVal = $("lookXVal");
const lookY = $("lookY"), lookYVal = $("lookYVal");
const talkToggleBtn = $("talkToggleBtn");
const talkRate = $("talkRate"), talkRateVal = $("talkRateVal");
const talkAmount = $("talkAmount"), talkAmountVal = $("talkAmountVal");
const talkJitter = $("talkJitter"), talkJitterVal = $("talkJitterVal");
let talkEnabled = false;
let wander = true;
let lastState = null;
function fmt(n, d=2){ return Number(n).toFixed(d); }
async function postState(patch) {
const res = await fetch("/api/state", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error("POST failed: " + res.status);
return res.json();
}
function setConn(ok) {
connDot.classList.toggle("ok", ok);
connDot.classList.toggle("bad", !ok);
connTxt.textContent = ok ? "online" : "offline";
}
function updateStateText(s) {
if (!s) { stateTxt.textContent = "–"; return; }
const e = s.emotion ?? "?";
const i = s.intensity ?? "?";
const t = s.talk?.enabled ? "talk" : "silent";
const lk = (s.look === null || s.look === undefined) ? "wander" : `look(${fmt(s.look.x)},${fmt(s.look.y)})`;
stateTxt.textContent = `${e} | intensity ${fmt(i)} | ${t} | ${lk}`;
}
// Emotion buttons
document.querySelectorAll("button[data-emotion]").forEach(btn => {
btn.addEventListener("click", async () => {
try {
await postState({ emotion: btn.dataset.emotion });
} catch(e) { console.error(e); }
});
});
// Intensity slider
intensity.addEventListener("input", () => intensityVal.textContent = fmt(intensity.value));
intensity.addEventListener("change", async () => {
try { await postState({ intensity: Number(intensity.value) }); } catch(e){ console.error(e); }
});
// Blink + Wander toggle
$("blinkBtn").addEventListener("click", async () => {
try { await postState({ blink: true }); } catch(e){ console.error(e); }
});
$("wanderBtn").addEventListener("click", async (ev) => {
wander = !wander;
ev.target.textContent = "Blick: " + (wander ? "wander" : "fix");
ev.target.classList.toggle("primary", wander);
try {
await postState({ look: wander ? null : { x: Number(lookX.value), y: Number(lookY.value) } });
} catch(e){ console.error(e); }
});
// Look sliders
function updateLookLabels(){
lookXVal.textContent = fmt(lookX.value);
lookYVal.textContent = fmt(lookY.value);
}
lookX.addEventListener("input", updateLookLabels);
lookY.addEventListener("input", updateLookLabels);
updateLookLabels();
$("applyLookBtn").addEventListener("click", async () => {
try {
wander = false;
$("wanderBtn").textContent = "Blick: fix";
$("wanderBtn").classList.remove("primary");
await postState({ look: { x: Number(lookX.value), y: Number(lookY.value) } });
} catch(e){ console.error(e); }
});
$("centerLookBtn").addEventListener("click", async () => {
lookX.value = 0; lookY.value = 0; updateLookLabels();
try {
wander = false;
$("wanderBtn").textContent = "Blick: fix";
$("wanderBtn").classList.remove("primary");
await postState({ look: { x: 0, y: 0 } });
} catch(e){ console.error(e); }
});
// Speak (one-shot)
$("speakBtn").addEventListener("click", async () => {
try {
await postState({ mouth: { open: true, amount: Number(talkAmount.value), duration_ms: 700 } });
} catch(e){ console.error(e); }
});
// Talk controls
function updateTalkLabels(){
talkRateVal.textContent = fmt(talkRate.value);
talkAmountVal.textContent = fmt(talkAmount.value);
talkJitterVal.textContent = fmt(talkJitter.value);
}
talkRate.addEventListener("input", updateTalkLabels);
talkAmount.addEventListener("input", updateTalkLabels);
talkJitter.addEventListener("input", updateTalkLabels);
updateTalkLabels();
async function pushTalk() {
try {
await postState({
talk: {
enabled: talkEnabled,
rate_hz: Number(talkRate.value),
amount: Number(talkAmount.value),
jitter: Number(talkJitter.value),
}
});
} catch(e){ console.error(e); }
}
talkToggleBtn.addEventListener("click", async () => {
talkEnabled = !talkEnabled;
talkToggleBtn.textContent = "Talk: " + (talkEnabled ? "ON" : "OFF");
talkToggleBtn.classList.toggle("primary", !talkEnabled); // OFF = primary (wie vorher)
await pushTalk();
});
[talkRate, talkAmount, talkJitter].forEach(el => {
el.addEventListener("change", async () => {
if (talkEnabled) await pushTalk();
});
});
// Live state via SSE (optional, aber nice)
function connectSSE() {
try {
const es = new EventSource("/events");
es.addEventListener("state", (e) => {
setConn(true);
try {
lastState = JSON.parse(e.data);
updateStateText(lastState);
// sync some UI hints
if (typeof lastState?.intensity === "number") {
intensity.value = lastState.intensity;
intensityVal.textContent = fmt(intensity.value);
}
if (lastState?.look === null) {
wander = true;
$("wanderBtn").textContent = "Blick: wander";
$("wanderBtn").classList.add("primary");
} else if (lastState?.look) {
wander = false;
$("wanderBtn").textContent = "Blick: fix";
$("wanderBtn").classList.remove("primary");
lookX.value = lastState.look.x ?? 0;
lookY.value = lastState.look.y ?? 0;
updateLookLabels();
}
if (lastState?.talk) {
talkEnabled = !!lastState.talk.enabled;
talkToggleBtn.textContent = "Talk: " + (talkEnabled ? "ON" : "OFF");
talkToggleBtn.classList.toggle("primary", !talkEnabled);
if (typeof lastState.talk.rate_hz === "number") talkRate.value = lastState.talk.rate_hz;
if (typeof lastState.talk.amount === "number") talkAmount.value = lastState.talk.amount;
if (typeof lastState.talk.jitter === "number") talkJitter.value = lastState.talk.jitter;
updateTalkLabels();
}
} catch {}
});
es.onerror = () => { setConn(false); es.close(); setTimeout(connectSSE, 1200); };
} catch {
setConn(false);
}
}
connectSSE();
// Initial ping
fetch("/api/state").then(r => r.json()).then(j => {
setConn(true);
lastState = j.state;
updateStateText(lastState);
}).catch(() => setConn(false));
</script>
<script src="/control/control.js"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
:root{
--panel:#0b1020cc;
--text:#e8f0ff;
--shadow: 0 12px 40px rgba(0,0,0,.55);
--r: 18px;
}
html, body { height:100%; margin:0; background:#000; }
.stage{
position:fixed; inset:0;
background:#000;
touch-action:manipulation;
user-select:none;
}
.face{ width:100vw; height:100vh; display:block; }
/* Touch overlay: 2 links only */
.overlay{
position:fixed;
left:max(12px, env(safe-area-inset-left));
right:max(12px, env(safe-area-inset-right));
bottom:max(12px, env(safe-area-inset-bottom));
display:flex; gap:10px; align-items:center;
background:var(--panel);
border:1px solid rgba(255,255,255,.10);
border-radius:var(--r);
padding:10px 12px;
box-shadow:var(--shadow);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
opacity:0;
transform: translateY(10px);
pointer-events:none;
transition: opacity .18s ease, transform .18s ease;
}
.overlay.show{ opacity:1; transform:translateY(0); pointer-events:auto; }
a.btn{
border:1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.06);
color: var(--text);
border-radius:14px;
padding:10px 12px;
font-size:14px;
text-decoration:none;
line-height:1;
display:inline-flex; align-items:center; gap:8px;
}
a.btn:active{ transform: translateY(1px); }
+368
View File
@@ -0,0 +1,368 @@
(() => {
const OVERLAY_TIMEOUT_MS = 2500;
const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
const clamp01 = (v) => {
const n = Number(v);
if (Number.isNaN(n)) return 0;
return clamp(n, 0, 1);
};
const EMOTIONS = new Set(["neutral","happy","sad","angry","sleepy","surprised","excited"]);
const state = {
emotion: "neutral",
intensity: 0.85,
look: { x: 0, y: 0 },
lookLock: false,
speaking: false,
eyesMoving: true,
};
const stage = document.getElementById("stage");
const overlay = document.getElementById("overlay");
const eyeL = document.getElementById("eyeL");
const eyeR = document.getElementById("eyeR");
const eyesGroup = document.getElementById("eyesGroup");
const mouth = document.getElementById("mouth");
// Touch overlay show
let overlayTimer = null;
function showOverlay(){
overlay.classList.add("show");
clearTimeout(overlayTimer);
overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS);
}
stage.addEventListener("pointerdown", () => showOverlay(), { passive:true });
// STYLE: closer to sticker art (thin edge, almost no glow)
function applyStyle(el) {
const a = 0.90 + 0.10 * state.intensity;
el.setAttribute("fill", `rgba(120,235,255,${a})`);
el.setAttribute("stroke", `rgba(210,255,255,${0.45 + 0.35 * state.intensity})`);
el.setAttribute("stroke-width", "4.5");
el.setAttribute("stroke-linejoin", "round");
el.setAttribute("stroke-linecap", "round");
}
// Helpers: Ellipse path (absolute, clean)
function ellipsePath(cx, cy, rx, ry) {
return `M ${cx-rx},${cy}
C ${cx-rx},${cy-ry} ${cx},${cy-ry} ${cx},${cy-ry}
C ${cx+rx},${cy-ry} ${cx+rx},${cy} ${cx+rx},${cy}
C ${cx+rx},${cy+ry} ${cx},${cy+ry} ${cx},${cy+ry}
C ${cx-rx},${cy+ry} ${cx-rx},${cy} ${cx-rx},${cy} Z`;
}
// ICON SHAPES (hand-tuned proportions similar to your reference)
// Coordinate system: viewBox 0..1000 x 0..600
const ICONS = {
neutral: {
// friendly neutral eyes: a bit wider + slightly shorter (feels warm, not surprised)
eyeL: ellipsePath(395, 270, 50, 90),
eyeR: ellipsePath(605, 270, 50, 90),
// friendly micro-smile: more curve + a bit wider
mouth: `M 400,442
Q 500,505 600,442
Q 560,478 500,478
Q 440,478 400,442 Z`,
// speaking visemes (keep "smile family" so it stays friendly while talking)
visemes: [
`M 400,442
Q 500,505 600,442
Q 560,478 500,478
Q 440,478 400,442 Z`,
`M 390,432
Q 500,520 610,432
Q 565,492 500,492
Q 435,492 390,432 Z`,
`M 375,418
Q 500,540 625,418
Q 570,510 500,510
Q 430,510 375,418 Z`,
],
allowLook: true,
},
happy: {
// Eyes: thick, filled, closed (no "eyebrow" look), positioned lower
eyeL: `M 350,308
Q 395,252 440,308
Q 395,276 350,308 Z`,
eyeR: `M 560,308
Q 605,252 650,308
Q 605,276 560,308 Z`,
// Mouth: smaller + centered (about "up to mid-eye width")
mouth: `M 410,392
Q 500,475 590,392
Q 565,452 500,452
Q 435,452 410,392 Z`,
// Speaking visemes: same style family, not oversized
visemes: [
`M 410,392
Q 500,475 590,392
Q 565,452 500,452
Q 435,452 410,392 Z`,
`M 402,384
Q 500,492 598,384
Q 570,466 500,466
Q 430,466 402,384 Z`,
`M 392,374
Q 500,512 608,374
Q 575,484 500,484
Q 425,484 392,374 Z`,
],
allowLook: false,
},
sad: {
// thin sleepy-ish eyes like reference bottom-right
eyeL: `M 330,270
Q 395,250 460,270
Q 460,294 395,294
Q 330,294 330,270 Z`,
eyeR: `M 540,270
Q 605,250 670,270
Q 670,294 605,294
Q 540,294 540,270 Z`,
mouth: `M 330,490
Q 500,350 670,490
Q 610,420 500,420
Q 390,420 330,490 Z`,
visemes: [
`M 330,490 Q 500,350 670,490 Q 610,420 500,420 Q 390,420 330,490 Z`,
`M 350,500 Q 500,360 650,500 Q 600,440 500,440 Q 400,440 350,500 Z`,
`M 365,510 Q 500,380 635,510 Q 590,460 500,460 Q 410,460 365,510 Z`,
],
allowLook: false,
},
sleepy: {
// even flatter than sad
eyeL: `M 320,270
Q 395,258 470,270
Q 470,292 395,304
Q 320,292 320,270 Z`,
eyeR: `M 530,270
Q 605,258 680,270
Q 680,292 605,304
Q 530,292 530,270 Z`,
mouth: `M 335,495
Q 500,360 665,495
Q 610,435 500,435
Q 390,435 335,495 Z`,
visemes: [
`M 335,495 Q 500,360 665,495 Q 610,435 500,435 Q 390,435 335,495 Z`,
`M 355,505 Q 500,380 645,505 Q 595,455 500,455 Q 405,455 355,505 Z`,
`M 370,515 Q 500,400 630,515 Q 585,470 500,470 Q 415,470 370,515 Z`,
],
allowLook: false,
},
angry: {
// Eyes: sharp, inward pointing "evil" shapes (closer to template)
eyeL: `M 325,255
Q 360,205 435,225
Q 455,230 470,245
Q 415,330 340,305
Q 315,295 325,255 Z`,
eyeR: `M 675,255
Q 640,205 565,225
Q 545,230 530,245
Q 585,330 660,305
Q 685,295 675,255 Z`,
// Mouth: smaller, angled trapezoid (not huge)
mouth: `M 405,410
L 600,445
L 565,500
L 360,468 Z`,
// Speaking visemes: same "shout" family but not growing absurdly
visemes: [
`M 405,410 L 600,445 L 565,500 L 360,468 Z`,
`M 395,405 L 610,448 L 575,515 L 350,480 Z`,
`M 385,398 L 620,452 L 590,530 L 340,495 Z`,
],
allowLook: false,
},
surprised: {
eyeL: ellipsePath(395, 270, 50, 100),
eyeR: ellipsePath(605, 270, 50, 100),
mouth: `M 450,382
Q 500,340 550,382
Q 585,450 550,518
Q 500,560 450,518
Q 415,450 450,382 Z`,
visemes: [
`M 450,382 Q 500,340 550,382 Q 585,450 550,518 Q 500,560 450,518 Q 415,450 450,382 Z`,
`M 440,370 Q 500,320 560,370 Q 600,450 560,530 Q 500,580 440,530 Q 400,450 440,370 Z`,
`M 430,360 Q 500,300 570,360 Q 615,450 570,540 Q 500,600 430,540 Q 385,450 430,360 Z`,
],
allowLook: true,
},
excited: {
// excited: big surprised eyes, big grin
eyeL: ellipsePath(395, 270, 54, 108),
eyeR: ellipsePath(605, 270, 54, 108),
mouth: `M 315,350
Q 500,560 685,350
Q 620,545 500,545
Q 380,545 315,350 Z`,
visemes: [
`M 315,350 Q 500,560 685,350 Q 620,545 500,545 Q 380,545 315,350 Z`,
`M 305,340 Q 500,580 695,340 Q 625,560 500,560 Q 375,560 305,340 Z`,
`M 290,330 Q 500,600 710,330 Q 630,575 500,575 Q 370,575 290,330 Z`,
],
allowLook: true,
},
};
// Look movement only for allowedLook emotions (otherwise it destroys icon-eyes)
function applyLook() {
const cfg = ICONS[state.emotion] ?? ICONS.neutral;
if (!cfg.allowLook) {
eyesGroup.setAttribute("transform", "");
return;
}
const dx = clamp(state.look.x, -1, 1) * 16;
const dy = clamp(state.look.y, -1, 1) * 12;
eyesGroup.setAttribute("transform", `translate(${dx},${dy})`);
}
// Speaking: use viseme cycling instead of scaling (keeps shapes “sticker clean”)
let speakTimer = null;
let visemeIndex = 0;
function startSpeaking() {
stopSpeaking();
visemeIndex = 0;
const base = state.emotion === "excited" ? 90 : (state.emotion === "sleepy" ? 180 : 120);
speakTimer = setInterval(() => {
if (!state.speaking) return;
visemeIndex = (visemeIndex + 1) % 3;
render(); // will pick viseme
}, base);
}
function stopSpeaking() {
if (speakTimer) clearInterval(speakTimer);
speakTimer = null;
visemeIndex = 0;
mouth.setAttribute("transform", "");
}
// Optional drift (only if allowLook and not locked)
let driftTimer = null;
function scheduleDrift() {
clearTimeout(driftTimer);
const cfg = ICONS[state.emotion] ?? ICONS.neutral;
if (state.lookLock || !state.eyesMoving || !cfg.allowLook) return;
state.look = {
x: (Math.random() * 2 - 1) * 0.5,
y: (Math.random() * 2 - 1) * 0.35,
};
render();
driftTimer = setTimeout(scheduleDrift, 900 + Math.random() * 900);
}
function render() {
const cfg = ICONS[state.emotion] ?? ICONS.neutral;
applyStyle(eyeL);
applyStyle(eyeR);
applyStyle(mouth);
eyeL.setAttribute("d", cfg.eyeL);
eyeR.setAttribute("d", cfg.eyeR);
const mouthPath = (state.speaking && cfg.visemes) ? cfg.visemes[visemeIndex] : cfg.mouth;
mouth.setAttribute("d", mouthPath);
applyLook();
}
// Public API
window.applyFaceState = (payload) => {
if (!payload || typeof payload !== "object") return;
if (payload.intensity !== undefined) state.intensity = clamp01(payload.intensity);
if (typeof payload.emotion === "string") {
state.emotion = EMOTIONS.has(payload.emotion) ? payload.emotion : "neutral";
}
if (payload.look && typeof payload.look === "object") {
state.look = {
x: clamp(Number(payload.look.x ?? 0), -1, 1),
y: clamp(Number(payload.look.y ?? 0), -1, 1),
};
state.lookLock = true;
} else {
state.lookLock = false;
}
if (typeof payload.speaking === "boolean") state.speaking = payload.speaking;
if (typeof payload.eyesMoving === "boolean") state.eyesMoving = payload.eyesMoving;
render();
if (state.speaking) startSpeaking();
else stopSpeaking();
if (!state.lookLock) scheduleDrift();
};
// SSE /events
(function connectSSE(){
let es;
function open(){
es = new EventSource("/events");
const handle = (ev) => {
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
};
es.addEventListener("state", handle);
es.onmessage = handle;
es.onerror = () => {
try { es.close(); } catch (_) {}
setTimeout(open, 1200);
};
}
open();
})();
// init
render();
scheduleDrift();
})();
+31 -16
View File
@@ -2,25 +2,40 @@
<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" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Face</title>
<link rel="stylesheet" href="/face.css" />
</head>
<body class="emotion-neutral">
<div id="face" aria-label="Robot face">
<div class="eyes">
<div class="eye"></div>
<div class="eye"></div>
<body>
<div class="stage" id="stage">
<svg class="face" viewBox="0 0 1000 600" role="img" aria-label="face">
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<g id="faceGroup" filter="url(#glow)">
<g id="eyesGroup">
<path id="eyeL" d="" />
<path id="eyeR" d="" />
</g>
<path id="mouth" d="" />
</g>
</svg>
<!-- Touch-only links (NO params) -->
<div class="overlay" id="overlay" aria-hidden="true">
<a class="btn" href="/drive">🚗 Drive</a>
<a class="btn" href="/control">🎛️ Control</a>
</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>
<script src="/face.js"></script>
</body>
</html>
-286
View File
@@ -1,286 +0,0 @@
: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;
--mouth-line-opacity: 0;
}
body.emotion-happy .mouth-shape { }
body.emotion-happy .mouth-shape.frown { } /* no-op */
body.emotion-happy .mouth-shape::before {
top: -20%; /* war 38% -> kleiner = weiter nach oben */
height: 62%; /* etwas größer, damit der Bogen schön wirkt */
}
body.emotion-sad {
--glow: rgba(120, 180, 255, 0.32);
--smile: 0;
--frown: 1;
--omouth: 0;
--mouth-line-y: 42%;
--mouth-line-h: 12px;
--mouth-line-opacity: 0;
}
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; }
:root{
--intensity: 0.7; /* 0..1 */
--mouth-open: 0; /* 0..1 */
}
/* Glow stärker je nach Intensität */
.eye, .mouth {
box-shadow: 0 0 calc(26px + 30px * var(--intensity)) var(--glow);
}
/* Mund-Linie “öffnet”: wird dicker und etwas tiefer */
.mouth-shape::after {
height: calc(var(--mouth-line-h) + 26px * var(--mouth-open));
top: calc(var(--mouth-line-y) + 6% * var(--mouth-open));
opacity: calc(var(--mouth-line-opacity) + 0.10 * var(--mouth-open));
}
+6
View File
@@ -0,0 +1,6 @@
interface=wlan0
dhcp-range=10.42.0.50,10.42.0.150,255.255.255.0,12h
bind-interfaces
# Captive Portal DNS
address=/#/10.42.0.1
+14
View File
@@ -0,0 +1,14 @@
interface=wlan0
driver=nl80211
ssid=Helva
hw_mode=g
channel=6
# wmm_enabled=1
auth_algs=1
# wpa=2
wpa=0
# wpa_passphrase=Helva
# wpa_key_mgmt=WPA-PSK
# rsn_pairwise=CCMP
country_code=AT
ieee80211n=1
@@ -0,0 +1,6 @@
[Match]
Name=wlan0
[Network]
Address=10.42.0.1/24
ConfigureWithoutCarrier=yes
@@ -0,0 +1,6 @@
[Unit]
After=network-online.target
Wants=network-online.target
[Service]
ExecStartPre=/sbin/ip link set wlan0 up
+1
View File
@@ -0,0 +1 @@
<html>