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
|
python3 -m venv /opt/face/venv
|
||||||
/opt/face/venv/bin/pip install --upgrade pip
|
/opt/face/venv/bin/pip install --upgrade pip
|
||||||
/opt/face/venv/bin/pip install fastapi uvicorn
|
/opt/face/venv/bin/pip install fastapi uvicorn netifaces
|
||||||
|
|
||||||
### Exchange Nginx Web-Folder
|
### Exchange Nginx Web-Folder
|
||||||
sudo rm -r /var/www/html
|
sudo rm -r /var/www/html
|
||||||
@@ -73,18 +73,21 @@ http://pi-ip/
|
|||||||
curl -X POST http://<pi>/api/state -H "Content-Type: application/json" \
|
curl -X POST http://<pi>/api/state -H "Content-Type: application/json" \
|
||||||
-d '{"emotion":"happy","blink":true,"intensity":0.85}'
|
-d '{"emotion":"happy","blink":true,"intensity":0.85}'
|
||||||
|
|
||||||
### systemd Service
|
### systemd Services
|
||||||
sudo ln -s /opt/helva-robot/face/etc/systemd/system/face.service /etc/systemd/system/
|
sudo ln -s /opt/helva-robot/face/etc/systemd/system/face.service /etc/systemd/system/
|
||||||
|
sudo ln -s /opt/helva-robot/face/etc/systemd/system/redirect_to_tethering.service /etc/systemd/system/
|
||||||
|
|
||||||
sudo chown -R www-data:www-data /opt/face
|
sudo chown -R www-data:www-data /opt/face/*.py
|
||||||
sudo chmod -R 755 /opt/face
|
sudo chmod -R 755 /opt/face/*.py
|
||||||
|
|
||||||
Start:
|
### Start:
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now face.service
|
sudo systemctl enable --now face.service
|
||||||
sudo systemctl status face.service --no-pager
|
sudo systemctl status face.service --no-pager
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Face Control UI
|
### Face Control UI
|
||||||
http://localhost/control/
|
http://localhost/control/
|
||||||
|
|
||||||
@@ -193,11 +196,25 @@ For Web-UI-Controller you need to have access to the raspberry-pi i.e. via a hot
|
|||||||
sudo apt install -y python3-venv
|
sudo apt install -y python3-venv
|
||||||
python3 -m venv /opt/drive-ctl-venv
|
python3 -m venv /opt/drive-ctl-venv
|
||||||
|
|
||||||
|
sudo chown -R $USER:$USER /opt/drive-ctl-venv
|
||||||
/opt/drive-ctl-venv/bin/pip install fastapi uvicorn pyserial
|
/opt/drive-ctl-venv/bin/pip install fastapi uvicorn pyserial
|
||||||
|
|
||||||
source /opt/drive-ctl-venv/bin/activate
|
source /opt/drive-ctl-venv/bin/activate
|
||||||
pip install fastapi uvicorn pyserial
|
pip install fastapi uvicorn pyserial
|
||||||
|
|
||||||
|
Configure Arduino-USB-ID
|
||||||
|
|
||||||
|
ls -l /dev/serial/by-id/ 2>/dev/null
|
||||||
|
|
||||||
|
Example Output
|
||||||
|
|
||||||
|
usb-1a86_USB2.0-Serial-if00-port0 -> ../../ttyUSB0
|
||||||
|
|
||||||
|
Watch USB devices while replugging
|
||||||
|
|
||||||
|
dmesg -T | tail -n 80
|
||||||
|
|
||||||
|
Configure the USB-Device-ID in /opt/helva-robot/drive/opt/drive-ctl/server.py
|
||||||
|
|
||||||
### Activate Web Control Interface
|
### Activate Web Control Interface
|
||||||
|
|
||||||
@@ -223,4 +240,23 @@ Check Serial device is stable, otherwise create a udev rule
|
|||||||
|
|
||||||
http://pi-ip/drive/
|
http://pi-ip/drive/
|
||||||
|
|
||||||
|
### Debug drive-ctl WebUI
|
||||||
|
|
||||||
|
check local webservice
|
||||||
|
|
||||||
|
curl -s http://127.0.0.1:8002/health
|
||||||
|
|
||||||
|
should be -> {"ok": true, ... } -> otherwise look at the logs:
|
||||||
|
|
||||||
|
journalctl -u drive-ctl -n 80 --no-pager
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Networking / AccessPoint / Captive Portal
|
||||||
|
|
||||||
|
see /network/ folder for config
|
||||||
|
|
||||||
|
### Captive Portal
|
||||||
|
allows you to log into the raspi AP and you get automatically to the page of the roboter
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,303 +1,163 @@
|
|||||||
/*
|
/*
|
||||||
4WD Robot Motor Control (2x BTS7960: Left + Right)
|
Arduino Drive "Safe Executor"
|
||||||
- Soft start/stop via ramping (slew-rate limiter)
|
- Receives: L <int> R <int> (-255..255)
|
||||||
- Differential drive mixing: throttle + turn
|
- Outputs PWM immediately (no ramping here)
|
||||||
- Serial control (USB): f/b/l/r/s or m <throttle> <turn>
|
- Safety: watchdog stop if no command
|
||||||
- Watchdog: stops if no command received in time
|
- Deadzone + Min PWM mapping to avoid motor buzzing at low PWM
|
||||||
|
- Optional direction-change protection (short stop)
|
||||||
Wiring (per BTS7960 module):
|
|
||||||
- LPWM -> Arduino PWM pin
|
|
||||||
- RPWM -> Arduino PWM pin
|
|
||||||
- LEN/REN (or L_EN/R_EN) -> Arduino digital pins (or permanently HIGH to 5V)
|
|
||||||
- VCC -> 5V (logic)
|
|
||||||
- GND -> GND common with Arduino and motor supply
|
|
||||||
- B+ / B- -> motor supply (e.g. 12V)
|
|
||||||
- M+ / M- -> motor output
|
|
||||||
|
|
||||||
NOTE:
|
|
||||||
Each side is one BTS7960 module driving two motors in parallel (front+rear) on that side.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <Arduino.h>
|
// ===== Left BTS7960 pins =====
|
||||||
#include <PS2X_lib.h>
|
const uint8_t L_RPWM = 5;
|
||||||
PS2X ps2x;
|
const uint8_t L_LPWM = 6;
|
||||||
|
const uint8_t L_REN = 7;
|
||||||
|
const uint8_t L_LEN = 8;
|
||||||
|
|
||||||
// ------ PS2 Controller Pins -------------
|
// ===== Right BTS7960 pins =====
|
||||||
const uint8_t PS2_CLK = 2;
|
const uint8_t R_RPWM = 9;
|
||||||
const uint8_t PS2_ATT = 3;
|
const uint8_t R_LPWM = 10;
|
||||||
const uint8_t PS2_CMD = 4;
|
const uint8_t R_REN = 11;
|
||||||
const uint8_t PS2_DAT = 12;
|
const uint8_t R_LEN = 12;
|
||||||
|
|
||||||
// ------- USE PS2 Controller or Serial Input --------------
|
// ===== Safety / feel =====
|
||||||
#define USE_PS2 1
|
const uint16_t CMD_TIMEOUT_MS = 600; // Web sendet regelmäßig; 600ms ist entspannt
|
||||||
|
const uint8_t DEADZONE = 10; // kleine Werte -> 0
|
||||||
|
const uint8_t MIN_PWM = 70; // anpassen: 60..110 typisch (gegen "brummen")
|
||||||
|
|
||||||
// ----------------------------- Pins (ANPASSEN) -----------------------------
|
// Optional: beim Richtungswechsel kurz stoppen (schont Treiber/Getriebe)
|
||||||
// LEFT BTS7960
|
const bool PROTECT_DIR_CHANGE = true;
|
||||||
const uint8_t L_LPWM = 5; // PWM pin
|
const uint16_t DIR_CHANGE_STOP_MS = 60;
|
||||||
const uint8_t L_RPWM = 6; // PWM pin
|
|
||||||
const uint8_t L_LEN = 7; // enable pin (LEN)
|
|
||||||
const uint8_t L_REN = 8; // enable pin (REN)
|
|
||||||
|
|
||||||
// RIGHT BTS7960
|
int targetL = 0, targetR = 0;
|
||||||
const uint8_t R_LPWM = 9; // PWM pin
|
unsigned long lastCmdMs = 0;
|
||||||
const uint8_t R_RPWM = 10; // PWM pin
|
|
||||||
const uint8_t R_LEN = 11; // enable pin (LEN)
|
|
||||||
const uint8_t R_REN = 12; // enable pin (REN)
|
|
||||||
|
|
||||||
// --------------------------- Tuning / Limits -------------------------------
|
int lastOutL = 0, lastOutR = 0;
|
||||||
// PWM range: 0..255
|
|
||||||
const int PWM_MAX = 255;
|
|
||||||
|
|
||||||
// Ramp speed: PWM units per second (z.B. 300 => ~0.85s von 0 auf 255)
|
static int clamp255(int v) {
|
||||||
const float RAMP_UP_PER_SEC = 320.0f;
|
if (v > 255) return 255;
|
||||||
const float RAMP_DOWN_PER_SEC = 520.0f; // meist darf bremsen/stoppen schneller sein
|
if (v < -255) return -255;
|
||||||
|
|
||||||
// Deadband: kleine Werte ignorieren (gegen "Zittern")
|
|
||||||
const int INPUT_DEADBAND = 10;
|
|
||||||
|
|
||||||
// Watchdog: wenn so lange kein Command kommt -> STOP
|
|
||||||
const uint32_t COMMAND_TIMEOUT_MS = 600;
|
|
||||||
|
|
||||||
// Loop interval (Ramping arbeitet zeitbasiert; häufig genug aufrufen)
|
|
||||||
const uint32_t CONTROL_INTERVAL_MS = 10;
|
|
||||||
|
|
||||||
// ---------------------------- State ----------------------------------------
|
|
||||||
volatile int targetLeft = 0; // -255..255
|
|
||||||
volatile int targetRight = 0; // -255..255
|
|
||||||
|
|
||||||
float currentLeft = 0.0f; // ramped output -255..255
|
|
||||||
float currentRight = 0.0f;
|
|
||||||
|
|
||||||
uint32_t lastCmdMs = 0;
|
|
||||||
uint32_t lastControlMs = 0;
|
|
||||||
|
|
||||||
// ------------------------- Helpers -----------------------------------------
|
|
||||||
static int clampInt(int v, int lo, int hi) {
|
|
||||||
if (v < lo) return lo;
|
|
||||||
if (v > hi) return hi;
|
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int applyDeadband(int v, int db) {
|
int applyMinPwm(int v) {
|
||||||
if (abs(v) < db) return 0;
|
v = clamp255(v);
|
||||||
return v;
|
int a = abs(v);
|
||||||
|
if (a <= DEADZONE) return 0;
|
||||||
|
|
||||||
|
int s = (v >= 0) ? 1 : -1;
|
||||||
|
|
||||||
|
// map: DEADZONE..255 -> MIN_PWM..255
|
||||||
|
long mapped = MIN_PWM + (long)(a - DEADZONE) * (255 - MIN_PWM) / (255 - DEADZONE);
|
||||||
|
if (mapped > 255) mapped = 255;
|
||||||
|
|
||||||
|
return s * (int)mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ramp current towards target based on dt
|
void setBTS7960(int speed, uint8_t rpwm, uint8_t lpwm) {
|
||||||
static float rampTo(float current, float target, float dtSec) {
|
speed = clamp255(speed);
|
||||||
float diff = target - current;
|
|
||||||
if (diff == 0.0f) return current;
|
|
||||||
|
|
||||||
const float rate = (abs(target) > abs(current)) ? RAMP_UP_PER_SEC : RAMP_DOWN_PER_SEC;
|
|
||||||
float step = rate * dtSec;
|
|
||||||
|
|
||||||
if (abs(diff) <= step) return target;
|
|
||||||
return current + (diff > 0 ? step : -step);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send signed speed (-255..255) to one BTS7960
|
|
||||||
static void driveBTS7960(uint8_t lpwm, uint8_t rpwm, uint8_t len, uint8_t ren, int speed) {
|
|
||||||
speed = clampInt(speed, -PWM_MAX, PWM_MAX);
|
|
||||||
|
|
||||||
// Enable driver
|
|
||||||
digitalWrite(len, HIGH);
|
|
||||||
digitalWrite(ren, HIGH);
|
|
||||||
|
|
||||||
int pwm = abs(speed);
|
|
||||||
|
|
||||||
// IMPORTANT: never drive both PWM pins at the same time
|
|
||||||
if (speed > 0) {
|
if (speed > 0) {
|
||||||
analogWrite(lpwm, pwm);
|
analogWrite(rpwm, (uint8_t)speed);
|
||||||
analogWrite(rpwm, 0);
|
analogWrite(lpwm, 0);
|
||||||
} else if (speed < 0) {
|
} else if (speed < 0) {
|
||||||
analogWrite(lpwm, 0);
|
|
||||||
analogWrite(rpwm, pwm);
|
|
||||||
} else {
|
|
||||||
analogWrite(lpwm, 0);
|
|
||||||
analogWrite(rpwm, 0);
|
analogWrite(rpwm, 0);
|
||||||
|
analogWrite(lpwm, (uint8_t)(-speed));
|
||||||
|
} else {
|
||||||
|
analogWrite(rpwm, 0);
|
||||||
|
analogWrite(lpwm, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void stopAll() {
|
bool parseLine(const String& line, int &outL, int &outR) {
|
||||||
targetLeft = 0;
|
int idxL = line.indexOf('L');
|
||||||
targetRight = 0;
|
int idxR = line.indexOf('R');
|
||||||
|
if (idxL < 0 || idxR < 0) return false;
|
||||||
|
|
||||||
|
String partL = line.substring(idxL + 1, idxR);
|
||||||
|
String partR = line.substring(idxR + 1);
|
||||||
|
partL.trim(); partR.trim();
|
||||||
|
|
||||||
|
outL = clamp255(partL.toInt());
|
||||||
|
outR = clamp255(partR.toInt());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Differential drive mixing:
|
void setupPins() {
|
||||||
// throttle: -255..255, turn: -255..255
|
pinMode(L_RPWM, OUTPUT); pinMode(L_LPWM, OUTPUT);
|
||||||
static void setMix(int throttle, int turn) {
|
pinMode(L_REN, OUTPUT); pinMode(L_LEN, OUTPUT);
|
||||||
throttle = clampInt(throttle, -PWM_MAX, PWM_MAX);
|
|
||||||
turn = clampInt(turn, -PWM_MAX, PWM_MAX);
|
|
||||||
|
|
||||||
throttle = applyDeadband(throttle, INPUT_DEADBAND);
|
pinMode(R_RPWM, OUTPUT); pinMode(R_LPWM, OUTPUT);
|
||||||
turn = applyDeadband(turn, INPUT_DEADBAND);
|
pinMode(R_REN, OUTPUT); pinMode(R_LEN, OUTPUT);
|
||||||
|
|
||||||
// Classic mix
|
digitalWrite(L_REN, HIGH); digitalWrite(L_LEN, HIGH);
|
||||||
int left = throttle + turn;
|
digitalWrite(R_REN, HIGH); digitalWrite(R_LEN, HIGH);
|
||||||
int right = throttle - turn;
|
|
||||||
|
|
||||||
// Normalize if exceeds range
|
setBTS7960(0, L_RPWM, L_LPWM);
|
||||||
int maxMag = max(abs(left), abs(right));
|
setBTS7960(0, R_RPWM, R_LPWM);
|
||||||
if (maxMag > PWM_MAX) {
|
|
||||||
// scale down proportionally
|
|
||||||
left = (int)((float)left * ((float)PWM_MAX / (float)maxMag));
|
|
||||||
right = (int)((float)right * ((float)PWM_MAX / (float)maxMag));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
targetLeft = left;
|
void outputLR(int l, int r) {
|
||||||
targetRight = right;
|
l = applyMinPwm(l);
|
||||||
}
|
r = applyMinPwm(r);
|
||||||
|
|
||||||
// ------------------------- Serial command parser ---------------------------
|
if (PROTECT_DIR_CHANGE) {
|
||||||
// Commands:
|
// if sign changes across 0 while moving -> brief stop
|
||||||
// f <pwm> -> forward
|
auto sign = [](int v) -> int { return (v > 0) - (v < 0); };
|
||||||
// b <pwm> -> backward
|
|
||||||
// l <pwm> -> turn left (in place)
|
|
||||||
// r <pwm> -> turn right (in place)
|
|
||||||
// s -> stop
|
|
||||||
// m <throttle> <turn> -> mix mode (-255..255 each)
|
|
||||||
// Examples:
|
|
||||||
// f 140
|
|
||||||
// m 120 -40
|
|
||||||
// s
|
|
||||||
static void handleSerialLine(String line) {
|
|
||||||
line.trim();
|
|
||||||
if (line.length() == 0) return;
|
|
||||||
|
|
||||||
char cmd = tolower(line.charAt(0));
|
bool lFlip = (sign(lastOutL) != 0) && (sign(l) != 0) && (sign(lastOutL) != sign(l));
|
||||||
|
bool rFlip = (sign(lastOutR) != 0) && (sign(r) != 0) && (sign(lastOutR) != sign(r));
|
||||||
|
|
||||||
// Update watchdog timestamp on any valid-looking input
|
if (lFlip || rFlip) {
|
||||||
lastCmdMs = millis();
|
setBTS7960(0, L_RPWM, L_LPWM);
|
||||||
|
setBTS7960(0, R_RPWM, R_LPWM);
|
||||||
if (cmd == 's') {
|
delay(DIR_CHANGE_STOP_MS);
|
||||||
stopAll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split by spaces
|
|
||||||
// Simple parsing:
|
|
||||||
// cmd + integers
|
|
||||||
int a = 0, b = 0;
|
|
||||||
int n = 0;
|
|
||||||
|
|
||||||
// Try parse "m a b"
|
|
||||||
if (cmd == 'm') {
|
|
||||||
n = sscanf(line.c_str(), "m %d %d", &a, &b);
|
|
||||||
if (n == 2) {
|
|
||||||
setMix(a, b);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse "f a" / "b a" / "l a" / "r a"
|
|
||||||
n = sscanf(line.c_str(), "%c %d", &cmd, &a);
|
|
||||||
if (n < 2) return;
|
|
||||||
|
|
||||||
a = clampInt(a, 0, PWM_MAX);
|
|
||||||
|
|
||||||
switch (tolower(cmd)) {
|
|
||||||
case 'f': setMix(+a, 0); break;
|
|
||||||
case 'b': setMix(-a, 0); break;
|
|
||||||
case 'l': setMix(0, +a); break; // left turn in place
|
|
||||||
case 'r': setMix(0, -a); break; // right turn in place
|
|
||||||
default: break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------ Arduino ------------------------------------
|
setBTS7960(l, L_RPWM, L_LPWM);
|
||||||
|
setBTS7960(r, R_RPWM, R_LPWM);
|
||||||
|
|
||||||
|
lastOutL = l;
|
||||||
|
lastOutR = r;
|
||||||
|
}
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
int err = ps2x.config_gamepad(PS2_CLK, PS2_CMD, PS2_ATT, PS2_DAT, false, false);
|
|
||||||
Serial.print("PS2 init: "); Serial.println(err);
|
|
||||||
|
|
||||||
pinMode(L_LPWM, OUTPUT);
|
|
||||||
pinMode(L_RPWM, OUTPUT);
|
|
||||||
pinMode(L_LEN, OUTPUT);
|
|
||||||
pinMode(L_REN, OUTPUT);
|
|
||||||
|
|
||||||
pinMode(R_LPWM, OUTPUT);
|
|
||||||
pinMode(R_RPWM, OUTPUT);
|
|
||||||
pinMode(R_LEN, OUTPUT);
|
|
||||||
pinMode(R_REN, OUTPUT);
|
|
||||||
|
|
||||||
// Init: disabled PWM = 0, enables on
|
|
||||||
analogWrite(L_LPWM, 0); analogWrite(L_RPWM, 0);
|
|
||||||
analogWrite(R_LPWM, 0); analogWrite(R_RPWM, 0);
|
|
||||||
|
|
||||||
digitalWrite(L_LEN, HIGH); digitalWrite(L_REN, HIGH);
|
|
||||||
digitalWrite(R_LEN, HIGH); digitalWrite(R_REN, HIGH);
|
|
||||||
|
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
Serial.println(F("BTS7960 Robot Control ready."));
|
setupPins();
|
||||||
Serial.println(F("Commands: f/b/l/r <0..255>, m <throttle -255..255> <turn -255..255>, s"));
|
delay(200);
|
||||||
|
|
||||||
|
Serial.println(F("Drive Ready. Send: L <val> R <val> (-255..255)"));
|
||||||
|
Serial.print(F("DEADZONE=")); Serial.print(DEADZONE);
|
||||||
|
Serial.print(F(" MIN_PWM=")); Serial.println(MIN_PWM);
|
||||||
|
|
||||||
lastCmdMs = millis();
|
lastCmdMs = millis();
|
||||||
lastControlMs = millis();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
#if USE_PS2
|
|
||||||
// -------- PS2 Controller Input -------
|
|
||||||
ps2x.read_gamepad(false, 0);
|
|
||||||
|
|
||||||
// Not-Stop z.B. START
|
|
||||||
if (ps2x.ButtonPressed(PSB_START)) {
|
|
||||||
stopAll();
|
|
||||||
lastCmdMs = millis(); // watchdog “füttern”
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sticks lesen
|
|
||||||
int ly = ps2x.Analog(PSS_LY); // 0..255
|
|
||||||
int rx = ps2x.Analog(PSS_RX); // 0..255
|
|
||||||
|
|
||||||
// 0..255 -> -255..255
|
|
||||||
auto stickToSigned = [](int v) {
|
|
||||||
int x = (v - 128) * 2;
|
|
||||||
if (x > 255) x = 255;
|
|
||||||
if (x < -255) x = -255;
|
|
||||||
return x;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mapping: LY nach oben = vorwärts (invertieren)
|
|
||||||
int throttle = -stickToSigned(ly);
|
|
||||||
int turn = stickToSigned(rx);
|
|
||||||
|
|
||||||
lastCmdMs = millis(); // watchdog “füttern”
|
|
||||||
setMix(throttle, turn); // das ist dein Original-Mixer
|
|
||||||
|
|
||||||
#else
|
|
||||||
// -------- Serial input (line based) --------
|
|
||||||
static String buf;
|
|
||||||
while (Serial.available()) {
|
while (Serial.available()) {
|
||||||
char c = (char)Serial.read();
|
String line = Serial.readStringUntil('\n');
|
||||||
if (c == '\n' || c == '\r') {
|
line.trim();
|
||||||
if (buf.length() > 0) {
|
if (line.length() == 0) continue;
|
||||||
handleSerialLine(buf);
|
|
||||||
buf = "";
|
int l, r;
|
||||||
}
|
if (parseLine(line, l, r)) {
|
||||||
|
targetL = l;
|
||||||
|
targetR = r;
|
||||||
|
lastCmdMs = millis();
|
||||||
|
|
||||||
|
outputLR(targetL, targetR);
|
||||||
|
|
||||||
|
Serial.print(F("RX/OUT L=")); Serial.print(lastOutL);
|
||||||
|
Serial.print(F(" R=")); Serial.println(lastOutR);
|
||||||
} else {
|
} else {
|
||||||
// avoid huge line
|
Serial.println(F("Parse error -> STOP"));
|
||||||
if (buf.length() < 80) buf += c;
|
targetL = 0; targetR = 0;
|
||||||
|
lastCmdMs = millis();
|
||||||
|
outputLR(0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#ENDIF
|
|
||||||
|
|
||||||
// -------- Watchdog --------
|
|
||||||
if (millis() - lastCmdMs > COMMAND_TIMEOUT_MS) {
|
|
||||||
stopAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Control update (ramping + output) --------
|
// Watchdog stop
|
||||||
uint32_t now = millis();
|
if (millis() - lastCmdMs > CMD_TIMEOUT_MS) {
|
||||||
if (now - lastControlMs >= CONTROL_INTERVAL_MS) {
|
outputLR(0, 0);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-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/ {
|
location ^~ /drive {
|
||||||
alias /opt/helva-robot/drive/var/www/drive/;
|
alias /opt/helva-robot/drive/var/www/drive;
|
||||||
try_files $uri $uri/ /drive/index.html;
|
try_files $uri $uri/ /drive/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /drive-ws {
|
location = /drive-ws {
|
||||||
proxy_pass http://127.0.0.1:8002/ws;
|
proxy_pass http://127.0.0.1:8002/ws;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "Upgrade";
|
proxy_set_header Connection "Upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /drive-health {
|
location /drive-health {
|
||||||
|
|||||||
@@ -5,11 +5,33 @@ import serial
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
SERIAL_PORT = "/dev/ttyACM0" # ggf. /dev/ttyUSB0
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger("drive")
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
_last_send_t = None
|
||||||
|
|
||||||
|
def log_send_timing(note: str = ""):
|
||||||
|
global _last_send_t
|
||||||
|
now = time.perf_counter() # monotonic + hochauflösend
|
||||||
|
if _last_send_t is None:
|
||||||
|
_last_send_t = now
|
||||||
|
return
|
||||||
|
dt_ms = (now - _last_send_t) * 1000.0
|
||||||
|
_last_send_t = now
|
||||||
|
|
||||||
|
# Nur loggen wenn "auffällig"
|
||||||
|
if dt_ms > 120:
|
||||||
|
log.warning("SERIAL SEND GAP %.0f ms %s", dt_ms, note)
|
||||||
|
|
||||||
|
|
||||||
|
SERIAL_PORT = "/dev/serial/by-id/usb-1a86_USB2.0-Serial-if00-port0" # ggf. /dev/ttyUSB0
|
||||||
BAUD = 115200
|
BAUD = 115200
|
||||||
|
|
||||||
# Safety: if websocket stops sending, we also stop.
|
# Safety: if websocket stops sending, we also stop.
|
||||||
STOP_AFTER_SEC = 0.35
|
STOP_AFTER_SEC = 0.5
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@@ -24,6 +46,7 @@ def send_lr(l: int, r: int):
|
|||||||
l = clamp255(l)
|
l = clamp255(l)
|
||||||
r = clamp255(r)
|
r = clamp255(r)
|
||||||
line = f"L {l} R {r}\n".encode("ascii")
|
line = f"L {l} R {r}\n".encode("ascii")
|
||||||
|
log_send_timing("(before write)")
|
||||||
ser.write(line)
|
ser.write(line)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -35,6 +58,9 @@ async def ws(websocket: WebSocket):
|
|||||||
global last_msg_ts
|
global last_msg_ts
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
|
last_msg_ts = time.time()
|
||||||
|
send_lr(0, 0)
|
||||||
|
|
||||||
async def watchdog():
|
async def watchdog():
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|||||||
+269
-153
@@ -3,12 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Robot Dual Stick</title>
|
<title>Robot Dual Stick (Ramping)</title>
|
||||||
<style>
|
<style>
|
||||||
html, body { height: 100%; margin: 0; background: #0b0f14; color: #e8eef6; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; }
|
html, body { height: 100%; margin: 0; background: #0b0f14; color: #e8eef6; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; }
|
||||||
.wrap { height: 100%; display: grid; grid-template-rows: auto 1fr auto; padding: 14px; gap: 12px; }
|
.wrap { height: 100%; display: grid; grid-template-rows: auto 1fr auto auto; padding: 14px; gap: 12px; }
|
||||||
.top { display:flex; align-items:center; justify-content:space-between; gap: 10px; }
|
.top { display:flex; align-items:center; justify-content:space-between; gap: 10px; }
|
||||||
.badge { padding: 8px 12px; border-radius: 999px; background: #152033; font-weight: 700; }
|
.badge { padding: 8px 12px; border-radius: 999px; background: #152033; font-weight: 800; }
|
||||||
.status { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
.status { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
||||||
|
|
||||||
.panel { background:#101826; border-radius: 18px; padding: 12px; }
|
.panel { background:#101826; border-radius: 18px; padding: 12px; }
|
||||||
@@ -22,17 +22,22 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.stickBox { width: min(46vw, 340px); aspect-ratio: 1/1; }
|
.stickBox { width: min(46vw, 340px); aspect-ratio: 1/1; }
|
||||||
canvas { width: 100%; height: 100%; background:#0f1726; border-radius: 22px; }
|
canvas { width: 100%; height: 100%; background:#0f1726; border-radius: 22px; touch-action: none; }
|
||||||
|
|
||||||
.row { display:flex; gap: 12px; align-items:center; justify-content:space-between; flex-wrap: wrap; }
|
.row { display:flex; gap: 12px; align-items:center; justify-content:space-between; flex-wrap: wrap; }
|
||||||
.btn { padding: 14px 16px; border-radius: 14px; border: 0; background:#1e2b44; color:#fff; font-weight: 800; font-size: 16px; }
|
.btn { padding: 14px 16px; border-radius: 14px; border: 0; background:#1e2b44; color:#fff; font-weight: 900; font-size: 16px; }
|
||||||
.btn:active { transform: scale(0.98); }
|
.btn:active { transform: scale(0.98); }
|
||||||
.btnStop { background:#7a1f2a; }
|
.btnStop { background:#7a1f2a; }
|
||||||
|
.btnOn { outline: 3px solid rgba(59,130,246,0.7); }
|
||||||
|
|
||||||
input[type="range"]{ width: 220px; }
|
input[type="range"]{ width: 240px; }
|
||||||
.small { opacity: 0.85; font-size: 14px; }
|
.small { opacity: 0.86; font-size: 14px; }
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
||||||
.hint { margin-top: 8px; opacity: 0.85; font-size: 14px; text-align: center; }
|
.hint { margin-top: 8px; opacity: 0.85; font-size: 14px; text-align: center; }
|
||||||
|
|
||||||
|
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.kv { display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||||
|
.kv label { font-weight:800; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -56,46 +61,180 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel row">
|
<div class="panel row">
|
||||||
<div>
|
<div style="min-width: 320px;">
|
||||||
<div style="font-weight:900">Max Speed: <span id="maxv">180</span></div>
|
<div class="kv">
|
||||||
<input id="max" type="range" min="60" max="255" value="180">
|
<label>Max Speed:</label>
|
||||||
<div class="small mono" id="readout">T 0 | Turn 0 → L 0 | R 0</div>
|
<span class="mono"><span id="maxv">180</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:10px">
|
<input id="max" type="range" min="60" max="255" value="180">
|
||||||
|
|
||||||
|
<div class="grid2" style="margin-top:12px">
|
||||||
|
<div class="kv">
|
||||||
|
<label>Accel:</label>
|
||||||
|
<span class="mono"><span id="accv">2.2</span>/s</span>
|
||||||
|
</div>
|
||||||
|
<input id="acc" type="range" min="0.8" max="6.0" step="0.1" value="2.2">
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<label>Decel:</label>
|
||||||
|
<span class="mono"><span id="decv">3.6</span>/s</span>
|
||||||
|
</div>
|
||||||
|
<input id="dec" type="range" min="0.8" max="8.0" step="0.1" value="3.6">
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<label>Turn:</label>
|
||||||
|
<span class="mono"><span id="turnv">6.0</span>/s</span>
|
||||||
|
</div>
|
||||||
|
<input id="turn" type="range" min="1.0" max="12.0" step="0.1" value="6.0">
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<label>Expo:</label>
|
||||||
|
<span class="mono"><span id="expov">0.35</span></span>
|
||||||
|
</div>
|
||||||
|
<input id="expo" type="range" min="0.0" max="0.8" step="0.05" value="0.35">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
|
||||||
<button class="btn" id="btnConnect">Connect</button>
|
<button class="btn" id="btnConnect">Connect</button>
|
||||||
|
<button class="btn" id="btnSlow">SLOW</button>
|
||||||
<button class="btn btnStop" id="btnStop">STOP</button>
|
<button class="btn btnStop" id="btnStop">STOP</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel small mono" id="readout">
|
||||||
|
target: T 0.00 | Turn 0.00 || smooth: T 0.00 | Turn 0.00 || L 0 | R 0
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="small" style="text-align:center; opacity:0.8">
|
<div class="small" style="text-align:center; opacity:0.8">
|
||||||
Loslassen = STOP. Wenn Verbindung/Tab weg: STOP (Watchdog).
|
Loslassen = STOP. Tab/Verbindung weg = STOP. (Web sendet laufend, damit Arduino-Watchdog nicht stoppt.)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
|
// ===== UI refs =====
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
|
const readoutEl = document.getElementById('readout');
|
||||||
|
|
||||||
const maxEl = document.getElementById('max');
|
const maxEl = document.getElementById('max');
|
||||||
const maxvEl = document.getElementById('maxv');
|
const maxvEl = document.getElementById('maxv');
|
||||||
const readoutEl = document.getElementById('readout');
|
|
||||||
|
const accEl = document.getElementById('acc');
|
||||||
|
const accvEl = document.getElementById('accv');
|
||||||
|
|
||||||
|
const decEl = document.getElementById('dec');
|
||||||
|
const decvEl = document.getElementById('decv');
|
||||||
|
|
||||||
|
const turnRateEl = document.getElementById('turn');
|
||||||
|
const turnvEl = document.getElementById('turnv');
|
||||||
|
|
||||||
|
const expoEl = document.getElementById('expo');
|
||||||
|
const expovEl = document.getElementById('expov');
|
||||||
|
|
||||||
const btnConnect = document.getElementById('btnConnect');
|
const btnConnect = document.getElementById('btnConnect');
|
||||||
const btnStop = document.getElementById('btnStop');
|
const btnStop = document.getElementById('btnStop');
|
||||||
|
const btnSlow = document.getElementById('btnSlow');
|
||||||
|
|
||||||
const cL = document.getElementById('leftStick');
|
const cL = document.getElementById('leftStick');
|
||||||
const cR = document.getElementById('rightStick');
|
const cR = document.getElementById('rightStick');
|
||||||
const gL = cL.getContext('2d');
|
|
||||||
const gR = cR.getContext('2d');
|
|
||||||
|
|
||||||
|
// ===== WebSocket =====
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let connected = false;
|
let connected = false;
|
||||||
|
|
||||||
// Stick states (normalized)
|
function setStatus(txt, ok=false){
|
||||||
// Left stick: throttle in [-1..1] (up = +1)
|
statusEl.textContent = txt;
|
||||||
// Right stick: turn in [-1..1] (right = +1)
|
statusEl.style.background = ok ? "#16331d" : "#3a1a1a";
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(){
|
||||||
|
if (connected) return;
|
||||||
|
const proto = (location.protocol === "https:") ? "wss" : "ws";
|
||||||
|
const url = `${proto}://${location.host}/drive-ws`; // <— NGINX path
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = () => { connected = true; setStatus("online", true); };
|
||||||
|
ws.onclose = () => { connected = false; setStatus("offline", false); hardStop(); };
|
||||||
|
ws.onerror = () => { connected = false; setStatus("error", false); hardStop(); };
|
||||||
|
ws.onmessage = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
btnConnect.addEventListener('click', connect);
|
||||||
|
|
||||||
|
// ===== Control state =====
|
||||||
|
// Targets come directly from sticks
|
||||||
|
let throttleTarget = 0; // -1..+1 (up = +)
|
||||||
|
let turnTarget = 0; // -1..+1 (right = +)
|
||||||
|
|
||||||
|
// Smoothed (ramped) values
|
||||||
let throttle = 0;
|
let throttle = 0;
|
||||||
let turn = 0;
|
let turn = 0;
|
||||||
|
|
||||||
// For touch handling
|
// last motor outputs
|
||||||
|
let lastL = 0, lastR = 0;
|
||||||
|
|
||||||
|
// slow mode
|
||||||
|
let slowMode = false;
|
||||||
|
btnSlow.addEventListener('click', () => {
|
||||||
|
slowMode = !slowMode;
|
||||||
|
btnSlow.classList.toggle('btnOn', slowMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
|
||||||
|
|
||||||
|
// Expo curve: 0 = linear, higher = finer around center
|
||||||
|
// Returns in [-1..1]
|
||||||
|
function expo(v, e){
|
||||||
|
v = clamp(v, -1, 1);
|
||||||
|
e = clamp(e, 0, 0.95);
|
||||||
|
// cubic blend
|
||||||
|
return (1 - e) * v + e * v * v * v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Differential mix: throttle + turn -> left/right in [-1..1]
|
||||||
|
function mix(th, tr){
|
||||||
|
let l = th + tr;
|
||||||
|
let r = th - tr;
|
||||||
|
const m = Math.max(1, Math.abs(l), Math.abs(r));
|
||||||
|
return { l: l / m, r: r / m };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepTowards(cur, target, maxDelta){
|
||||||
|
const d = target - cur;
|
||||||
|
if (Math.abs(d) <= maxDelta) return target;
|
||||||
|
return cur + Math.sign(d) * maxDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send helper with simple rate limit (force bypasses)
|
||||||
|
let lastSendMs = 0;
|
||||||
|
function sendLR(l, r, force=false){
|
||||||
|
if (!connected) return;
|
||||||
|
const now = performance.now();
|
||||||
|
if (!force && (now - lastSendMs < 35)) return; // ~28Hz max
|
||||||
|
lastSendMs = now;
|
||||||
|
|
||||||
|
try { ws.send(JSON.stringify({ l, r })); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hardStop(){
|
||||||
|
throttleTarget = 0; turnTarget = 0;
|
||||||
|
throttle = 0; turn = 0;
|
||||||
|
lastL = 0; lastR = 0;
|
||||||
|
sendLR(0, 0, true);
|
||||||
|
leftStick.reset();
|
||||||
|
rightStick.reset();
|
||||||
|
renderReadout();
|
||||||
|
}
|
||||||
|
|
||||||
|
btnStop.addEventListener('click', hardStop);
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) hardStop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Stick widgets =====
|
||||||
function makeStick(canvas, mode /* 'throttle' or 'turn' */) {
|
function makeStick(canvas, mode /* 'throttle' or 'turn' */) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const cx = canvas.width / 2;
|
const cx = canvas.width / 2;
|
||||||
@@ -105,8 +244,6 @@
|
|||||||
let active = false;
|
let active = false;
|
||||||
let px = cx, py = cy;
|
let px = cx, py = cy;
|
||||||
|
|
||||||
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
|
|
||||||
|
|
||||||
function pointerPos(evt){
|
function pointerPos(evt){
|
||||||
const r = canvas.getBoundingClientRect();
|
const r = canvas.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
@@ -115,23 +252,61 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(nx, ny) {
|
function draw(){
|
||||||
// nx, ny in [-1..1] where ny is +down
|
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||||
|
|
||||||
|
// base circle
|
||||||
|
ctx.lineWidth = 10;
|
||||||
|
ctx.strokeStyle = "#24324d";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, Math.PI*2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// crosshair
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.strokeStyle = "#1c263b";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
|
||||||
|
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// mode line
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.strokeStyle = "#152033";
|
||||||
|
ctx.beginPath();
|
||||||
if (mode === 'throttle') {
|
if (mode === 'throttle') {
|
||||||
// Up should be +throttle, so invert ny
|
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
|
||||||
throttle = clamp(-ny, -1, 1);
|
|
||||||
} else {
|
} else {
|
||||||
turn = clamp(nx, -1, 1);
|
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// knob
|
||||||
|
ctx.fillStyle = "#3b82f6";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, canvas.width*0.065, 0, Math.PI*2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// inner dot
|
||||||
|
ctx.fillStyle = "#0b0f14";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, canvas.width*0.03, 0, Math.PI*2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(nx, ny){
|
||||||
|
// nx,ny in [-1..1], ny is +down
|
||||||
|
if (mode === 'throttle') {
|
||||||
|
throttleTarget = clamp(-ny, -1, 1); // up = +
|
||||||
|
} else {
|
||||||
|
turnTarget = clamp(nx, -1, 1); // right = +
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopSelf() {
|
function reset(){
|
||||||
active = false;
|
active = false;
|
||||||
px = cx; py = cy;
|
px = cx; py = cy;
|
||||||
if (mode === 'throttle') throttle = 0;
|
|
||||||
if (mode === 'turn') turn = 0;
|
|
||||||
draw();
|
draw();
|
||||||
updateAndSend(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDown(evt){
|
function onDown(evt){
|
||||||
@@ -144,9 +319,11 @@
|
|||||||
function onMove(evt){
|
function onMove(evt){
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
||||||
const p = pointerPos(evt);
|
const p = pointerPos(evt);
|
||||||
const dx = p.x - cx;
|
const dx = p.x - cx;
|
||||||
const dy = p.y - cy;
|
const dy = p.y - cy;
|
||||||
|
|
||||||
const dist = Math.hypot(dx, dy);
|
const dist = Math.hypot(dx, dy);
|
||||||
const k = dist > radius ? (radius / dist) : 1;
|
const k = dist > radius ? (radius / dist) : 1;
|
||||||
|
|
||||||
@@ -158,12 +335,16 @@
|
|||||||
|
|
||||||
apply(nx, ny);
|
apply(nx, ny);
|
||||||
draw();
|
draw();
|
||||||
updateAndSend(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUp(evt){
|
function onUp(evt){
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
stopSelf();
|
active = false;
|
||||||
|
px = cx; py = cy;
|
||||||
|
// Release = target back to 0 for that axis
|
||||||
|
if (mode === 'throttle') throttleTarget = 0;
|
||||||
|
if (mode === 'turn') turnTarget = 0;
|
||||||
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.addEventListener('pointerdown', onDown);
|
canvas.addEventListener('pointerdown', onDown);
|
||||||
@@ -172,142 +353,77 @@
|
|||||||
canvas.addEventListener('pointercancel', onUp);
|
canvas.addEventListener('pointercancel', onUp);
|
||||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||||
|
|
||||||
function drawBase() {
|
|
||||||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
|
||||||
|
|
||||||
// Outer circle
|
|
||||||
ctx.lineWidth = 10;
|
|
||||||
ctx.strokeStyle = "#24324d";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, radius, 0, Math.PI*2);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Crosshair
|
|
||||||
ctx.lineWidth = 4;
|
|
||||||
ctx.strokeStyle = "#1c263b";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
|
|
||||||
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Mode hint line
|
|
||||||
ctx.lineWidth = 6;
|
|
||||||
ctx.strokeStyle = "#152033";
|
|
||||||
ctx.beginPath();
|
|
||||||
if (mode === 'throttle') {
|
|
||||||
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
|
|
||||||
} else {
|
|
||||||
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawKnob() {
|
|
||||||
// Knob
|
|
||||||
ctx.fillStyle = "#3b82f6";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(px, py, canvas.width*0.065, 0, Math.PI*2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Inner dot
|
|
||||||
ctx.fillStyle = "#0b0f14";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(px, py, canvas.width*0.03, 0, Math.PI*2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw(){
|
|
||||||
drawBase();
|
|
||||||
drawKnob();
|
|
||||||
}
|
|
||||||
|
|
||||||
draw();
|
draw();
|
||||||
return { draw, stopSelf };
|
return { reset };
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(txt, ok=false){
|
const leftStick = makeStick(cL, 'throttle');
|
||||||
statusEl.textContent = txt;
|
const rightStick = makeStick(cR, 'turn');
|
||||||
statusEl.style.background = ok ? "#16331d" : "#3a1a1a";
|
|
||||||
|
// ===== UI sliders text =====
|
||||||
|
function refreshLabels(){
|
||||||
|
maxvEl.textContent = maxEl.value;
|
||||||
|
accvEl.textContent = accEl.value;
|
||||||
|
decvEl.textContent = decEl.value;
|
||||||
|
turnvEl.textContent = turnRateEl.value;
|
||||||
|
expovEl.textContent = expoEl.value;
|
||||||
|
}
|
||||||
|
[maxEl, accEl, decEl, turnRateEl, expoEl].forEach(el => el.addEventListener('input', refreshLabels));
|
||||||
|
refreshLabels();
|
||||||
|
|
||||||
|
// ===== Main control loop (RAMPING happens HERE) =====
|
||||||
|
function renderReadout(){
|
||||||
|
readoutEl.textContent =
|
||||||
|
`target: T ${throttleTarget.toFixed(2)} | Turn ${turnTarget.toFixed(2)} || ` +
|
||||||
|
`smooth: T ${throttle.toFixed(2)} | Turn ${turn.toFixed(2)} || ` +
|
||||||
|
`L ${lastL} | R ${lastR}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
|
let lastTick = performance.now();
|
||||||
|
|
||||||
// Mix throttle + turn to left/right [-1..1]
|
setInterval(() => {
|
||||||
function mix(throttle, turn) {
|
|
||||||
let l = throttle + turn;
|
|
||||||
let r = throttle - turn;
|
|
||||||
|
|
||||||
// normalize to keep within [-1..1]
|
|
||||||
const m = Math.max(1, Math.abs(l), Math.abs(r));
|
|
||||||
l /= m; r /= m;
|
|
||||||
return { l, r };
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSend = 0;
|
|
||||||
let lastL = 0, lastR = 0;
|
|
||||||
|
|
||||||
function sendLR(l, r, force=false){
|
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (!connected) return;
|
const dt = Math.max(0.001, (now - lastTick) / 1000);
|
||||||
if (!force && (now - lastSend < 35)) return; // ~28Hz
|
lastTick = now;
|
||||||
lastSend = now;
|
|
||||||
|
|
||||||
try { ws.send(JSON.stringify({l, r})); } catch(e) {}
|
// rates from UI
|
||||||
}
|
const ACCEL_PER_SEC = parseFloat(accEl.value);
|
||||||
|
const DECEL_PER_SEC = parseFloat(decEl.value);
|
||||||
|
const TURN_PER_SEC = parseFloat(turnRateEl.value);
|
||||||
|
const EXPO = parseFloat(expoEl.value);
|
||||||
|
|
||||||
function updateAndSend(force) {
|
// ramp throttle
|
||||||
const maxSpeed = parseInt(maxEl.value, 10);
|
const rateT = (Math.abs(throttleTarget) > Math.abs(throttle)) ? ACCEL_PER_SEC : DECEL_PER_SEC;
|
||||||
const m = mix(throttle, turn);
|
throttle = stepTowards(throttle, throttleTarget, rateT * dt);
|
||||||
|
|
||||||
const L = Math.round(clamp(m.l, -1, 1) * maxSpeed);
|
// ramp turn
|
||||||
const R = Math.round(clamp(m.r, -1, 1) * maxSpeed);
|
turn = stepTowards(turn, turnTarget, TURN_PER_SEC * dt);
|
||||||
|
|
||||||
|
// apply expo AFTER ramp (feels nicer)
|
||||||
|
const th = expo(throttle, EXPO);
|
||||||
|
const tr = expo(turn, EXPO);
|
||||||
|
|
||||||
|
// mix
|
||||||
|
let { l, r } = mix(th, tr);
|
||||||
|
|
||||||
|
// scale
|
||||||
|
let maxSpeed = parseInt(maxEl.value, 10);
|
||||||
|
if (slowMode) maxSpeed = Math.round(maxSpeed * 0.45);
|
||||||
|
|
||||||
|
const L = Math.round(clamp(l, -1, 1) * maxSpeed);
|
||||||
|
const R = Math.round(clamp(r, -1, 1) * maxSpeed);
|
||||||
|
|
||||||
lastL = L; lastR = R;
|
lastL = L; lastR = R;
|
||||||
readoutEl.textContent = `T ${throttle.toFixed(2)} | Turn ${turn.toFixed(2)} → L ${L} | R ${R}`;
|
renderReadout();
|
||||||
|
|
||||||
sendLR(L, R, force);
|
// always send with a gentle cap (force keeps Arduino alive)
|
||||||
}
|
// This is both "control output" and heartbeat.
|
||||||
|
sendLR(L, R, true);
|
||||||
|
}, 20); // 50 Hz smoothing + sending
|
||||||
|
|
||||||
function hardStop() {
|
// ===== Start =====
|
||||||
throttle = 0; turn = 0;
|
|
||||||
updateAndSend(true);
|
|
||||||
left.stopSelf();
|
|
||||||
right.stopSelf();
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect(){
|
|
||||||
if (connected) return;
|
|
||||||
const proto = (location.protocol === "https:") ? "wss" : "ws";
|
|
||||||
const url = `${proto}://${location.host}/drive-ws`;
|
|
||||||
ws = new WebSocket(url);
|
|
||||||
|
|
||||||
ws.onopen = () => { connected = true; setStatus("online", true); updateAndSend(true); };
|
|
||||||
ws.onclose = () => { connected = false; setStatus("offline", false); hardStop(); };
|
|
||||||
ws.onerror = () => { connected = false; setStatus("error", false); hardStop(); };
|
|
||||||
ws.onmessage = () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
btnConnect.addEventListener('click', connect);
|
|
||||||
btnStop.addEventListener('click', hardStop);
|
|
||||||
|
|
||||||
maxEl.addEventListener('input', () => {
|
|
||||||
maxvEl.textContent = maxEl.value;
|
|
||||||
updateAndSend(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create sticks
|
|
||||||
const left = makeStick(cL, 'throttle');
|
|
||||||
const right = makeStick(cR, 'turn');
|
|
||||||
|
|
||||||
// Auto-connect
|
|
||||||
connect();
|
connect();
|
||||||
setStatus("connecting...", false);
|
setStatus("connecting...", false);
|
||||||
|
|
||||||
// Safety: stop when page hidden
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
|
||||||
if (document.hidden) hardStop();
|
|
||||||
});
|
|
||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
|
# redirect service to tethering IP for 'webview kiosk' -> mobile phone to face
|
||||||
|
location /redirect {
|
||||||
|
proxy_pass http://127.0.0.1:8000/redirect;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# captive portal to face
|
||||||
|
include /opt/helva-robot/drive/etc/nginx/snippets/captive-portal.conf;
|
||||||
|
|
||||||
|
# gives access to drive control
|
||||||
|
include /opt/helva-robot/drive/etc/nginx/snippets/drive.conf;
|
||||||
|
|
||||||
root /opt/helva-robot/face/var/www/html;
|
root /opt/helva-robot/face/var/www/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
@@ -31,6 +45,5 @@ server {
|
|||||||
add_header Cache-Control no-cache;
|
add_header Cache-Control no-cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
include /opt/helva-robot/drive/etc/nginx/snippets/drive.conf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Redirect Tethering IP Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=/opt/face
|
||||||
|
ExecStart=/opt/face/venv/bin/python -m uvicorn redirect_tethering_ip:app --host 127.0.0.1 --port 8000 --app-dir /opt/face
|
||||||
|
Restart=always
|
||||||
|
RestartSec=1
|
||||||
|
|
||||||
|
# Tipp: eigener User ist sauber, aber www-data geht auch.
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
+80
-106
@@ -1,151 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Set
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import StreamingResponse, JSONResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
clients: set[asyncio.Queue[str]] = set()
|
STATE: Dict[str, Any] = {
|
||||||
|
|
||||||
# Global state sent to clients
|
|
||||||
state: Dict[str, Any] = {
|
|
||||||
"emotion": "neutral",
|
"emotion": "neutral",
|
||||||
"intensity": 0.7, # 0..1
|
"intensity": 0.85,
|
||||||
"look": None, # {"x": -1..1, "y": -1..1} or None
|
"look": {"x": 0.0, "y": 0.0},
|
||||||
"mouth": {"open": False, "amount": 0.0, "duration_ms": 0},
|
"speaking": False,
|
||||||
"talk": {"enabled": False, "rate_hz": 3.2, "amount": 0.9, "jitter": 0.25},
|
"eyesMoving": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
def clamp(v: float, lo: float, hi: float) -> float:
|
CLIENTS: Set[asyncio.Queue[str]] = set()
|
||||||
return max(lo, min(hi, v))
|
LOCK = asyncio.Lock()
|
||||||
|
|
||||||
def sse(event: str, data: str) -> str:
|
|
||||||
return f"event: {event}\ndata: {data}\n\n"
|
|
||||||
|
|
||||||
def normalize_patch(patch: Dict[str, Any]) -> Dict[str, Any]:
|
def clamp(v: Any, lo: float, hi: float) -> float:
|
||||||
out: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
if "emotion" in patch:
|
|
||||||
out["emotion"] = str(patch["emotion"])
|
|
||||||
|
|
||||||
if "intensity" in patch:
|
|
||||||
try:
|
try:
|
||||||
out["intensity"] = clamp(float(patch["intensity"]), 0.0, 1.0)
|
x = float(v)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return lo
|
||||||
|
return max(lo, min(hi, x))
|
||||||
|
|
||||||
if "look" in patch:
|
|
||||||
look = patch["look"]
|
|
||||||
if look is None:
|
|
||||||
out["look"] = None
|
|
||||||
elif isinstance(look, dict):
|
|
||||||
try:
|
|
||||||
x = clamp(float(look.get("x", 0.0)), -1.0, 1.0)
|
|
||||||
y = clamp(float(look.get("y", 0.0)), -1.0, 1.0)
|
|
||||||
out["look"] = {"x": x, "y": y}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if "mouth" in patch and isinstance(patch["mouth"], dict):
|
def merge_state(payload: Dict[str, Any]) -> None:
|
||||||
m = patch["mouth"]
|
if isinstance(payload.get("emotion"), str):
|
||||||
try:
|
STATE["emotion"] = payload["emotion"]
|
||||||
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 "intensity" in payload:
|
||||||
if "blink" in patch:
|
STATE["intensity"] = clamp(payload["intensity"], 0.0, 1.0)
|
||||||
out["blink"] = bool(patch["blink"])
|
|
||||||
|
|
||||||
if "talk" in patch:
|
if isinstance(payload.get("look"), dict):
|
||||||
t = patch["talk"]
|
lx = clamp(payload["look"].get("x", 0.0), -1.0, 1.0)
|
||||||
if isinstance(t, dict):
|
ly = clamp(payload["look"].get("y", 0.0), -1.0, 1.0)
|
||||||
try:
|
STATE["look"] = {"x": lx, "y": ly}
|
||||||
enabled = bool(t.get("enabled", False))
|
|
||||||
rate_hz = float(t.get("rate_hz", 3.2))
|
|
||||||
amount = float(t.get("amount", 0.9))
|
|
||||||
jitter = float(t.get("jitter", 0.25))
|
|
||||||
|
|
||||||
rate_hz = clamp(rate_hz, 0.5, 10.0)
|
if isinstance(payload.get("speaking"), bool):
|
||||||
amount = clamp(amount, 0.0, 1.0)
|
STATE["speaking"] = payload["speaking"]
|
||||||
jitter = clamp(jitter, 0.0, 1.0)
|
|
||||||
|
|
||||||
out["talk"] = {"enabled": enabled, "rate_hz": rate_hz, "amount": amount, "jitter": jitter}
|
if isinstance(payload.get("eyesMoving"), bool):
|
||||||
except Exception:
|
STATE["eyesMoving"] = payload["eyesMoving"]
|
||||||
pass
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
async def broadcast(payload: Dict[str, Any]) -> None:
|
async def broadcast() -> None:
|
||||||
msg = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
msg = json.dumps(STATE, separators=(",", ":"))
|
||||||
dead = []
|
dead: list[asyncio.Queue[str]] = []
|
||||||
for q in clients:
|
|
||||||
|
async with LOCK:
|
||||||
|
for q in CLIENTS:
|
||||||
try:
|
try:
|
||||||
q.put_nowait(msg)
|
q.put_nowait(msg)
|
||||||
except Exception:
|
except Exception:
|
||||||
dead.append(q)
|
dead.append(q)
|
||||||
for q in dead:
|
for q in dead:
|
||||||
clients.discard(q)
|
CLIENTS.discard(q)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/state")
|
||||||
|
async def get_state():
|
||||||
|
return JSONResponse(STATE)
|
||||||
|
|
||||||
|
@app.get("/api/state")
|
||||||
|
async def get_state():
|
||||||
|
return JSONResponse(STATE)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/state")
|
||||||
|
async def set_state(payload: Dict[str, Any]):
|
||||||
|
merge_state(payload)
|
||||||
|
await broadcast()
|
||||||
|
return JSONResponse({"ok": True, "state": STATE})
|
||||||
|
|
||||||
|
@app.post("/api/state")
|
||||||
|
async def set_state(payload: Dict[str, Any]):
|
||||||
|
merge_state(payload)
|
||||||
|
await broadcast()
|
||||||
|
return JSONResponse({"ok": True, "state": STATE})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/events")
|
@app.get("/events")
|
||||||
async def events(request: Request):
|
async def events(request: Request):
|
||||||
q: asyncio.Queue[str] = asyncio.Queue()
|
"""
|
||||||
clients.add(q)
|
SSE stream for browser:
|
||||||
|
const es = new EventSource("/events");
|
||||||
|
nginx should proxy /events -> http://127.0.0.1:8001/events
|
||||||
|
"""
|
||||||
|
q: asyncio.Queue[str] = asyncio.Queue(maxsize=50)
|
||||||
|
|
||||||
|
async with LOCK:
|
||||||
|
CLIENTS.add(q)
|
||||||
|
|
||||||
async def gen():
|
async def gen():
|
||||||
try:
|
try:
|
||||||
# Send current state immediately on connect
|
# initial state immediately
|
||||||
yield sse("state", json.dumps(state, separators=(",", ":"), ensure_ascii=False))
|
initial = json.dumps(STATE, separators=(",", ":"))
|
||||||
|
yield f"event: state\ndata: {initial}\n\n"
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
break
|
break
|
||||||
msg = await q.get()
|
|
||||||
yield sse("state", msg)
|
try:
|
||||||
|
msg = await asyncio.wait_for(q.get(), timeout=15.0)
|
||||||
|
yield f"event: state\ndata: {msg}\n\n"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# keepalive
|
||||||
|
yield ": keepalive\n\n"
|
||||||
finally:
|
finally:
|
||||||
clients.discard(q)
|
async with LOCK:
|
||||||
|
CLIENTS.discard(q)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no", # important behind nginx
|
||||||
}
|
}
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
|
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
|
||||||
|
|
||||||
@app.post("/api/state")
|
|
||||||
async def set_state(patch: Dict[str, Any]):
|
|
||||||
global state
|
|
||||||
normalized = normalize_patch(patch)
|
|
||||||
|
|
||||||
# Merge persistent fields
|
|
||||||
for k in ("emotion", "intensity", "look", "mouth", "talk"):
|
|
||||||
if k in normalized:
|
|
||||||
state[k] = normalized[k]
|
|
||||||
|
|
||||||
# Broadcast merged state + one-shot flags if any
|
|
||||||
payload = dict(state)
|
|
||||||
if "blink" in normalized:
|
|
||||||
payload["blink"] = normalized["blink"]
|
|
||||||
|
|
||||||
await broadcast(payload)
|
|
||||||
return JSONResponse({"ok": True, "state": state})
|
|
||||||
|
|
||||||
# Compatibility endpoint (optional): keeps your old curl calls working
|
|
||||||
@app.post("/api/emotion/{name}")
|
|
||||||
async def set_emotion(name: str):
|
|
||||||
global state
|
|
||||||
state["emotion"] = name
|
|
||||||
payload = dict(state)
|
|
||||||
await broadcast(payload)
|
|
||||||
return JSONResponse({"ok": True, "state": state})
|
|
||||||
|
|
||||||
@app.get("/api/state")
|
|
||||||
async def get_state():
|
|
||||||
return {"state": state}
|
|
||||||
|
|
||||||
|
|||||||
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">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||||
<title>Robot Face Control</title>
|
<title>Face Control</title>
|
||||||
<style>
|
<link rel="stylesheet" href="/control/control.css" />
|
||||||
:root { color-scheme: dark; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
||||||
background: #0b0f14;
|
|
||||||
color: #d7e3f2;
|
|
||||||
}
|
|
||||||
.wrap {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 16px;
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: rgba(18, 25, 37, 0.85);
|
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 14px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
|
|
||||||
}
|
|
||||||
h1 { font-size: 18px; margin: 0 0 10px 0; opacity: 0.95; }
|
|
||||||
h2 { font-size: 14px; margin: 0 0 10px 0; opacity: 0.85; }
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background: #121925;
|
|
||||||
color: #d7e3f2;
|
|
||||||
border: 1px solid rgba(255,255,255,0.10);
|
|
||||||
padding: 12px 10px;
|
|
||||||
border-radius: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: transform 120ms ease, border-color 120ms ease;
|
|
||||||
user-select: none;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
button:active { transform: scale(0.98); }
|
|
||||||
button.primary { border-color: rgba(0,255,180,0.35); }
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.row3 {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
input[type="range"] {
|
|
||||||
width: 100%;
|
|
||||||
accent-color: #6ee7ff;
|
|
||||||
}
|
|
||||||
.value {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
opacity: 0.85;
|
|
||||||
min-width: 72px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.toggle {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.10);
|
|
||||||
background: #121925;
|
|
||||||
}
|
|
||||||
.dot {
|
|
||||||
width: 10px; height: 10px; border-radius: 999px;
|
|
||||||
background: rgba(255,255,255,0.35);
|
|
||||||
}
|
|
||||||
.dot.ok { background: rgba(0,255,180,0.75); }
|
|
||||||
.dot.bad { background: rgba(255,70,70,0.75); }
|
|
||||||
.small { font-size: 12px; opacity: 0.75; }
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<main class="page">
|
||||||
<div class="card">
|
<header class="top">
|
||||||
<h1>Robot Face Control</h1>
|
<h1>Face Control</h1>
|
||||||
<div class="toggle">
|
<div class="row">
|
||||||
<span class="pill"><span id="connDot" class="dot"></span><span id="connTxt">offline</span></span>
|
<span class="pill">Status: <strong id="conn">?</strong></span>
|
||||||
<div class="small" id="stateTxt">–</div>
|
<span class="pill">Aktuell: <strong id="current">neutral</strong></span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="card">
|
<section class="card">
|
||||||
<h2>Emotion</h2>
|
<h2>Emotion</h2>
|
||||||
<div class="grid">
|
<div class="grid" id="emotionGrid"></div>
|
||||||
<button data-emotion="neutral" class="primary">neutral</button>
|
</section>
|
||||||
<button data-emotion="happy">happy</button>
|
|
||||||
<button data-emotion="sad">sad</button>
|
|
||||||
<button data-emotion="angry">angry</button>
|
|
||||||
<button data-emotion="surprised">surprised</button>
|
|
||||||
<button data-emotion="sleepy">sleepy</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Parameter</h2>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<label class="toggle">
|
||||||
|
<input id="speaking" type="checkbox" />
|
||||||
|
<span>🗣️ Sprechen</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="toggle">
|
||||||
|
<input id="eyesMoving" type="checkbox" checked />
|
||||||
|
<span>👀 Augen bewegen</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="slider">
|
||||||
|
<span>Intensity</span>
|
||||||
|
<input id="intensity" type="range" min="0" max="1" step="0.01" value="0.85" />
|
||||||
|
<output id="intensityVal">0.85</output>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Look</h2>
|
||||||
|
<p class="muted">Zieh im Feld: links/rechts/oben/unten. Doppeltipp = zentrieren.</p>
|
||||||
|
<div class="lookpad" id="lookpad">
|
||||||
|
<div class="cross"></div>
|
||||||
|
<div class="dot" id="lookdot"></div>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="intensity">Intensität</label>
|
<button class="btn" id="center">Center</button>
|
||||||
<div class="value" id="intensityVal">0.70</div>
|
<button class="btn" id="stareOff">Stare OFF</button>
|
||||||
</div>
|
|
||||||
<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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="card">
|
<footer class="foot">
|
||||||
<h2>Blick (fix)</h2>
|
<a class="link" href="/">← Face</a>
|
||||||
<div class="row">
|
<a class="link" href="/drive">Drive →</a>
|
||||||
<label for="lookX">X (links ↔ rechts)</label>
|
</footer>
|
||||||
<div class="value" id="lookXVal">0.00</div>
|
</main>
|
||||||
</div>
|
|
||||||
<input id="lookX" type="range" min="-1" max="1" step="0.01" value="0" />
|
|
||||||
|
|
||||||
<div class="row">
|
<script src="/control/control.js"></script>
|
||||||
<label for="lookY">Y (oben ↕ unten)</label>
|
|
||||||
<div class="value" id="lookYVal">0.00</div>
|
|
||||||
</div>
|
|
||||||
<input id="lookY" type="range" min="-1" max="1" step="0.01" value="0" />
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<button id="applyLookBtn">Blick anwenden</button>
|
|
||||||
<button id="centerLookBtn">Zentrieren</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Sprechen</h2>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<button id="speakBtn">Speak (700ms)</button>
|
|
||||||
<button id="talkToggleBtn" class="primary">Talk: OFF</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<label for="talkRate">Rate (Hz)</label>
|
|
||||||
<div class="value" id="talkRateVal">3.20</div>
|
|
||||||
</div>
|
|
||||||
<input id="talkRate" type="range" min="0.5" max="10" step="0.1" value="3.2" />
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<label for="talkAmount">Mund-Öffnung</label>
|
|
||||||
<div class="value" id="talkAmountVal">0.90</div>
|
|
||||||
</div>
|
|
||||||
<input id="talkAmount" type="range" min="0" max="1" step="0.01" value="0.9" />
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<label for="talkJitter">Jitter</label>
|
|
||||||
<div class="value" id="talkJitterVal">0.25</div>
|
|
||||||
</div>
|
|
||||||
<input id="talkJitter" type="range" min="0" max="1" step="0.01" value="0.25" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card small">
|
|
||||||
Tipp: URL am Handy öffnen: <b>/control/</b> (z. B. http://raspy/control/)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
const connDot = $("connDot");
|
|
||||||
const connTxt = $("connTxt");
|
|
||||||
const stateTxt = $("stateTxt");
|
|
||||||
|
|
||||||
const intensity = $("intensity");
|
|
||||||
const intensityVal = $("intensityVal");
|
|
||||||
|
|
||||||
const lookX = $("lookX"), lookXVal = $("lookXVal");
|
|
||||||
const lookY = $("lookY"), lookYVal = $("lookYVal");
|
|
||||||
|
|
||||||
const talkToggleBtn = $("talkToggleBtn");
|
|
||||||
const talkRate = $("talkRate"), talkRateVal = $("talkRateVal");
|
|
||||||
const talkAmount = $("talkAmount"), talkAmountVal = $("talkAmountVal");
|
|
||||||
const talkJitter = $("talkJitter"), talkJitterVal = $("talkJitterVal");
|
|
||||||
|
|
||||||
let talkEnabled = false;
|
|
||||||
let wander = true;
|
|
||||||
let lastState = null;
|
|
||||||
|
|
||||||
function fmt(n, d=2){ return Number(n).toFixed(d); }
|
|
||||||
|
|
||||||
async function postState(patch) {
|
|
||||||
const res = await fetch("/api/state", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {"Content-Type":"application/json"},
|
|
||||||
body: JSON.stringify(patch),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("POST failed: " + res.status);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setConn(ok) {
|
|
||||||
connDot.classList.toggle("ok", ok);
|
|
||||||
connDot.classList.toggle("bad", !ok);
|
|
||||||
connTxt.textContent = ok ? "online" : "offline";
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStateText(s) {
|
|
||||||
if (!s) { stateTxt.textContent = "–"; return; }
|
|
||||||
const e = s.emotion ?? "?";
|
|
||||||
const i = s.intensity ?? "?";
|
|
||||||
const t = s.talk?.enabled ? "talk" : "silent";
|
|
||||||
const lk = (s.look === null || s.look === undefined) ? "wander" : `look(${fmt(s.look.x)},${fmt(s.look.y)})`;
|
|
||||||
stateTxt.textContent = `${e} | intensity ${fmt(i)} | ${t} | ${lk}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emotion buttons
|
|
||||||
document.querySelectorAll("button[data-emotion]").forEach(btn => {
|
|
||||||
btn.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await postState({ emotion: btn.dataset.emotion });
|
|
||||||
} catch(e) { console.error(e); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Intensity slider
|
|
||||||
intensity.addEventListener("input", () => intensityVal.textContent = fmt(intensity.value));
|
|
||||||
intensity.addEventListener("change", async () => {
|
|
||||||
try { await postState({ intensity: Number(intensity.value) }); } catch(e){ console.error(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Blink + Wander toggle
|
|
||||||
$("blinkBtn").addEventListener("click", async () => {
|
|
||||||
try { await postState({ blink: true }); } catch(e){ console.error(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
$("wanderBtn").addEventListener("click", async (ev) => {
|
|
||||||
wander = !wander;
|
|
||||||
ev.target.textContent = "Blick: " + (wander ? "wander" : "fix");
|
|
||||||
ev.target.classList.toggle("primary", wander);
|
|
||||||
try {
|
|
||||||
await postState({ look: wander ? null : { x: Number(lookX.value), y: Number(lookY.value) } });
|
|
||||||
} catch(e){ console.error(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Look sliders
|
|
||||||
function updateLookLabels(){
|
|
||||||
lookXVal.textContent = fmt(lookX.value);
|
|
||||||
lookYVal.textContent = fmt(lookY.value);
|
|
||||||
}
|
|
||||||
lookX.addEventListener("input", updateLookLabels);
|
|
||||||
lookY.addEventListener("input", updateLookLabels);
|
|
||||||
updateLookLabels();
|
|
||||||
|
|
||||||
$("applyLookBtn").addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
wander = false;
|
|
||||||
$("wanderBtn").textContent = "Blick: fix";
|
|
||||||
$("wanderBtn").classList.remove("primary");
|
|
||||||
await postState({ look: { x: Number(lookX.value), y: Number(lookY.value) } });
|
|
||||||
} catch(e){ console.error(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
$("centerLookBtn").addEventListener("click", async () => {
|
|
||||||
lookX.value = 0; lookY.value = 0; updateLookLabels();
|
|
||||||
try {
|
|
||||||
wander = false;
|
|
||||||
$("wanderBtn").textContent = "Blick: fix";
|
|
||||||
$("wanderBtn").classList.remove("primary");
|
|
||||||
await postState({ look: { x: 0, y: 0 } });
|
|
||||||
} catch(e){ console.error(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Speak (one-shot)
|
|
||||||
$("speakBtn").addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await postState({ mouth: { open: true, amount: Number(talkAmount.value), duration_ms: 700 } });
|
|
||||||
} catch(e){ console.error(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Talk controls
|
|
||||||
function updateTalkLabels(){
|
|
||||||
talkRateVal.textContent = fmt(talkRate.value);
|
|
||||||
talkAmountVal.textContent = fmt(talkAmount.value);
|
|
||||||
talkJitterVal.textContent = fmt(talkJitter.value);
|
|
||||||
}
|
|
||||||
talkRate.addEventListener("input", updateTalkLabels);
|
|
||||||
talkAmount.addEventListener("input", updateTalkLabels);
|
|
||||||
talkJitter.addEventListener("input", updateTalkLabels);
|
|
||||||
updateTalkLabels();
|
|
||||||
|
|
||||||
async function pushTalk() {
|
|
||||||
try {
|
|
||||||
await postState({
|
|
||||||
talk: {
|
|
||||||
enabled: talkEnabled,
|
|
||||||
rate_hz: Number(talkRate.value),
|
|
||||||
amount: Number(talkAmount.value),
|
|
||||||
jitter: Number(talkJitter.value),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch(e){ console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
talkToggleBtn.addEventListener("click", async () => {
|
|
||||||
talkEnabled = !talkEnabled;
|
|
||||||
talkToggleBtn.textContent = "Talk: " + (talkEnabled ? "ON" : "OFF");
|
|
||||||
talkToggleBtn.classList.toggle("primary", !talkEnabled); // OFF = primary (wie vorher)
|
|
||||||
await pushTalk();
|
|
||||||
});
|
|
||||||
|
|
||||||
[talkRate, talkAmount, talkJitter].forEach(el => {
|
|
||||||
el.addEventListener("change", async () => {
|
|
||||||
if (talkEnabled) await pushTalk();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Live state via SSE (optional, aber nice)
|
|
||||||
function connectSSE() {
|
|
||||||
try {
|
|
||||||
const es = new EventSource("/events");
|
|
||||||
es.addEventListener("state", (e) => {
|
|
||||||
setConn(true);
|
|
||||||
try {
|
|
||||||
lastState = JSON.parse(e.data);
|
|
||||||
updateStateText(lastState);
|
|
||||||
|
|
||||||
// sync some UI hints
|
|
||||||
if (typeof lastState?.intensity === "number") {
|
|
||||||
intensity.value = lastState.intensity;
|
|
||||||
intensityVal.textContent = fmt(intensity.value);
|
|
||||||
}
|
|
||||||
if (lastState?.look === null) {
|
|
||||||
wander = true;
|
|
||||||
$("wanderBtn").textContent = "Blick: wander";
|
|
||||||
$("wanderBtn").classList.add("primary");
|
|
||||||
} else if (lastState?.look) {
|
|
||||||
wander = false;
|
|
||||||
$("wanderBtn").textContent = "Blick: fix";
|
|
||||||
$("wanderBtn").classList.remove("primary");
|
|
||||||
lookX.value = lastState.look.x ?? 0;
|
|
||||||
lookY.value = lastState.look.y ?? 0;
|
|
||||||
updateLookLabels();
|
|
||||||
}
|
|
||||||
if (lastState?.talk) {
|
|
||||||
talkEnabled = !!lastState.talk.enabled;
|
|
||||||
talkToggleBtn.textContent = "Talk: " + (talkEnabled ? "ON" : "OFF");
|
|
||||||
talkToggleBtn.classList.toggle("primary", !talkEnabled);
|
|
||||||
if (typeof lastState.talk.rate_hz === "number") talkRate.value = lastState.talk.rate_hz;
|
|
||||||
if (typeof lastState.talk.amount === "number") talkAmount.value = lastState.talk.amount;
|
|
||||||
if (typeof lastState.talk.jitter === "number") talkJitter.value = lastState.talk.jitter;
|
|
||||||
updateTalkLabels();
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
es.onerror = () => { setConn(false); es.close(); setTimeout(connectSSE, 1200); };
|
|
||||||
} catch {
|
|
||||||
setConn(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connectSSE();
|
|
||||||
|
|
||||||
// Initial ping
|
|
||||||
fetch("/api/state").then(r => r.json()).then(j => {
|
|
||||||
setConn(true);
|
|
||||||
lastState = j.state;
|
|
||||||
updateStateText(lastState);
|
|
||||||
}).catch(() => setConn(false));
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||||
<title>Robot Face</title>
|
<title>Face</title>
|
||||||
<link rel="stylesheet" href="/style.css?v=2" />
|
<link rel="stylesheet" href="/face.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="emotion-neutral">
|
<body>
|
||||||
<div id="face" aria-label="Robot face">
|
<div class="stage" id="stage">
|
||||||
<div class="eyes">
|
<svg class="face" viewBox="0 0 1000 600" role="img" aria-label="face">
|
||||||
<div class="eye"></div>
|
<defs>
|
||||||
<div class="eye"></div>
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="4" result="b"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="b"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g id="faceGroup" filter="url(#glow)">
|
||||||
|
<g id="eyesGroup">
|
||||||
|
<path id="eyeL" d="" />
|
||||||
|
<path id="eyeR" d="" />
|
||||||
|
</g>
|
||||||
|
<path id="mouth" d="" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Touch-only links (NO params) -->
|
||||||
|
<div class="overlay" id="overlay" aria-hidden="true">
|
||||||
|
<a class="btn" href="/drive">🚗 Drive</a>
|
||||||
|
<a class="btn" href="/control">🎛️ Control</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mouth">
|
<script src="/face.js"></script>
|
||||||
<div class="mouth-shape"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="label" id="label">neutral</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/app.js?v=2"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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