Compare commits
22
Commits
cafae8a71d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ccb7d21dd | ||
|
|
d3b274e205 | ||
|
|
ce60155838 | ||
|
|
96a2048cb6 | ||
|
|
da4cb8a6e6 | ||
|
|
19ac56b19b | ||
|
|
91f3120f4f | ||
|
|
18fe02deb4 | ||
|
|
caed34956c | ||
|
|
9c3c162a82 | ||
|
|
a7300eba9b | ||
|
|
afca03954c | ||
|
|
e3b553696c | ||
|
|
3d5b5704f5 | ||
|
|
34756be2de | ||
|
|
c296d4e950 | ||
|
|
a36981da66 | ||
|
|
cd8569318f | ||
|
|
29b1e5c29d | ||
|
|
c2bb45ac5a | ||
|
|
4b040391da | ||
|
|
f75c654a39 |
@@ -0,0 +1 @@
|
||||
drive/opt/drive-ctl/__pycache__/
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
source /opt/drive-ctl-venv/bin/activate
|
||||
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
|
||||
|
||||
@@ -223,4 +240,23 @@ Check Serial device is stable, otherwise create a udev rule
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
targetLeft = left;
|
||||
targetRight = right;
|
||||
setBTS7960(0, L_RPWM, L_LPWM);
|
||||
setBTS7960(0, R_RPWM, R_LPWM);
|
||||
}
|
||||
|
||||
// ------------------------- 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;
|
||||
void outputLR(int l, int r) {
|
||||
l = applyMinPwm(l);
|
||||
r = applyMinPwm(r);
|
||||
|
||||
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
|
||||
lastCmdMs = millis();
|
||||
bool lFlip = (sign(lastOutL) != 0) && (sign(l) != 0) && (sign(lastOutL) != sign(l));
|
||||
bool rFlip = (sign(lastOutR) != 0) && (sign(r) != 0) && (sign(lastOutR) != sign(r));
|
||||
|
||||
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);
|
||||
if (lFlip || rFlip) {
|
||||
setBTS7960(0, L_RPWM, L_LPWM);
|
||||
setBTS7960(0, R_RPWM, R_LPWM);
|
||||
delay(DIR_CHANGE_STOP_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse "f a" / "b a" / "l a" / "r a"
|
||||
n = sscanf(line.c_str(), "%c %d", &cmd, &a);
|
||||
if (n < 2) return;
|
||||
setBTS7960(l, L_RPWM, L_LPWM);
|
||||
setBTS7960(r, R_RPWM, R_LPWM);
|
||||
|
||||
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;
|
||||
}
|
||||
lastOutL = l;
|
||||
lastOutR = r;
|
||||
}
|
||||
|
||||
// ------------------------------ Arduino ------------------------------------
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
-132
@@ -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 /; }
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
location /drive-ws {
|
||||
location = /drive-ws {
|
||||
proxy_pass http://127.0.0.1:8002/ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600;
|
||||
}
|
||||
|
||||
location /drive-health {
|
||||
|
||||
@@ -5,11 +5,33 @@ import serial
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
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
|
||||
|
||||
# Safety: if websocket stops sending, we also stop.
|
||||
STOP_AFTER_SEC = 0.35
|
||||
STOP_AFTER_SEC = 0.5
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -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")
|
||||
@@ -35,6 +58,9 @@ async def ws(websocket: WebSocket):
|
||||
global last_msg_ts
|
||||
await websocket.accept()
|
||||
|
||||
last_msg_ts = time.time()
|
||||
send_lr(0, 0)
|
||||
|
||||
async def watchdog():
|
||||
while True:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
+268
-152
@@ -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>
|
||||
<div style="min-width: 320px;">
|
||||
<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">
|
||||
<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 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="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,142 +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
|
||||
|
||||
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>
|
||||
|
||||
@@ -2,6 +2,20 @@ 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;
|
||||
index index.html;
|
||||
|
||||
@@ -31,6 +45,5 @@ server {
|
||||
add_header Cache-Control no-cache;
|
||||
}
|
||||
|
||||
include /opt/helva-robot/drive/etc/nginx/snippets/drive.conf;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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] = {}
|
||||
def clamp(v: Any, lo: float, hi: float) -> float:
|
||||
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:
|
||||
try:
|
||||
out["intensity"] = clamp(float(patch["intensity"]), 0.0, 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
def merge_state(payload: Dict[str, Any]) -> None:
|
||||
if isinstance(payload.get("emotion"), str):
|
||||
STATE["emotion"] = payload["emotion"]
|
||||
|
||||
if "look" in patch:
|
||||
look = patch["look"]
|
||||
if look is None:
|
||||
out["look"] = None
|
||||
elif isinstance(look, dict):
|
||||
if "intensity" in payload:
|
||||
STATE["intensity"] = clamp(payload["intensity"], 0.0, 1.0)
|
||||
|
||||
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}
|
||||
|
||||
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:
|
||||
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}
|
||||
q.put_nowait(msg)
|
||||
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
|
||||
if "blink" in patch:
|
||||
out["blink"] = bool(patch["blink"])
|
||||
@app.get("/state")
|
||||
async def get_state():
|
||||
return JSONResponse(STATE)
|
||||
|
||||
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))
|
||||
@app.get("/api/state")
|
||||
async def get_state():
|
||||
return JSONResponse(STATE)
|
||||
|
||||
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}
|
||||
except Exception:
|
||||
pass
|
||||
@app.post("/state")
|
||||
async def set_state(payload: Dict[str, Any]):
|
||||
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")
|
||||
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}
|
||||
|
||||
|
||||
Executable
+14
@@ -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')
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
</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>
|
||||
<button class="btn" id="center">Center</button>
|
||||
<button class="btn" id="stareOff">Stare OFF</button>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
|
||||
<div class="mouth">
|
||||
<div class="mouth-shape"></div>
|
||||
</div>
|
||||
<g id="faceGroup" filter="url(#glow)">
|
||||
<g id="eyesGroup">
|
||||
<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>
|
||||
|
||||
<script src="/app.js?v=2"></script>
|
||||
<script src="/face.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Executable
+14
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
<html>
|
||||
Reference in New Issue
Block a user