Compare commits

...
22 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
Helva 4b040391da fixed web-control path and watchdog stoping 2026-02-08 14:04:31 +01:00
Helva f75c654a39 remote control problem 2026-02-08 13:23:02 +01:00
25 changed files with 1483 additions and 1526 deletions
+1
View File
@@ -0,0 +1 @@
drive/opt/drive-ctl/__pycache__/
+41 -5
View File
@@ -17,7 +17,7 @@ We build a robot with raspberry and arduino.
python3 -m venv /opt/face/venv python3 -m venv /opt/face/venv
/opt/face/venv/bin/pip install --upgrade pip /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 ### Exchange Nginx Web-Folder
sudo rm -r /var/www/html 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" \ curl -X POST http://<pi>/api/state -H "Content-Type: application/json" \
-d '{"emotion":"happy","blink":true,"intensity":0.85}' -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/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 chown -R www-data:www-data /opt/face/*.py
sudo chmod -R 755 /opt/face sudo chmod -R 755 /opt/face/*.py
Start: ### Start:
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now face.service sudo systemctl enable --now face.service
sudo systemctl status face.service --no-pager sudo systemctl status face.service --no-pager
### Face Control UI ### Face Control UI
http://localhost/control/ http://localhost/control/
@@ -193,11 +196,25 @@ For Web-UI-Controller you need to have access to the raspberry-pi i.e. via a hot
sudo apt install -y python3-venv sudo apt install -y python3-venv
python3 -m venv /opt/drive-ctl-venv python3 -m venv /opt/drive-ctl-venv
sudo chown -R $USER:$USER /opt/drive-ctl-venv
/opt/drive-ctl-venv/bin/pip install fastapi uvicorn pyserial /opt/drive-ctl-venv/bin/pip install fastapi uvicorn pyserial
source /opt/drive-ctl-venv/bin/activate source /opt/drive-ctl-venv/bin/activate
pip install fastapi uvicorn pyserial pip install fastapi uvicorn pyserial
Configure Arduino-USB-ID
ls -l /dev/serial/by-id/ 2>/dev/null
Example Output
usb-1a86_USB2.0-Serial-if00-port0 -> ../../ttyUSB0
Watch USB devices while replugging
dmesg -T | tail -n 80
Configure the USB-Device-ID in /opt/helva-robot/drive/opt/drive-ctl/server.py
### Activate Web Control Interface ### Activate Web Control Interface
@@ -223,4 +240,23 @@ Check Serial device is stable, otherwise create a udev rule
http://pi-ip/drive/ http://pi-ip/drive/
### Debug drive-ctl WebUI
check local webservice
curl -s http://127.0.0.1:8002/health
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
+113 -253
View File
@@ -1,303 +1,163 @@
/* /*
4WD Robot Motor Control (2x BTS7960: Left + Right) Arduino Drive "Safe Executor"
- Soft start/stop via ramping (slew-rate limiter) - Receives: L <int> R <int> (-255..255)
- Differential drive mixing: throttle + turn - Outputs PWM immediately (no ramping here)
- Serial control (USB): f/b/l/r/s or m <throttle> <turn> - Safety: watchdog stop if no command
- Watchdog: stops if no command received in time - Deadzone + Min PWM mapping to avoid motor buzzing at low PWM
- Optional direction-change protection (short stop)
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.
*/ */
#include <Arduino.h> // ===== Left BTS7960 pins =====
#include <PS2X_lib.h> const uint8_t L_RPWM = 5;
PS2X ps2x; const uint8_t L_LPWM = 6;
const uint8_t L_REN = 7;
const uint8_t L_LEN = 8;
// ------ PS2 Controller Pins ------------- // ===== Right BTS7960 pins =====
const uint8_t PS2_CLK = 2; const uint8_t R_RPWM = 9;
const uint8_t PS2_ATT = 3; const uint8_t R_LPWM = 10;
const uint8_t PS2_CMD = 4; const uint8_t R_REN = 11;
const uint8_t PS2_DAT = 12; const uint8_t R_LEN = 12;
// ------- USE PS2 Controller or Serial Input -------------- // ===== Safety / feel =====
#define USE_PS2 1 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) ----------------------------- // Optional: beim Richtungswechsel kurz stoppen (schont Treiber/Getriebe)
// LEFT BTS7960 const bool PROTECT_DIR_CHANGE = true;
const uint8_t L_LPWM = 5; // PWM pin const uint16_t DIR_CHANGE_STOP_MS = 60;
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)
// RIGHT BTS7960 int targetL = 0, targetR = 0;
const uint8_t R_LPWM = 9; // PWM pin unsigned long lastCmdMs = 0;
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)
// --------------------------- Tuning / Limits ------------------------------- int lastOutL = 0, lastOutR = 0;
// PWM range: 0..255
const int PWM_MAX = 255;
// Ramp speed: PWM units per second (z.B. 300 => ~0.85s von 0 auf 255) static int clamp255(int v) {
const float RAMP_UP_PER_SEC = 320.0f; if (v > 255) return 255;
const float RAMP_DOWN_PER_SEC = 520.0f; // meist darf bremsen/stoppen schneller sein if (v < -255) return -255;
// 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;
return v; return v;
} }
static int applyDeadband(int v, int db) { int applyMinPwm(int v) {
if (abs(v) < db) return 0; v = clamp255(v);
return 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 void setBTS7960(int speed, uint8_t rpwm, uint8_t lpwm) {
static float rampTo(float current, float target, float dtSec) { speed = clamp255(speed);
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
if (speed > 0) { if (speed > 0) {
analogWrite(lpwm, pwm); analogWrite(rpwm, (uint8_t)speed);
analogWrite(rpwm, 0); analogWrite(lpwm, 0);
} else if (speed < 0) { } else if (speed < 0) {
analogWrite(lpwm, 0);
analogWrite(rpwm, pwm);
} else {
analogWrite(lpwm, 0);
analogWrite(rpwm, 0); analogWrite(rpwm, 0);
analogWrite(lpwm, (uint8_t)(-speed));
} else {
analogWrite(rpwm, 0);
analogWrite(lpwm, 0);
} }
} }
static void stopAll() { bool parseLine(const String& line, int &outL, int &outR) {
targetLeft = 0; int idxL = line.indexOf('L');
targetRight = 0; 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: void setupPins() {
// throttle: -255..255, turn: -255..255 pinMode(L_RPWM, OUTPUT); pinMode(L_LPWM, OUTPUT);
static void setMix(int throttle, int turn) { pinMode(L_REN, OUTPUT); pinMode(L_LEN, OUTPUT);
throttle = clampInt(throttle, -PWM_MAX, PWM_MAX);
turn = clampInt(turn, -PWM_MAX, PWM_MAX);
throttle = applyDeadband(throttle, INPUT_DEADBAND); pinMode(R_RPWM, OUTPUT); pinMode(R_LPWM, OUTPUT);
turn = applyDeadband(turn, INPUT_DEADBAND); pinMode(R_REN, OUTPUT); pinMode(R_LEN, OUTPUT);
// Classic mix digitalWrite(L_REN, HIGH); digitalWrite(L_LEN, HIGH);
int left = throttle + turn; digitalWrite(R_REN, HIGH); digitalWrite(R_LEN, HIGH);
int right = throttle - turn;
// Normalize if exceeds range setBTS7960(0, L_RPWM, L_LPWM);
int maxMag = max(abs(left), abs(right)); setBTS7960(0, R_RPWM, R_LPWM);
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));
}
targetLeft = left;
targetRight = right;
} }
// ------------------------- Serial command parser --------------------------- void outputLR(int l, int r) {
// Commands: l = applyMinPwm(l);
// f <pwm> -> forward r = applyMinPwm(r);
// 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;
char cmd = tolower(line.charAt(0)); if (PROTECT_DIR_CHANGE) {
// if sign changes across 0 while moving -> brief stop
auto sign = [](int v) -> int { return (v > 0) - (v < 0); };
// Update watchdog timestamp on any valid-looking input bool lFlip = (sign(lastOutL) != 0) && (sign(l) != 0) && (sign(lastOutL) != sign(l));
lastCmdMs = millis(); bool rFlip = (sign(lastOutR) != 0) && (sign(r) != 0) && (sign(lastOutR) != sign(r));
if (cmd == 's') { if (lFlip || rFlip) {
stopAll(); setBTS7960(0, L_RPWM, L_LPWM);
return; setBTS7960(0, R_RPWM, R_LPWM);
} delay(DIR_CHANGE_STOP_MS);
// 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" setBTS7960(l, L_RPWM, L_LPWM);
n = sscanf(line.c_str(), "%c %d", &cmd, &a); setBTS7960(r, R_RPWM, R_LPWM);
if (n < 2) return;
a = clampInt(a, 0, PWM_MAX); lastOutL = l;
lastOutR = r;
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;
}
} }
// ------------------------------ Arduino ------------------------------------
void setup() { 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.begin(115200);
Serial.println(F("BTS7960 Robot Control ready.")); setupPins();
Serial.println(F("Commands: f/b/l/r <0..255>, m <throttle -255..255> <turn -255..255>, s")); 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(); lastCmdMs = millis();
lastControlMs = millis();
} }
void loop() { 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()) { while (Serial.available()) {
char c = (char)Serial.read(); String line = Serial.readStringUntil('\n');
if (c == '\n' || c == '\r') { line.trim();
if (buf.length() > 0) { if (line.length() == 0) continue;
handleSerialLine(buf);
buf = ""; 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 { } else {
// avoid huge line Serial.println(F("Parse error -> STOP"));
if (buf.length() < 80) buf += c; targetL = 0; targetR = 0;
lastCmdMs = millis();
outputLR(0, 0);
} }
} }
#ENDIF
// -------- Watchdog -------- // Watchdog stop
if (millis() - lastCmdMs > COMMAND_TIMEOUT_MS) { if (millis() - lastCmdMs > CMD_TIMEOUT_MS) {
stopAll(); outputLR(0, 0);
}
// -------- 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));
} }
} }
@@ -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 /; }
+4 -3
View File
@@ -1,15 +1,16 @@
location /drive/ { location ^~ /drive {
alias /opt/helva-robot/drive/var/www/drive/; alias /opt/helva-robot/drive/var/www/drive;
try_files $uri $uri/ /drive/index.html; try_files $uri $uri/ /drive/index.html;
} }
location /drive-ws { location = /drive-ws {
proxy_pass http://127.0.0.1:8002/ws; proxy_pass http://127.0.0.1:8002/ws;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade"; proxy_set_header Connection "Upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_read_timeout 3600;
} }
location /drive-health { location /drive-health {
+28 -2
View File
@@ -5,11 +5,33 @@ import serial
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
SERIAL_PORT = "/dev/ttyACM0" # ggf. /dev/ttyUSB0 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 BAUD = 115200
# Safety: if websocket stops sending, we also stop. # Safety: if websocket stops sending, we also stop.
STOP_AFTER_SEC = 0.35 STOP_AFTER_SEC = 0.5
app = FastAPI() app = FastAPI()
@@ -24,6 +46,7 @@ def send_lr(l: int, r: int):
l = clamp255(l) l = clamp255(l)
r = clamp255(r) r = clamp255(r)
line = f"L {l} R {r}\n".encode("ascii") line = f"L {l} R {r}\n".encode("ascii")
log_send_timing("(before write)")
ser.write(line) ser.write(line)
@app.get("/health") @app.get("/health")
@@ -35,6 +58,9 @@ async def ws(websocket: WebSocket):
global last_msg_ts global last_msg_ts
await websocket.accept() await websocket.accept()
last_msg_ts = time.time()
send_lr(0, 0)
async def watchdog(): async def watchdog():
while True: while True:
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
+268 -152
View File
@@ -3,12 +3,12 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <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> <style>
html, body { height: 100%; margin: 0; background: #0b0f14; color: #e8eef6; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; } 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; } .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; } .status { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.panel { background:#101826; border-radius: 18px; padding: 12px; } .panel { background:#101826; border-radius: 18px; padding: 12px; }
@@ -22,17 +22,22 @@
user-select: none; user-select: none;
} }
.stickBox { width: min(46vw, 340px); aspect-ratio: 1/1; } .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; } .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); } .btn:active { transform: scale(0.98); }
.btnStop { background:#7a1f2a; } .btnStop { background:#7a1f2a; }
.btnOn { outline: 3px solid rgba(59,130,246,0.7); }
input[type="range"]{ width: 220px; } input[type="range"]{ width: 240px; }
.small { opacity: 0.85; font-size: 14px; } .small { opacity: 0.86; font-size: 14px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.hint { margin-top: 8px; opacity: 0.85; font-size: 14px; text-align: center; } .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> </style>
</head> </head>
<body> <body>
@@ -56,46 +61,180 @@
</div> </div>
<div class="panel row"> <div class="panel row">
<div> <div style="min-width: 320px;">
<div style="font-weight:900">Max Speed: <span id="maxv">180</span></div> <div class="kv">
<label>Max Speed:</label>
<span class="mono"><span id="maxv">180</span></span>
</div>
<input id="max" type="range" min="60" max="255" value="180"> <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 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>
<div style="display:flex; gap:10px">
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<button class="btn" id="btnConnect">Connect</button> <button class="btn" id="btnConnect">Connect</button>
<button class="btn" id="btnSlow">SLOW</button>
<button class="btn btnStop" id="btnStop">STOP</button> <button class="btn btnStop" id="btnStop">STOP</button>
</div> </div>
</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"> <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>
</div> </div>
<script> <script>
(() => { (() => {
// ===== UI refs =====
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const readoutEl = document.getElementById('readout');
const maxEl = document.getElementById('max'); const maxEl = document.getElementById('max');
const maxvEl = document.getElementById('maxv'); 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 btnConnect = document.getElementById('btnConnect');
const btnStop = document.getElementById('btnStop'); const btnStop = document.getElementById('btnStop');
const btnSlow = document.getElementById('btnSlow');
const cL = document.getElementById('leftStick'); const cL = document.getElementById('leftStick');
const cR = document.getElementById('rightStick'); const cR = document.getElementById('rightStick');
const gL = cL.getContext('2d');
const gR = cR.getContext('2d');
// ===== WebSocket =====
let ws = null; let ws = null;
let connected = false; let connected = false;
// Stick states (normalized) function setStatus(txt, ok=false){
// Left stick: throttle in [-1..1] (up = +1) statusEl.textContent = txt;
// Right stick: turn in [-1..1] (right = +1) 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 throttle = 0;
let turn = 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' */) { function makeStick(canvas, mode /* 'throttle' or 'turn' */) {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const cx = canvas.width / 2; const cx = canvas.width / 2;
@@ -105,8 +244,6 @@
let active = false; let active = false;
let px = cx, py = cy; let px = cx, py = cy;
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
function pointerPos(evt){ function pointerPos(evt){
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
return { return {
@@ -115,23 +252,61 @@
}; };
} }
function apply(nx, ny) { function draw(){
// nx, ny in [-1..1] where ny is +down 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') { if (mode === 'throttle') {
// Up should be +throttle, so invert ny ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
throttle = clamp(-ny, -1, 1);
} else { } 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; active = false;
px = cx; py = cy; px = cx; py = cy;
if (mode === 'throttle') throttle = 0;
if (mode === 'turn') turn = 0;
draw(); draw();
updateAndSend(true);
} }
function onDown(evt){ function onDown(evt){
@@ -144,9 +319,11 @@
function onMove(evt){ function onMove(evt){
if (!active) return; if (!active) return;
evt.preventDefault(); evt.preventDefault();
const p = pointerPos(evt); const p = pointerPos(evt);
const dx = p.x - cx; const dx = p.x - cx;
const dy = p.y - cy; const dy = p.y - cy;
const dist = Math.hypot(dx, dy); const dist = Math.hypot(dx, dy);
const k = dist > radius ? (radius / dist) : 1; const k = dist > radius ? (radius / dist) : 1;
@@ -158,12 +335,16 @@
apply(nx, ny); apply(nx, ny);
draw(); draw();
updateAndSend(false);
} }
function onUp(evt){ function onUp(evt){
evt.preventDefault(); 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); canvas.addEventListener('pointerdown', onDown);
@@ -172,142 +353,77 @@
canvas.addEventListener('pointercancel', onUp); canvas.addEventListener('pointercancel', onUp);
canvas.addEventListener('contextmenu', e => e.preventDefault()); 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(); draw();
return { draw, stopSelf }; return { reset };
} }
function setStatus(txt, ok=false){ const leftStick = makeStick(cL, 'throttle');
statusEl.textContent = txt; const rightStick = makeStick(cR, 'turn');
statusEl.style.background = ok ? "#16331d" : "#3a1a1a";
// ===== 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] setInterval(() => {
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){
const now = performance.now(); const now = performance.now();
if (!connected) return; const dt = Math.max(0.001, (now - lastTick) / 1000);
if (!force && (now - lastSend < 35)) return; // ~28Hz lastTick = now;
lastSend = 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) { // ramp throttle
const maxSpeed = parseInt(maxEl.value, 10); const rateT = (Math.abs(throttleTarget) > Math.abs(throttle)) ? ACCEL_PER_SEC : DECEL_PER_SEC;
const m = mix(throttle, turn); throttle = stepTowards(throttle, throttleTarget, rateT * dt);
const L = Math.round(clamp(m.l, -1, 1) * maxSpeed); // ramp turn
const R = Math.round(clamp(m.r, -1, 1) * maxSpeed); 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; 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
function hardStop() { // ===== Start =====
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
connect(); connect();
setStatus("connecting...", false); setStatus("connecting...", false);
// Safety: stop when page hidden
document.addEventListener("visibilitychange", () => {
if (document.hidden) hardStop();
});
})(); })();
</script> </script>
</body> </body>
+14 -1
View File
@@ -2,6 +2,20 @@ server {
listen 80; listen 80;
server_name _; 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; root /opt/helva-robot/face/var/www/html;
index index.html; index index.html;
@@ -31,6 +45,5 @@ server {
add_header Cache-Control no-cache; add_header Cache-Control no-cache;
} }
include /opt/helva-robot/drive/etc/nginx/snippets/drive.conf;
} }
+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
+83 -109
View File
@@ -1,151 +1,125 @@
from __future__ import annotations
import asyncio import asyncio
import json import json
from typing import Any, Dict, Optional from typing import Any, Dict, Set
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse, JSONResponse from fastapi.responses import JSONResponse, StreamingResponse
app = FastAPI() app = FastAPI()
clients: set[asyncio.Queue[str]] = set() STATE: Dict[str, Any] = {
# Global state sent to clients
state: Dict[str, Any] = {
"emotion": "neutral", "emotion": "neutral",
"intensity": 0.7, # 0..1 "intensity": 0.85,
"look": None, # {"x": -1..1, "y": -1..1} or None "look": {"x": 0.0, "y": 0.0},
"mouth": {"open": False, "amount": 0.0, "duration_ms": 0}, "speaking": False,
"talk": {"enabled": False, "rate_hz": 3.2, "amount": 0.9, "jitter": 0.25}, "eyesMoving": True,
} }
def clamp(v: float, lo: float, hi: float) -> float: CLIENTS: Set[asyncio.Queue[str]] = set()
return max(lo, min(hi, v)) 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]: def clamp(v: Any, lo: float, hi: float) -> float:
out: Dict[str, Any] = {} try:
x = float(v)
except Exception:
return lo
return max(lo, min(hi, x))
if "emotion" in patch:
out["emotion"] = str(patch["emotion"])
if "intensity" in patch: def merge_state(payload: Dict[str, Any]) -> None:
try: if isinstance(payload.get("emotion"), str):
out["intensity"] = clamp(float(patch["intensity"]), 0.0, 1.0) STATE["emotion"] = payload["emotion"]
except Exception:
pass
if "look" in patch: if "intensity" in payload:
look = patch["look"] STATE["intensity"] = clamp(payload["intensity"], 0.0, 1.0)
if look is None:
out["look"] = None if isinstance(payload.get("look"), dict):
elif isinstance(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}
if isinstance(payload.get("speaking"), bool):
STATE["speaking"] = payload["speaking"]
if isinstance(payload.get("eyesMoving"), bool):
STATE["eyesMoving"] = payload["eyesMoving"]
async def broadcast() -> None:
msg = json.dumps(STATE, separators=(",", ":"))
dead: list[asyncio.Queue[str]] = []
async with LOCK:
for q in CLIENTS:
try: try:
x = clamp(float(look.get("x", 0.0)), -1.0, 1.0) q.put_nowait(msg)
y = clamp(float(look.get("y", 0.0)), -1.0, 1.0)
out["look"] = {"x": x, "y": y}
except Exception: except Exception:
pass dead.append(q)
for q in dead:
CLIENTS.discard(q)
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
# one-shot flags are allowed but not stored in state @app.get("/state")
if "blink" in patch: async def get_state():
out["blink"] = bool(patch["blink"]) return JSONResponse(STATE)
if "talk" in patch: @app.get("/api/state")
t = patch["talk"] async def get_state():
if isinstance(t, dict): return JSONResponse(STATE)
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))
rate_hz = clamp(rate_hz, 0.5, 10.0)
amount = clamp(amount, 0.0, 1.0)
jitter = clamp(jitter, 0.0, 1.0)
out["talk"] = {"enabled": enabled, "rate_hz": rate_hz, "amount": amount, "jitter": jitter} @app.post("/state")
except Exception: async def set_state(payload: Dict[str, Any]):
pass merge_state(payload)
await broadcast()
return JSONResponse({"ok": True, "state": STATE})
return out @app.post("/api/state")
async def set_state(payload: Dict[str, Any]):
merge_state(payload)
await broadcast()
return JSONResponse({"ok": True, "state": STATE})
async def broadcast(payload: Dict[str, Any]) -> None:
msg = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
dead = []
for q in clients:
try:
q.put_nowait(msg)
except Exception:
dead.append(q)
for q in dead:
clients.discard(q)
@app.get("/events") @app.get("/events")
async def events(request: Request): 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(): async def gen():
try: try:
# Send current state immediately on connect # initial state immediately
yield sse("state", json.dumps(state, separators=(",", ":"), ensure_ascii=False)) initial = json.dumps(STATE, separators=(",", ":"))
yield f"event: state\ndata: {initial}\n\n"
while True: while True:
if await request.is_disconnected(): if await request.is_disconnected():
break 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: finally:
clients.discard(q) async with LOCK:
CLIENTS.discard(q)
headers = { headers = {
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no", # important behind nginx
} }
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers) 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"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Robot Face Control</title> <title>Face Control</title>
<style> <link rel="stylesheet" href="/control/control.css" />
: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>
</head> </head>
<body> <body>
<div class="wrap"> <main class="page">
<div class="card"> <header class="top">
<h1>Robot Face Control</h1> <h1>Face Control</h1>
<div class="toggle"> <div class="row">
<span class="pill"><span id="connDot" class="dot"></span><span id="connTxt">offline</span></span> <span class="pill">Status: <strong id="conn">?</strong></span>
<div class="small" id="stateTxt">–</div> <span class="pill">Aktuell: <strong id="current">neutral</strong></span>
</div> </div>
</div> </header>
<div class="card"> <section class="card">
<h2>Emotion</h2> <h2>Emotion</h2>
<div class="grid"> <div class="grid" id="emotionGrid"></div>
<button data-emotion="neutral" class="primary">neutral</button> </section>
<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>
<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"> <div class="row">
<label for="intensity">Intensität</label> <button class="btn" id="center">Center</button>
<div class="value" id="intensityVal">0.70</div> <button class="btn" id="stareOff">Stare OFF</button>
</div> </div>
<input id="intensity" type="range" min="0" max="1" step="0.01" value="0.70" /> </section>
<div class="footer">
<button id="blinkBtn">Blinzeln</button>
<button id="wanderBtn" class="primary">Blick: wander</button>
</div>
</div>
<div class="card"> <footer class="foot">
<h2>Blick (fix)</h2> <a class="link" href="/">← Face</a>
<div class="row"> <a class="link" href="/drive">Drive →</a>
<label for="lookX">X (links ↔ rechts)</label> </footer>
<div class="value" id="lookXVal">0.00</div> </main>
</div>
<input id="lookX" type="range" min="-1" max="1" step="0.01" value="0" />
<div class="row"> <script src="/control/control.js"></script>
<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>
</body> </body>
</html> </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();
})();
+29 -14
View File
@@ -2,25 +2,40 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Robot Face</title> <title>Face</title>
<link rel="stylesheet" href="/style.css?v=2" /> <link rel="stylesheet" href="/face.css" />
</head> </head>
<body class="emotion-neutral"> <body>
<div id="face" aria-label="Robot face"> <div class="stage" id="stage">
<div class="eyes"> <svg class="face" viewBox="0 0 1000 600" role="img" aria-label="face">
<div class="eye"></div> <defs>
<div class="eye"></div> <filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
</div> <feGaussianBlur stdDeviation="4" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<div class="mouth"> <g id="faceGroup" filter="url(#glow)">
<div class="mouth-shape"></div> <g id="eyesGroup">
</div> <path id="eyeL" d="" />
<path id="eyeR" d="" />
</g>
<path id="mouth" d="" />
</g>
</svg>
<div class="label" id="label">neutral</div> <!-- 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>
<script src="/app.js?v=2"></script> <script src="/face.js"></script>
</body> </body>
</html> </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>