feat(ui): social-bot web dashboard (issue #107) #112

Merged
sl-jetson merged 2 commits from sl-webui/issue-107-dashboard into main 2026-03-02 08:41:19 -05:00
23 changed files with 4846 additions and 4 deletions

35
include/battery.h Normal file
View File

@ -0,0 +1,35 @@
#ifndef BATTERY_H
#define BATTERY_H
/*
* battery.h Vbat ADC reading for CRSF telemetry (Issue #103)
*
* Hardware: ADC3 channel IN11 on PC1 (ADC_BATT 1, Mamba F722).
* Voltage divider: 10 / 1 11:1 ratio.
* Resolution: 12-bit (04095), Vref = 3.3 V.
*
* Filtered output in millivolts. Reading is averaged over
* BATTERY_SAMPLES conversions (software oversampling) to reduce noise.
*/
#include <stdint.h>
/* Initialise ADC3 for single-channel Vbat reading on PC1. */
void battery_init(void);
/*
* battery_read_mv() blocking single-shot read; returns Vbat in mV.
* Takes ~1 µs (12-bit conversion at 36 MHz APB2 / 8 prescaler = 4.5 MHz ADC clk).
* Returns 0 if ADC not initialised or conversion times out.
*/
uint32_t battery_read_mv(void);
/*
* battery_estimate_pct() coarse SoC estimate from Vbat (mV).
* Works for 3S LiPo (10.512.6 V) and 4S (14.016.8 V).
* Detection is automatic based on voltage.
* Returns 0100, or 255 if voltage is out of range.
*/
uint8_t battery_estimate_pct(uint32_t voltage_mv);
#endif /* BATTERY_H */

View File

@ -127,10 +127,20 @@
// Debug: UART5 (PC12=TX, PD2=RX)
// --- CRSF / ExpressLRS ---
// CH1[0]=steer CH2[1]=lean(future) CH5[4]=arm CH6[5]=mode
// CH1[0]=steer CH2[1]=throttle CH5[4]=arm CH6[5]=mode
#define CRSF_ARM_THRESHOLD 1750 /* CH5 raw value; > threshold = armed */
#define CRSF_STEER_MAX 400 /* CH1 range: -400..+400 motor counts */
#define CRSF_FAILSAFE_MS 300 /* Disarm after this ms without a frame */
#define CRSF_FAILSAFE_MS 500 /* Disarm after this ms without a frame (Issue #103) */
// --- Battery ADC (ADC3, PC1 = ADC123_IN11) ---
/* Mamba F722: 10kΩ + 1kΩ voltage divider → 11:1 ratio */
#define VBAT_SCALE_NUM 11 /* Numerator of divider ratio */
#define VBAT_AREF_MV 3300 /* ADC reference in mV */
#define VBAT_ADC_BITS 12 /* 12-bit ADC → 4096 counts */
/* Filtered Vbat in mV: (raw * 3300 * 11) / 4096, updated at 10Hz */
// --- CRSF Telemetry TX (uplink: FC → ELRS module → pilot handset) ---
#define CRSF_TELEMETRY_HZ 1 /* Telemetry TX rate (Hz) */
// --- PID Tuning ---
#define PID_KP 35.0f
@ -155,8 +165,8 @@
// --- RC / Mode Manager ---
/* CRSF channel indices (0-based; CRSF range 172-1811, center 992) */
#define CRSF_CH_SPEED 2 /* CH3 — left stick vertical (fwd/back) */
#define CRSF_CH_STEER 3 /* CH4 — left stick horizontal (yaw) */
#define CRSF_CH_STEER 0 /* CH1 — right stick horizontal (steer) */
#define CRSF_CH_SPEED 1 /* CH2 — right stick vertical (throttle) */
#define CRSF_CH_ARM 4 /* CH5 — arm switch (2-pos) */
#define CRSF_CH_MODE 5 /* CH6 — mode switch (3-pos) */
/* Deadband around CRSF center (992) in raw counts (~2% of range) */

View File

@ -40,6 +40,30 @@ void crsf_parse_byte(uint8_t byte);
*/
int16_t crsf_to_range(uint16_t val, int16_t min, int16_t max);
/*
* crsf_send_battery() transmit CRSF battery-sensor telemetry frame (type 0x08)
* back to the ELRS TX module over UART4 TX. Call at CRSF_TELEMETRY_HZ (1 Hz).
*
* voltage_mv : battery voltage in millivolts (e.g. 12600 for 3S full)
* current_ma : current draw in milliamps (0 if no sensor)
* remaining_pct: state-of-charge 0100 % (255 = unknown)
*
* Frame: [0xC8][12][0x08][v16_hi][v16_lo][c16_hi][c16_lo][cap24×3][rem][CRC]
* voltage unit: 100 mV (12600 mV 126)
* current unit: 100 mA
*/
void crsf_send_battery(uint32_t voltage_mv, uint32_t current_ma,
uint8_t remaining_pct);
/*
* crsf_send_flight_mode() transmit CRSF flight-mode frame (type 0x21)
* for display on the pilot's handset OSD.
*
* armed: true "ARMED\0"
* false "DISARM\0"
*/
void crsf_send_flight_mode(bool armed);
extern volatile CRSFState crsf_state;
#endif /* CRSF_H */

89
src/battery.c Normal file
View File

@ -0,0 +1,89 @@
/*
* battery.c Vbat ADC reading for CRSF telemetry uplink (Issue #103)
*
* Hardware: ADC3 channel IN11 on PC1 (ADC_BATT 1, Mamba F722S FC).
* Voltage divider: 10 (upper) / 1 (lower) VBAT_SCALE_NUM = 11.
*
* Vbat_mV = (raw × VBAT_AREF_MV × VBAT_SCALE_NUM) >> VBAT_ADC_BITS
* = (raw × 3300 × 11) / 4096
*/
#include "battery.h"
#include "config.h"
#include "stm32f7xx_hal.h"
static ADC_HandleTypeDef s_hadc;
static bool s_ready = false;
void battery_init(void) {
__HAL_RCC_ADC3_CLK_ENABLE();
__HAL_RCC_GPIOC_CLK_ENABLE();
/* PC1 → analog input (no pull, no speed) */
GPIO_InitTypeDef gpio = {0};
gpio.Pin = GPIO_PIN_1;
gpio.Mode = GPIO_MODE_ANALOG;
gpio.Pull = GPIO_NOPULL;
HAL_GPIO_Init(GPIOC, &gpio);
/* ADC3 — single-conversion, software trigger, 12-bit right-aligned */
s_hadc.Instance = ADC3;
s_hadc.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV8; /* APB2/8 */
s_hadc.Init.Resolution = ADC_RESOLUTION_12B;
s_hadc.Init.ScanConvMode = DISABLE;
s_hadc.Init.ContinuousConvMode = DISABLE;
s_hadc.Init.DiscontinuousConvMode = DISABLE;
s_hadc.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE;
s_hadc.Init.ExternalTrigConv = ADC_SOFTWARE_START;
s_hadc.Init.DataAlign = ADC_DATAALIGN_RIGHT;
s_hadc.Init.NbrOfConversion = 1;
s_hadc.Init.DMAContinuousRequests = DISABLE;
s_hadc.Init.EOCSelection = ADC_EOC_SINGLE_CONV;
if (HAL_ADC_Init(&s_hadc) != HAL_OK) return;
/* Channel IN11 (PC1) with 480-cycle sampling for stability */
ADC_ChannelConfTypeDef ch = {0};
ch.Channel = ADC_CHANNEL_11;
ch.Rank = 1;
ch.SamplingTime = ADC_SAMPLETIME_480CYCLES;
if (HAL_ADC_ConfigChannel(&s_hadc, &ch) != HAL_OK) return;
s_ready = true;
}
uint32_t battery_read_mv(void) {
if (!s_ready) return 0u;
HAL_ADC_Start(&s_hadc);
if (HAL_ADC_PollForConversion(&s_hadc, 2u) != HAL_OK) return 0u;
uint32_t raw = HAL_ADC_GetValue(&s_hadc);
HAL_ADC_Stop(&s_hadc);
/* Vbat_mV = raw × (VREF_mV × scale) / ADC_counts */
return (raw * (uint32_t)VBAT_AREF_MV * VBAT_SCALE_NUM) /
((1u << VBAT_ADC_BITS));
}
/*
* Coarse SoC estimate.
* 3S LiPo: 9.9 V (0%) 12.6 V (100%) detect by Vbat < 13 V
* 4S LiPo: 13.2 V (0%) 16.8 V (100%) detect by Vbat 13 V
*/
uint8_t battery_estimate_pct(uint32_t voltage_mv) {
uint32_t v_min_mv, v_max_mv;
if (voltage_mv >= 13000u) {
/* 4S LiPo */
v_min_mv = 13200u;
v_max_mv = 16800u;
} else {
/* 3S LiPo */
v_min_mv = 9900u;
v_max_mv = 12600u;
}
if (voltage_mv <= v_min_mv) return 0u;
if (voltage_mv >= v_max_mv) return 100u;
return (uint8_t)(((voltage_mv - v_min_mv) * 100u) / (v_max_mv - v_min_mv));
}

View File

@ -291,3 +291,64 @@ int16_t crsf_to_range(uint16_t val, int16_t min, int16_t max) {
if (r > max) r = max;
return (int16_t)r;
}
/* ------------------------------------------------------------------ */
/* Telemetry TX helpers */
/* ------------------------------------------------------------------ */
/*
* Build a CRSF frame in `buf` and return the total byte count.
* buf must be at least CRSF_MAX_FRAME_LEN bytes.
* frame_type : CRSF type byte (e.g. 0x08 battery, 0x21 flight mode)
* payload : frame payload bytes (excluding type, CRC)
* plen : payload length in bytes
*/
static uint8_t crsf_build_frame(uint8_t *buf, uint8_t frame_type,
const uint8_t *payload, uint8_t plen) {
/* Total frame = SYNC + LEN + TYPE + PAYLOAD + CRC */
uint8_t frame_len = 2u + 1u + plen + 1u; /* SYNC + LEN + TYPE + payload + CRC */
if (frame_len > CRSF_MAX_FRAME_LEN) return 0;
buf[0] = CRSF_SYNC; /* 0xC8 */
buf[1] = (uint8_t)(plen + 2u); /* LEN = TYPE + payload + CRC */
buf[2] = frame_type;
memcpy(&buf[3], payload, plen);
buf[frame_len - 1] = crsf_frame_crc(buf, frame_len);
return frame_len;
}
/*
* crsf_send_battery() type 0x08 battery sensor.
* voltage_mv units of 100 mV (big-endian uint16)
* current_ma units of 100 mA (big-endian uint16)
* remaining_pct 0100 % (uint8); capacity mAh always 0 (no coulomb counter)
*/
void crsf_send_battery(uint32_t voltage_mv, uint32_t current_ma,
uint8_t remaining_pct) {
uint16_t v100 = (uint16_t)(voltage_mv / 100u); /* 100 mV units */
uint16_t c100 = (uint16_t)(current_ma / 100u); /* 100 mA units */
/* Payload: [v_hi][v_lo][c_hi][c_lo][cap_hi][cap_mid][cap_lo][remaining] */
uint8_t payload[8] = {
(uint8_t)(v100 >> 8), (uint8_t)(v100 & 0xFF),
(uint8_t)(c100 >> 8), (uint8_t)(c100 & 0xFF),
0, 0, 0, /* capacity mAh — not tracked */
remaining_pct,
};
uint8_t frame[CRSF_MAX_FRAME_LEN];
uint8_t flen = crsf_build_frame(frame, 0x08u, payload, sizeof(payload));
if (flen) HAL_UART_Transmit(&s_uart, frame, flen, 5u);
}
/*
* crsf_send_flight_mode() type 0x21 flight mode text.
* Displays on the handset's OSD/status bar.
* "ARMED\0" when armed (5 payload bytes + null)
* "DISARM\0" when not (7 payload bytes + null)
*/
void crsf_send_flight_mode(bool armed) {
const char *text = armed ? "ARMED" : "DISARM";
uint8_t plen = (uint8_t)(strlen(text) + 1u); /* include null terminator */
uint8_t frame[CRSF_MAX_FRAME_LEN];
uint8_t flen = crsf_build_frame(frame, 0x21u, (const uint8_t *)text, plen);
if (flen) HAL_UART_Transmit(&s_uart, frame, flen, 5u);
}

View File

@ -16,6 +16,7 @@
#include "bmp280.h"
#include "mag.h"
#include "jetson_cmd.h"
#include "battery.h"
#include <math.h>
#include <string.h>
#include <stdio.h>
@ -142,6 +143,9 @@ int main(void) {
mode_manager_t mode;
mode_manager_init(&mode);
/* Init battery ADC (PC1/ADC3 — Vbat divider 11:1) for CRSF telemetry */
battery_init();
/* Probe I2C1 for optional sensors — skip gracefully if not found */
int baro_chip = 0; /* chip_id: 0x58=BMP280, 0x60=BME280, 0=absent */
mag_type_t mag_type = MAG_NONE;
@ -167,6 +171,7 @@ int main(void) {
uint32_t send_tick = 0;
uint32_t balance_tick = 0;
uint32_t esc_tick = 0;
uint32_t crsf_telem_tick = 0; /* CRSF uplink telemetry TX timer */
const float dt = 1.0f / PID_LOOP_HZ; /* 1ms at 1kHz */
while (1) {
@ -313,6 +318,17 @@ int main(void) {
}
}
/* CRSF telemetry uplink — battery voltage + arm state at 1 Hz.
* Sends battery sensor frame (0x08) and flight mode frame (0x21)
* back to ELRS TX module so the pilot's handset OSD shows Vbat + state. */
if (now - crsf_telem_tick >= (1000u / CRSF_TELEMETRY_HZ)) {
crsf_telem_tick = now;
uint32_t vbat_mv = battery_read_mv();
uint8_t soc_pct = battery_estimate_pct(vbat_mv);
crsf_send_battery(vbat_mv, 0u, soc_pct);
crsf_send_flight_mode(bal.state == BALANCE_ARMED);
}
/* USB telemetry at 50Hz (only when streaming enabled and calibration done) */
if (cdc_streaming && mpu6000_is_calibrated() && now - send_tick >= 20) {
send_tick = now;

237
test/test_crsf_frames.py Normal file
View File

@ -0,0 +1,237 @@
"""
test_crsf_frames.py Unit tests for CRSF telemetry frame building (Issue #103).
Mirrors the C logic in src/crsf.c for crsf_send_battery() and
crsf_send_flight_mode() so frame layout and CRC can be verified
without flashing hardware.
Run with: pytest test/test_crsf_frames.py -v
"""
import struct
import pytest
# ── CRC8 DVB-S2 (polynomial 0xD5) ─────────────────────────────────────────
def crc8_dvb_s2(crc: int, byte: int) -> int:
crc ^= byte
for _ in range(8):
crc = ((crc << 1) ^ 0xD5) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF
return crc
def crsf_frame_crc(frame: bytes) -> int:
"""CRC covers frame[2] (type) through frame[-2] (last payload byte)."""
crc = 0
for b in frame[2:-1]:
crc = crc8_dvb_s2(crc, b)
return crc
# ── Frame builders matching C implementation ───────────────────────────────
CRSF_SYNC = 0xC8
def build_battery_frame(voltage_mv: int, current_ma: int,
remaining_pct: int) -> bytes:
"""
Type 0x08 battery sensor.
voltage : 100 mV units, uint16 big-endian
current : 100 mA units, uint16 big-endian
capacity : 0 mAh (not tracked), uint24 big-endian
remaining: 0100 %, uint8
Total payload = 8 bytes.
"""
v100 = voltage_mv // 100
c100 = current_ma // 100
payload = struct.pack('>HH3sB',
v100, c100,
b'\x00\x00\x00',
remaining_pct)
frame_type = 0x08
# SYNC + LEN(TYPE+payload+CRC) + TYPE + payload + CRC
frame_body = bytes([CRSF_SYNC, len(payload) + 2, frame_type]) + payload
frame = frame_body + b'\x00' # placeholder CRC slot
crc = crsf_frame_crc(frame)
return frame_body + bytes([crc])
def build_flight_mode_frame(armed: bool) -> bytes:
"""Type 0x21 — flight mode text, null-terminated."""
text = b'ARMED\x00' if armed else b'DISARM\x00'
frame_type = 0x21
frame_body = bytes([CRSF_SYNC, len(text) + 2, frame_type]) + text
frame = frame_body + b'\x00'
crc = crsf_frame_crc(frame)
return frame_body + bytes([crc])
# ── Helpers ────────────────────────────────────────────────────────────────
def validate_frame(frame: bytes):
"""Assert basic CRSF frame invariants."""
assert frame[0] == CRSF_SYNC, "bad sync byte"
length_field = frame[1]
total = length_field + 2 # SYNC + LEN + rest
assert len(frame) == total, f"frame length mismatch: {len(frame)} != {total}"
assert len(frame) <= 64, "frame too long (CRSF max 64 bytes)"
expected_crc = crsf_frame_crc(frame)
assert frame[-1] == expected_crc, \
f"CRC mismatch: got {frame[-1]:#04x}, expected {expected_crc:#04x}"
# ── Battery frame tests ────────────────────────────────────────────────────
class TestBatteryFrame:
def test_sync_byte(self):
f = build_battery_frame(12600, 0, 100)
assert f[0] == 0xC8
def test_frame_type(self):
f = build_battery_frame(12600, 0, 100)
assert f[2] == 0x08
def test_frame_invariants(self):
validate_frame(build_battery_frame(12600, 5000, 80))
def test_voltage_encoding_3s_full(self):
"""12.6 V → 126 in 100 mV units, big-endian."""
f = build_battery_frame(12600, 0, 100)
v100 = (f[3] << 8) | f[4]
assert v100 == 126
def test_voltage_encoding_4s_full(self):
"""16.8 V → 168."""
f = build_battery_frame(16800, 0, 100)
v100 = (f[3] << 8) | f[4]
assert v100 == 168
def test_current_encoding(self):
"""5000 mA → 50 in 100 mA units."""
f = build_battery_frame(12000, 5000, 75)
c100 = (f[5] << 8) | f[6]
assert c100 == 50
def test_remaining_pct(self):
f = build_battery_frame(11000, 0, 42)
assert f[10] == 42
def test_capacity_zero(self):
"""Capacity bytes (cap_hi, cap_mid, cap_lo) are zero — no coulomb counter."""
f = build_battery_frame(12600, 0, 100)
assert f[7] == 0 and f[8] == 0 and f[9] == 0
def test_crc_correct(self):
f = build_battery_frame(11500, 2000, 65)
validate_frame(f)
def test_zero_voltage(self):
"""Disconnected battery → 0 mV, 0 %."""
f = build_battery_frame(0, 0, 0)
validate_frame(f)
v100 = (f[3] << 8) | f[4]
assert v100 == 0
assert f[10] == 0
def test_total_frame_length(self):
"""Battery frame: SYNC(1)+LEN(1)+TYPE(1)+payload(8)+CRC(1) = 12 bytes."""
f = build_battery_frame(12000, 0, 80)
assert len(f) == 12
# ── Flight mode frame tests ────────────────────────────────────────────────
class TestFlightModeFrame:
def test_armed_text(self):
f = build_flight_mode_frame(True)
payload = f[3:-1]
assert payload == b'ARMED\x00'
def test_disarmed_text(self):
f = build_flight_mode_frame(False)
payload = f[3:-1]
assert payload == b'DISARM\x00'
def test_frame_type(self):
assert build_flight_mode_frame(True)[2] == 0x21
assert build_flight_mode_frame(False)[2] == 0x21
def test_crc_armed(self):
validate_frame(build_flight_mode_frame(True))
def test_crc_disarmed(self):
validate_frame(build_flight_mode_frame(False))
def test_armed_frame_length(self):
"""ARMED\0 = 6 bytes payload → total 10 bytes."""
f = build_flight_mode_frame(True)
assert len(f) == 10
def test_disarmed_frame_length(self):
"""DISARM\0 = 7 bytes payload → total 11 bytes."""
f = build_flight_mode_frame(False)
assert len(f) == 11
# ── CRC8 DVB-S2 self-test ─────────────────────────────────────────────────
class TestCRC8:
def test_known_vector(self):
"""Verify CRC8 DVB-S2 against known value (poly 0xD5, init 0)."""
# CRC of single byte 0xAB with poly 0xD5, init 0 → 0xC8
crc = crc8_dvb_s2(0, 0xAB)
assert crc == 0xC8
def test_different_payloads_differ(self):
f1 = build_battery_frame(12600, 0, 100)
f2 = build_battery_frame(11000, 0, 50)
assert f1[-1] != f2[-1], "different payloads should have different CRCs"
def test_crc_covers_type(self):
"""Changing the frame type changes the CRC."""
fa = build_battery_frame(12600, 0, 100) # type 0x08
fb = build_flight_mode_frame(True) # type 0x21
# Both frames differ in type byte and thus CRC
assert fa[-1] != fb[-1]
# ── battery_estimate_pct logic mirrored in Python ─────────────────────────
def battery_estimate_pct(voltage_mv: int) -> int:
"""Python mirror of battery_estimate_pct() in battery.c."""
if voltage_mv >= 13000:
v_min, v_max = 13200, 16800
else:
v_min, v_max = 9900, 12600
if voltage_mv <= v_min:
return 0
if voltage_mv >= v_max:
return 100
return int((voltage_mv - v_min) * 100 / (v_max - v_min))
class TestBatteryEstimatePct:
def test_3s_full(self):
assert battery_estimate_pct(12600) == 100
def test_3s_empty(self):
assert battery_estimate_pct(9900) == 0
def test_3s_mid(self):
pct = battery_estimate_pct(11250)
assert 45 <= pct <= 55
def test_4s_full(self):
assert battery_estimate_pct(16800) == 100
def test_4s_empty(self):
assert battery_estimate_pct(13200) == 0
def test_3s_over_voltage(self):
"""13000 mV triggers 4S branch (v_min=13200) → classified as dead 4S → 0%."""
assert battery_estimate_pct(13000) == 0
def test_zero_voltage(self):
assert battery_estimate_pct(0) == 0

2
ui/social-bot/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

12
ui/social-bot/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Saltybot Social Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2920
ui/social-bot/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
{
"name": "saltybot-social-dashboard",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Social-bot monitoring and configuration dashboard",
"scripts": {
"dev": "vite --port 8080 --host",
"build": "vite build",
"preview": "vite preview --port 8080 --host"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"roslib": "^1.4.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"vite": "^5.4.10"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

165
ui/social-bot/src/App.jsx Normal file
View File

@ -0,0 +1,165 @@
/**
* App.jsx Saltybot Social Dashboard root component.
*
* Layout:
* [TopBar: connection config + pipeline state badge]
* [TabNav: Status | Faces | Conversation | Personality | Navigation]
* [TabContent]
*/
import { useState, useCallback } from 'react';
import { useRosbridge } from './hooks/useRosbridge.js';
import { StatusPanel } from './components/StatusPanel.jsx';
import { FaceGallery } from './components/FaceGallery.jsx';
import { ConversationLog } from './components/ConversationLog.jsx';
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
import { NavModeSelector } from './components/NavModeSelector.jsx';
const TABS = [
{ id: 'status', label: 'Status', icon: '⬤' },
{ id: 'faces', label: 'Faces', icon: '◉' },
{ id: 'conversation', label: 'Conversation', icon: '◌' },
{ id: 'personality', label: 'Personality', icon: '◈' },
{ id: 'navigation', label: 'Navigation', icon: '◫' },
];
const DEFAULT_WS_URL = 'ws://localhost:9090';
function ConnectionBar({ url, setUrl, connected, error }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(url);
const handleApply = () => {
setUrl(draft);
setEditing(false);
};
return (
<div className="flex items-center gap-2 text-xs">
{/* Connection dot */}
<div className={`w-2 h-2 rounded-full shrink-0 ${
connected ? 'bg-green-400' : error ? 'bg-red-500' : 'bg-gray-600'
}`} />
{editing ? (
<div className="flex items-center gap-1">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleApply(); if (e.key === 'Escape') setEditing(false); }}
autoFocus
className="bg-gray-900 border border-cyan-800 rounded px-2 py-0.5 text-cyan-300 w-52 focus:outline-none"
/>
<button onClick={handleApply} className="px-2 py-0.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-400 hover:bg-cyan-900">Connect</button>
<button onClick={() => setEditing(false)} className="text-gray-600 hover:text-gray-400 px-1"></button>
</div>
) : (
<button
onClick={() => { setDraft(url); setEditing(true); }}
className="text-gray-500 hover:text-cyan-400 transition-colors truncate max-w-40"
title={url}
>
{connected ? (
<span className="text-green-400">rosbridge: {url}</span>
) : error ? (
<span className="text-red-400" title={error}> {url}</span>
) : (
<span className="text-gray-500">{url} (connecting)</span>
)}
</button>
)}
</div>
);
}
export default function App() {
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
const [activeTab, setActiveTab] = useState('status');
const { connected, error, subscribe, publish, callService, setParam } = useRosbridge(wsUrl);
// Memoized publish for NavModeSelector (avoids recreating on every render)
const publishFn = useCallback(
(name, type, data) => publish(name, type, data),
[publish]
);
return (
<div className="min-h-screen flex flex-col bg-[#050510] text-gray-300 font-mono">
{/* ── Top Bar ── */}
<header className="flex items-center justify-between px-4 py-2 bg-[#070712] border-b border-cyan-950 shrink-0 gap-2 flex-wrap">
<div className="flex items-center gap-3">
<span className="text-orange-500 font-bold tracking-widest text-sm"> SALTYBOT</span>
<span className="text-cyan-800 text-xs hidden sm:inline">SOCIAL DASHBOARD</span>
</div>
<ConnectionBar
url={wsUrl}
setUrl={setWsUrl}
connected={connected}
error={error}
/>
</header>
{/* ── Tab Nav ── */}
<nav className="bg-[#070712] border-b border-cyan-950 shrink-0">
<div className="flex overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-xs font-bold tracking-widest whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab.id
? 'border-cyan-500 text-cyan-300 bg-cyan-950 bg-opacity-30'
: 'border-transparent text-gray-500 hover:text-gray-300 hover:border-gray-700'
}`}
>
<span className="hidden sm:inline text-base leading-none">{tab.icon}</span>
{tab.label.toUpperCase()}
</button>
))}
</div>
</nav>
{/* ── Content ── */}
<main className="flex-1 overflow-y-auto p-4">
{activeTab === 'status' && (
<StatusPanel subscribe={subscribe} />
)}
{activeTab === 'faces' && (
<FaceGallery
subscribe={subscribe}
callService={callService}
/>
)}
{activeTab === 'conversation' && (
<ConversationLog subscribe={subscribe} />
)}
{activeTab === 'personality' && (
<PersonalityTuner
subscribe={subscribe}
setParam={setParam}
/>
)}
{activeTab === 'navigation' && (
<NavModeSelector
subscribe={subscribe}
publish={publishFn}
/>
)}
</main>
{/* ── Footer ── */}
<footer className="bg-[#070712] border-t border-cyan-950 px-4 py-1.5 flex items-center justify-between text-xs text-gray-700 shrink-0">
<span>rosbridge: <code className="text-gray-600">{wsUrl}</code></span>
<span className={connected ? 'text-green-700' : 'text-red-900'}>
{connected ? 'CONNECTED' : 'DISCONNECTED'}
</span>
</footer>
</div>
);
}

View File

@ -0,0 +1,221 @@
/**
* ConversationLog.jsx Real-time transcript with speaker labels.
*
* Subscribes:
* /social/speech/transcript (SpeechTranscript) human utterances
* /social/conversation/response (ConversationResponse) bot replies
*/
import { useEffect, useRef, useState } from 'react';
const MAX_ENTRIES = 200;
function formatTime(ts) {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function HumanBubble({ entry }) {
return (
<div className="flex flex-col items-end gap-0.5">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="text-xs">{formatTime(entry.ts)}</span>
<span className="text-blue-400 font-bold">{entry.speaker || 'unknown'}</span>
{entry.confidence != null && (
<span className="text-gray-600">({Math.round(entry.confidence * 100)}%)</span>
)}
{entry.partial && <span className="text-amber-600 text-xs italic">partial</span>}
</div>
<div className={`max-w-xs sm:max-w-md bubble-human rounded-lg px-3 py-2 text-sm text-blue-100 ${entry.partial ? 'bubble-partial' : ''}`}>
{entry.text}
</div>
</div>
);
}
function BotBubble({ entry }) {
return (
<div className="flex flex-col items-start gap-0.5">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="text-teal-400 font-bold">Salty</span>
{entry.speaker && <span className="text-gray-600"> {entry.speaker}</span>}
<span className="text-xs">{formatTime(entry.ts)}</span>
{entry.partial && <span className="text-amber-600 text-xs italic">streaming</span>}
</div>
<div className={`max-w-xs sm:max-w-md bubble-bot rounded-lg px-3 py-2 text-sm text-teal-100 ${entry.partial ? 'bubble-partial' : ''}`}>
{entry.text}
</div>
</div>
);
}
export function ConversationLog({ subscribe }) {
const [entries, setEntries] = useState([]);
const [autoScroll, setAutoScroll] = useState(true);
const bottomRef = useRef(null);
const scrollRef = useRef(null);
// Auto-scroll to bottom when new entries arrive
useEffect(() => {
if (autoScroll && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [entries, autoScroll]);
// Human transcript
useEffect(() => {
const unsub = subscribe(
'/social/speech/transcript',
'saltybot_social_msgs/SpeechTranscript',
(msg) => {
setEntries((prev) => {
const entry = {
id: `h-${msg.header?.stamp?.sec ?? Date.now()}-${msg.turn_id ?? Math.random()}`,
type: 'human',
text: msg.text,
speaker: msg.speaker_id,
confidence: msg.confidence,
partial: msg.is_partial,
ts: Date.now(),
};
// Replace partial entry with same turn if exists
if (msg.is_partial) {
const idx = prev.findLastIndex(
(e) => e.type === 'human' && e.partial && e.speaker === msg.speaker_id
);
if (idx !== -1) {
const updated = [...prev];
updated[idx] = entry;
return updated;
}
} else {
// Replace any trailing partial for same speaker
const idx = prev.findLastIndex(
(e) => e.type === 'human' && e.partial && e.speaker === msg.speaker_id
);
if (idx !== -1) {
const updated = [...prev];
updated[idx] = { ...entry, id: prev[idx].id };
return updated.slice(-MAX_ENTRIES);
}
}
return [...prev, entry].slice(-MAX_ENTRIES);
});
}
);
return unsub;
}, [subscribe]);
// Bot response
useEffect(() => {
const unsub = subscribe(
'/social/conversation/response',
'saltybot_social_msgs/ConversationResponse',
(msg) => {
setEntries((prev) => {
const entry = {
id: `b-${msg.turn_id ?? Math.random()}`,
type: 'bot',
text: msg.text,
speaker: msg.speaker_id,
partial: msg.is_partial,
turnId: msg.turn_id,
ts: Date.now(),
};
// Replace streaming partial with same turn_id
if (msg.turn_id != null) {
const idx = prev.findLastIndex(
(e) => e.type === 'bot' && e.turnId === msg.turn_id
);
if (idx !== -1) {
const updated = [...prev];
updated[idx] = entry;
return updated;
}
}
return [...prev, entry].slice(-MAX_ENTRIES);
});
}
);
return unsub;
}, [subscribe]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
setAutoScroll(atBottom);
};
const handleClear = () => setEntries([]);
return (
<div className="flex flex-col h-full" style={{ minHeight: '400px', maxHeight: '70vh' }}>
{/* Toolbar */}
<div className="flex items-center justify-between mb-2 shrink-0">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
CONVERSATION LOG ({entries.length})
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="accent-cyan-500 w-3 h-3"
/>
<span className="text-gray-500 text-xs">Auto-scroll</span>
</label>
<button
onClick={handleClear}
className="px-2 py-0.5 rounded border border-gray-700 text-gray-500 hover:text-gray-300 text-xs"
>
Clear
</button>
</div>
</div>
{/* Scroll container */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto space-y-3 pr-1"
style={{ minHeight: '300px' }}
>
{entries.length === 0 ? (
<div className="text-gray-600 text-sm text-center py-12 border border-dashed border-gray-800 rounded-lg">
Waiting for conversation
</div>
) : (
entries.map((entry) =>
entry.type === 'human' ? (
<HumanBubble key={entry.id} entry={entry} />
) : (
<BotBubble key={entry.id} entry={entry} />
)
)
)}
<div ref={bottomRef} />
</div>
{/* Legend */}
<div className="flex gap-4 mt-2 pt-2 border-t border-gray-900 shrink-0">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-blue-950 border border-blue-700" />
<span className="text-gray-600 text-xs">Human</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-teal-950 border border-teal-700" />
<span className="text-gray-600 text-xs">Salty</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-amber-600 opacity-60" />
<span className="text-gray-600 text-xs">Streaming</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,237 @@
/**
* FaceGallery.jsx Enrolled person management.
*
* Services:
* /social/enrollment/list_persons (ListPersons.srv)
* /social/enrollment/enroll_person (EnrollPerson.srv)
* /social/enrollment/delete_person (DeletePerson.srv)
*
* Topics (live detections):
* /social/face_detections (FaceDetectionArray) optional, shows live bboxes
*/
import { useEffect, useState, useCallback } from 'react';
function PersonCard({ person, onDelete, liveDetection }) {
const [deleting, setDeleting] = useState(false);
const enrolledAt = person.enrolled_at
? new Date(person.enrolled_at.sec * 1000).toLocaleDateString()
: '—';
const handleDelete = async () => {
if (!confirm(`Delete ${person.person_name}?`)) return;
setDeleting(true);
try {
await onDelete(person.person_id);
} finally {
setDeleting(false);
}
};
const isActive = liveDetection?.face_id === person.person_id;
return (
<div className={`bg-gray-900 rounded-lg border p-3 flex flex-col gap-2 transition-colors ${
isActive ? 'border-green-600 bg-green-950' : 'border-gray-800'
}`}>
{/* Avatar placeholder */}
<div className="w-full aspect-square rounded bg-gray-800 border border-gray-700 flex items-center justify-center relative overflow-hidden">
<svg className="w-12 h-12 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
{isActive && (
<div className="absolute inset-0 border-2 border-green-500 rounded pointer-events-none" />
)}
<div className="absolute top-1 right-1">
<div className={`w-2 h-2 rounded-full ${isActive ? 'bg-green-400' : 'bg-gray-700'}`} />
</div>
</div>
{/* Info */}
<div>
<div className="text-cyan-300 text-sm font-bold truncate">{person.person_name}</div>
<div className="text-gray-600 text-xs">ID: {person.person_id}</div>
<div className="text-gray-600 text-xs">Samples: {person.sample_count}</div>
<div className="text-gray-600 text-xs">Enrolled: {enrolledAt}</div>
</div>
{/* Delete */}
<button
onClick={handleDelete}
disabled={deleting}
className="w-full py-1 text-xs rounded border border-red-900 text-red-400 hover:bg-red-950 hover:border-red-700 disabled:opacity-40 transition-colors"
>
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
);
}
export function FaceGallery({ subscribe, callService }) {
const [persons, setPersons] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [enrolling, setEnrolling] = useState(false);
const [newName, setNewName] = useState('');
const [newSamples, setNewSamples] = useState(5);
const [liveDetections, setLiveDetections] = useState([]);
const loadPersons = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await callService(
'/social/enrollment/list_persons',
'saltybot_social_msgs/srv/ListPersons',
{}
);
setPersons(result?.persons ?? []);
} catch (e) {
setError('Service unavailable: ' + (e?.message ?? e));
} finally {
setLoading(false);
}
}, [callService]);
useEffect(() => {
loadPersons();
}, [loadPersons]);
// Live face detections
useEffect(() => {
const unsub = subscribe(
'/social/face_detections',
'saltybot_social_msgs/FaceDetectionArray',
(msg) => setLiveDetections(msg.faces ?? [])
);
return unsub;
}, [subscribe]);
const handleEnroll = async () => {
if (!newName.trim()) return;
setEnrolling(true);
setError(null);
try {
const result = await callService(
'/social/enrollment/enroll_person',
'saltybot_social_msgs/srv/EnrollPerson',
{ name: newName.trim(), mode: 'capture', n_samples: newSamples }
);
if (result?.success) {
setNewName('');
await loadPersons();
} else {
setError(result?.message ?? 'Enrollment failed');
}
} catch (e) {
setError('Enroll error: ' + (e?.message ?? e));
} finally {
setEnrolling(false);
}
};
const handleDelete = async (personId) => {
try {
await callService(
'/social/enrollment/delete_person',
'saltybot_social_msgs/srv/DeletePerson',
{ person_id: personId }
);
await loadPersons();
} catch (e) {
setError('Delete error: ' + (e?.message ?? e));
}
};
return (
<div className="space-y-4">
{/* Enroll new person */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">ENROLL NEW PERSON</div>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
placeholder="Person name…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleEnroll()}
className="flex-1 bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-cyan-700"
/>
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs shrink-0">Samples:</span>
<input
type="number"
min={1}
max={20}
value={newSamples}
onChange={(e) => setNewSamples(Number(e.target.value))}
className="w-16 bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
/>
</div>
<button
onClick={handleEnroll}
disabled={enrolling || !newName.trim()}
className="px-4 py-1.5 rounded bg-cyan-950 hover:bg-cyan-900 border border-cyan-700 text-cyan-300 text-sm disabled:opacity-40 transition-colors"
>
{enrolling ? 'Capturing…' : 'Enroll'}
</button>
</div>
{enrolling && (
<div className="mt-2 text-amber-400 text-xs animate-pulse">
Capturing face samples look at the camera
</div>
)}
</div>
{/* Error */}
{error && (
<div className="bg-red-950 border border-red-800 rounded px-3 py-2 text-red-300 text-xs">
{error}
</div>
)}
{/* Gallery header */}
<div className="flex items-center justify-between">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
ENROLLED PERSONS ({persons.length})
</div>
<div className="flex items-center gap-2">
{liveDetections.length > 0 && (
<div className="flex items-center gap-1 text-green-400 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
{liveDetections.length} detected
</div>
)}
<button
onClick={loadPersons}
disabled={loading}
className="px-2 py-1 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs disabled:opacity-40 transition-colors"
>
{loading ? '…' : 'Refresh'}
</button>
</div>
</div>
{/* Grid */}
{loading && persons.length === 0 ? (
<div className="text-gray-600 text-sm text-center py-8">Loading</div>
) : persons.length === 0 ? (
<div className="text-gray-600 text-sm text-center py-8 border border-dashed border-gray-800 rounded-lg">
No enrolled persons
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{persons.map((p) => (
<PersonCard
key={p.person_id}
person={p}
onDelete={handleDelete}
liveDetection={liveDetections.find(d => d.face_id === p.person_id)}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,176 @@
/**
* NavModeSelector.jsx Follow mode switcher.
*
* Publishes: /social/nav/mode (std_msgs/String)
* Subscribes: /social/nav/mode (std_msgs/String) echoed back by social_nav_node
* /social/nav/status (std_msgs/String) freeform status string
*/
import { useEffect, useState } from 'react';
const MODES = [
{
id: 'shadow',
label: 'SHADOW',
icon: '👤',
description: 'Follow directly behind at distance',
color: 'border-blue-700 text-blue-300 bg-blue-950',
activeColor: 'border-blue-400 text-blue-100 bg-blue-900 mode-active',
},
{
id: 'lead',
label: 'LEAD',
icon: '➡',
description: 'Robot moves ahead, person follows',
color: 'border-green-700 text-green-300 bg-green-950',
activeColor: 'border-green-400 text-green-100 bg-green-900 mode-active',
},
{
id: 'side',
label: 'SIDE',
icon: '↔',
description: 'Walk side-by-side',
color: 'border-purple-700 text-purple-300 bg-purple-950',
activeColor: 'border-purple-400 text-purple-100 bg-purple-900 mode-active',
},
{
id: 'orbit',
label: 'ORBIT',
icon: '⟳',
description: 'Circle around the tracked person',
color: 'border-amber-700 text-amber-300 bg-amber-950',
activeColor: 'border-amber-400 text-amber-100 bg-amber-900 mode-active',
},
{
id: 'loose',
label: 'LOOSE',
icon: '⬡',
description: 'Follow with generous spacing',
color: 'border-teal-700 text-teal-300 bg-teal-950',
activeColor: 'border-teal-400 text-teal-100 bg-teal-900 mode-active',
},
{
id: 'tight',
label: 'TIGHT',
icon: '⬟',
description: 'Follow closely, minimal gap',
color: 'border-red-700 text-red-300 bg-red-950',
activeColor: 'border-red-400 text-red-100 bg-red-900 mode-active',
},
];
const VOICE_COMMANDS = [
{ mode: 'shadow', cmd: '"shadow" / "follow me"' },
{ mode: 'lead', cmd: '"lead me" / "go ahead"' },
{ mode: 'side', cmd: '"stay beside"' },
{ mode: 'orbit', cmd: '"orbit"' },
{ mode: 'loose', cmd: '"give me space"' },
{ mode: 'tight', cmd: '"stay close"' },
];
export function NavModeSelector({ subscribe, publish }) {
const [activeMode, setActiveMode] = useState(null);
const [navStatus, setNavStatus] = useState('');
const [sending, setSending] = useState(null);
// Subscribe to echoed mode topic
useEffect(() => {
const unsub = subscribe(
'/social/nav/mode',
'std_msgs/String',
(msg) => setActiveMode(msg.data)
);
return unsub;
}, [subscribe]);
// Subscribe to nav status
useEffect(() => {
const unsub = subscribe(
'/social/nav/status',
'std_msgs/String',
(msg) => setNavStatus(msg.data)
);
return unsub;
}, [subscribe]);
const handleMode = async (modeId) => {
setSending(modeId);
publish('/social/nav/mode', 'std_msgs/String', { data: modeId });
// Optimistic update; will be confirmed when echoed back
setActiveMode(modeId);
setTimeout(() => setSending(null), 800);
};
return (
<div className="space-y-4">
{/* Status */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-2">NAV STATUS</div>
<div className="flex items-center gap-3">
{activeMode ? (
<>
<div className="w-2.5 h-2.5 rounded-full bg-green-400 animate-pulse" />
<span className="text-gray-300 text-sm">
Mode: <span className="text-cyan-300 font-bold uppercase">{activeMode}</span>
</span>
</>
) : (
<>
<div className="w-2.5 h-2.5 rounded-full bg-gray-600" />
<span className="text-gray-600 text-sm">No mode received</span>
</>
)}
{navStatus && (
<span className="ml-auto text-gray-500 text-xs">{navStatus}</span>
)}
</div>
</div>
{/* Mode buttons */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">FOLLOW MODE</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{MODES.map((mode) => {
const isActive = activeMode === mode.id;
const isSending = sending === mode.id;
return (
<button
key={mode.id}
onClick={() => handleMode(mode.id)}
disabled={isSending}
title={mode.description}
className={`flex flex-col items-center gap-1 py-3 px-2 rounded-lg border font-bold text-sm transition-all duration-200 ${
isActive ? mode.activeColor : mode.color
} hover:opacity-90 active:scale-95 disabled:cursor-wait`}
>
<span className="text-xl">{mode.icon}</span>
<span className="tracking-widest text-xs">{mode.label}</span>
{isSending && <span className="text-xs opacity-60">sending</span>}
</button>
);
})}
</div>
<p className="text-gray-600 text-xs mt-3">
Tap to publish to <code>/social/nav/mode</code>
</p>
</div>
{/* Voice commands reference */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">VOICE COMMANDS</div>
<div className="space-y-1.5">
{VOICE_COMMANDS.map(({ mode, cmd }) => (
<div key={mode} className="flex items-center gap-2 text-xs">
<span className="text-gray-500 uppercase w-14 shrink-0">{mode}</span>
<span className="text-gray-400 italic">{cmd}</span>
</div>
))}
</div>
<p className="text-gray-600 text-xs mt-3">
Voice commands are parsed by <code>social_nav_node</code> from{' '}
<code>/social/speech/command</code>.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,228 @@
/**
* PersonalityTuner.jsx Personality dial controls.
*
* Reads current personality from /social/personality/state (PersonalityState).
* Writes via /personality_node/set_parameters (rcl_interfaces/srv/SetParameters).
*
* SOUL.md params controlled:
* sass_level (int, 010)
* humor_level (int, 010)
* verbosity (int, 010) new param, add to SOUL.md + personality_node
*/
import { useEffect, useState } from 'react';
const PERSONALITY_DIALS = [
{
key: 'sass_level',
label: 'SASS',
param: { name: 'sass_level', type: 'integer' },
min: 0, max: 10,
leftLabel: 'Polite',
rightLabel: 'Maximum Sass',
color: 'accent-orange',
barColor: '#f97316',
description: '0 = pure politeness, 10 = maximum sass',
},
{
key: 'humor_level',
label: 'HUMOR',
param: { name: 'humor_level', type: 'integer' },
min: 0, max: 10,
leftLabel: 'Deadpan',
rightLabel: 'Comedian',
color: 'accent-cyan',
barColor: '#06b6d4',
description: '0 = deadpan/serious, 10 = comedian',
},
{
key: 'verbosity',
label: 'VERBOSITY',
param: { name: 'verbosity', type: 'integer' },
min: 0, max: 10,
leftLabel: 'Terse',
rightLabel: 'Verbose',
color: 'accent-purple',
barColor: '#a855f7',
description: '0 = brief responses, 10 = elaborate explanations',
},
];
function DialSlider({ dial, value, onChange }) {
const pct = ((value - dial.min) / (dial.max - dial.min)) * 100;
return (
<div className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-gray-400 tracking-widest">{dial.label}</span>
<span
className="text-sm font-bold w-6 text-right"
style={{ color: dial.barColor }}
>
{value}
</span>
</div>
<div className="relative">
<input
type="range"
min={dial.min}
max={dial.max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className={`w-full h-1.5 rounded appearance-none cursor-pointer ${dial.color}`}
style={{
background: `linear-gradient(to right, ${dial.barColor} ${pct}%, #1f2937 ${pct}%)`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-600">
<span>{dial.leftLabel}</span>
<span>{dial.rightLabel}</span>
</div>
</div>
);
}
export function PersonalityTuner({ subscribe, setParam }) {
const [values, setValues] = useState({ sass_level: 4, humor_level: 7, verbosity: 5 });
const [applied, setApplied] = useState(null); // last-applied snapshot
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState(null);
const [personaInfo, setPersonaInfo] = useState(null);
// Sync with live personality state
useEffect(() => {
const unsub = subscribe(
'/social/personality/state',
'saltybot_social_msgs/PersonalityState',
(msg) => {
setPersonaInfo({
name: msg.persona_name,
mood: msg.mood,
person: msg.person_id,
tier: msg.relationship_tier,
greeting: msg.greeting_text,
});
}
);
return unsub;
}, [subscribe]);
const isDirty = JSON.stringify(values) !== JSON.stringify(applied);
const handleApply = async () => {
setSaving(true);
setSaveResult(null);
try {
const params = PERSONALITY_DIALS.map((d) => ({
name: d.param.name,
type: d.param.type,
value: values[d.key],
}));
await setParam('personality_node', params);
setApplied({ ...values });
setSaveResult({ ok: true, msg: 'Parameters applied to personality_node' });
} catch (e) {
setSaveResult({ ok: false, msg: e?.message ?? 'Service call failed' });
} finally {
setSaving(false);
setTimeout(() => setSaveResult(null), 4000);
}
};
const handleReset = () => {
setValues({ sass_level: 4, humor_level: 7, verbosity: 5 });
};
return (
<div className="space-y-4">
{/* Current persona info */}
{personaInfo && (
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-2">ACTIVE PERSONA</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-600">Name: </span>
<span className="text-cyan-300 font-bold">{personaInfo.name || '—'}</span>
</div>
<div>
<span className="text-gray-600">Mood: </span>
<span className={`font-bold ${
personaInfo.mood === 'happy' ? 'text-green-400' :
personaInfo.mood === 'curious' ? 'text-blue-400' :
personaInfo.mood === 'annoyed' ? 'text-red-400' :
personaInfo.mood === 'playful' ? 'text-purple-400' :
'text-gray-400'
}`}>{personaInfo.mood || '—'}</span>
</div>
{personaInfo.person && (
<>
<div>
<span className="text-gray-600">Talking to: </span>
<span className="text-amber-400">{personaInfo.person}</span>
</div>
<div>
<span className="text-gray-600">Tier: </span>
<span className="text-gray-300">{personaInfo.tier || '—'}</span>
</div>
</>
)}
{personaInfo.greeting && (
<div className="col-span-2 mt-1 text-gray-400 italic border-t border-gray-800 pt-1">
&quot;{personaInfo.greeting}&quot;
</div>
)}
</div>
</div>
)}
{/* Personality sliders */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-5">
<div className="text-cyan-700 text-xs font-bold tracking-widest">PERSONALITY DIALS</div>
{PERSONALITY_DIALS.map((dial) => (
<DialSlider
key={dial.key}
dial={dial}
value={values[dial.key]}
onChange={(v) => setValues((prev) => ({ ...prev, [dial.key]: v }))}
/>
))}
{/* Info */}
<div className="text-gray-700 text-xs border-t border-gray-900 pt-3">
Changes call <code className="text-gray-500">/personality_node/set_parameters</code>.
Hot-reload to SOUL.md happens within reload_interval (default 5s).
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
onClick={handleApply}
disabled={saving || !isDirty}
className="flex-1 py-2 rounded font-bold text-sm border border-cyan-700 bg-cyan-950 hover:bg-cyan-900 text-cyan-300 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{saving ? 'Applying…' : isDirty ? 'Apply Changes' : 'Up to Date'}
</button>
<button
onClick={handleReset}
className="px-4 py-2 rounded text-sm border border-gray-700 text-gray-400 hover:text-gray-200 hover:border-gray-600 transition-colors"
>
Defaults
</button>
</div>
{/* Save result */}
{saveResult && (
<div className={`rounded px-3 py-2 text-xs ${
saveResult.ok
? 'bg-green-950 border border-green-800 text-green-300'
: 'bg-red-950 border border-red-800 text-red-300'
}`}>
{saveResult.msg}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,154 @@
/**
* StatusPanel.jsx Live pipeline status display.
*
* Subscribes to /social/orchestrator/state (std_msgs/String, JSON payload):
* {
* state: "idle"|"listening"|"thinking"|"speaking"|"throttled",
* gpu_free_mb: number,
* gpu_total_mb: number,
* latency: {
* wakeword_to_transcript: { mean_ms, p95_ms, n },
* transcript_to_llm: { mean_ms, p95_ms, n },
* llm_to_tts: { mean_ms, p95_ms, n },
* end_to_end: { mean_ms, p95_ms, n }
* },
* persona_name: string,
* active_person: string
* }
*/
import { useEffect, useState } from 'react';
const STATE_CONFIG = {
idle: { label: 'IDLE', color: 'text-gray-400', bg: 'bg-gray-800', border: 'border-gray-600', pulse: '' },
listening: { label: 'LISTENING', color: 'text-blue-300', bg: 'bg-blue-950', border: 'border-blue-600', pulse: 'pulse-blue' },
thinking: { label: 'THINKING', color: 'text-amber-300', bg: 'bg-amber-950', border: 'border-amber-600', pulse: 'pulse-amber' },
speaking: { label: 'SPEAKING', color: 'text-green-300', bg: 'bg-green-950', border: 'border-green-600', pulse: 'pulse-green' },
throttled: { label: 'THROTTLED', color: 'text-red-300', bg: 'bg-red-950', border: 'border-red-600', pulse: 'pulse-amber' },
};
function LatencyRow({ label, stat }) {
if (!stat || stat.n === 0) return null;
const warn = stat.mean_ms > 500;
const crit = stat.mean_ms > 1500;
const cls = crit ? 'text-red-400' : warn ? 'text-amber-400' : 'text-cyan-400';
return (
<div className="flex justify-between items-center py-0.5">
<span className="text-gray-500 text-xs">{label}</span>
<div className="text-right">
<span className={`text-xs font-bold ${cls}`}>{Math.round(stat.mean_ms)}ms</span>
<span className="text-gray-600 text-xs ml-1">p95:{Math.round(stat.p95_ms)}ms</span>
</div>
</div>
);
}
export function StatusPanel({ subscribe }) {
const [status, setStatus] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
useEffect(() => {
const unsub = subscribe(
'/social/orchestrator/state',
'std_msgs/String',
(msg) => {
try {
const data = JSON.parse(msg.data);
setStatus(data);
setLastUpdate(Date.now());
} catch {
// ignore parse errors
}
}
);
return unsub;
}, [subscribe]);
const state = status?.state ?? 'idle';
const cfg = STATE_CONFIG[state] ?? STATE_CONFIG.idle;
const gpuFree = status?.gpu_free_mb ?? 0;
const gpuTotal = status?.gpu_total_mb ?? 1;
const gpuUsed = gpuTotal - gpuFree;
const gpuPct = Math.round((gpuUsed / gpuTotal) * 100);
const gpuWarn = gpuPct > 80;
const lat = status?.latency ?? {};
const stale = lastUpdate && Date.now() - lastUpdate > 5000;
return (
<div className="space-y-4">
{/* Pipeline State */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">PIPELINE STATE</div>
<div className="flex items-center gap-4">
<div
className={`w-5 h-5 rounded-full shrink-0 ${cfg.bg} border-2 ${cfg.border} ${cfg.pulse}`}
/>
<div>
<div className={`text-2xl font-bold tracking-widest ${cfg.color}`}>{cfg.label}</div>
{status?.persona_name && (
<div className="text-gray-500 text-xs mt-0.5">
Persona: <span className="text-cyan-500">{status.persona_name}</span>
</div>
)}
{status?.active_person && (
<div className="text-gray-500 text-xs">
Talking to: <span className="text-amber-400">{status.active_person}</span>
</div>
)}
</div>
{stale && (
<div className="ml-auto text-red-500 text-xs">STALE</div>
)}
{!status && (
<div className="ml-auto text-gray-600 text-xs">No signal</div>
)}
</div>
</div>
{/* GPU Memory */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">GPU MEMORY</div>
{gpuTotal > 1 ? (
<>
<div className="flex justify-between text-xs mb-1.5">
<span className={gpuWarn ? 'text-red-400' : 'text-gray-400'}>
{Math.round(gpuUsed)} MB used
</span>
<span className="text-gray-500">{Math.round(gpuTotal)} MB total</span>
</div>
<div className="w-full h-3 bg-gray-900 rounded overflow-hidden border border-gray-800">
<div
className="h-full transition-all duration-500 rounded"
style={{
width: `${gpuPct}%`,
background: gpuPct > 90 ? '#ef4444' : gpuPct > 75 ? '#f59e0b' : '#06b6d4',
}}
/>
</div>
<div className={`text-xs mt-1 text-right ${gpuWarn ? 'text-amber-400' : 'text-gray-600'}`}>
{gpuPct}%
</div>
</>
) : (
<div className="text-gray-600 text-xs">No GPU data</div>
)}
</div>
{/* Latency */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">LATENCY</div>
{Object.keys(lat).length > 0 ? (
<div className="divide-y divide-gray-900">
<LatencyRow label="Wake → Transcript" stat={lat.wakeword_to_transcript} />
<LatencyRow label="Transcript → LLM" stat={lat.transcript_to_llm} />
<LatencyRow label="LLM → TTS" stat={lat.llm_to_tts} />
<LatencyRow label="End-to-End" stat={lat.end_to_end} />
</div>
) : (
<div className="text-gray-600 text-xs">No latency data yet</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,116 @@
/**
* useRosbridge.js React hook for ROS2 rosbridge_server WebSocket connection.
*
* rosbridge_server default: ws://<robot-ip>:9090
* Provides subscribe/publish/callService helpers bound to the active connection.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import ROSLIB from 'roslib';
export function useRosbridge(url) {
const [connected, setConnected] = useState(false);
const [error, setError] = useState(null);
const rosRef = useRef(null);
const subscribersRef = useRef(new Map()); // topic -> ROSLIB.Topic
useEffect(() => {
if (!url) return;
const ros = new ROSLIB.Ros({ url });
rosRef.current = ros;
ros.on('connection', () => {
setConnected(true);
setError(null);
});
ros.on('error', (err) => {
setError(err?.toString() || 'Connection error');
});
ros.on('close', () => {
setConnected(false);
});
return () => {
subscribersRef.current.forEach((topic) => topic.unsubscribe());
subscribersRef.current.clear();
ros.close();
rosRef.current = null;
};
}, [url]);
/** Subscribe to a ROS2 topic. Returns an unsubscribe function. */
const subscribe = useCallback((name, messageType, callback) => {
if (!rosRef.current) return () => {};
const key = `${name}::${messageType}`;
if (subscribersRef.current.has(key)) {
subscribersRef.current.get(key).unsubscribe();
}
const topic = new ROSLIB.Topic({
ros: rosRef.current,
name,
messageType,
});
topic.subscribe(callback);
subscribersRef.current.set(key, topic);
return () => {
topic.unsubscribe();
subscribersRef.current.delete(key);
};
}, []);
/** Publish a single message to a ROS2 topic. */
const publish = useCallback((name, messageType, data) => {
if (!rosRef.current) return;
const topic = new ROSLIB.Topic({
ros: rosRef.current,
name,
messageType,
});
topic.publish(new ROSLIB.Message(data));
}, []);
/** Call a ROS2 service. Returns a Promise resolving to the response. */
const callService = useCallback((name, serviceType, request = {}) => {
return new Promise((resolve, reject) => {
if (!rosRef.current) {
reject(new Error('Not connected'));
return;
}
const svc = new ROSLIB.Service({
ros: rosRef.current,
name,
serviceType,
});
svc.callService(new ROSLIB.ServiceRequest(request), resolve, reject);
});
}, []);
/** Set ROS2 node parameters via rcl_interfaces/srv/SetParameters. */
const setParam = useCallback((nodeName, params) => {
// params: { name: string, type: 'bool'|'int'|'double'|'string', value: any }[]
const TYPE_MAP = { bool: 1, integer: 2, double: 3, string: 4, int: 2 };
const parameters = params.map(({ name, type, value }) => {
const typeInt = TYPE_MAP[type] ?? 4;
const valueKey = {
1: 'bool_value',
2: 'integer_value',
3: 'double_value',
4: 'string_value',
}[typeInt];
return { name, value: { type: typeInt, [valueKey]: value } };
});
return callService(
`/${nodeName}/set_parameters`,
'rcl_interfaces/srv/SetParameters',
{ parameters }
);
}, [callService]);
return { connected, error, subscribe, publish, callService, setParam };
}

View File

@ -0,0 +1,73 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
body {
font-family: 'Courier New', Courier, monospace;
background: #050510;
color: #d1d5db;
margin: 0;
padding: 0;
min-height: 100vh;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: #020208;
}
::-webkit-scrollbar-thumb {
background: #1a3a4a;
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: #00b8d9;
}
/* Pulse animations for pipeline state */
@keyframes pulse-blue {
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.6); }
50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0); }
}
@keyframes pulse-amber {
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.6); }
50% { box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); }
}
@keyframes pulse-green {
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.6); }
50% { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
}
.pulse-blue { animation: pulse-blue 1.2s infinite; }
.pulse-amber { animation: pulse-amber 0.8s infinite; }
.pulse-green { animation: pulse-green 0.6s infinite; }
/* Conversation bubbles */
.bubble-human {
background: #1e3a5f;
border: 1px solid #2d6a9f;
}
.bubble-bot {
background: #0d3030;
border: 1px solid #0d9488;
}
.bubble-partial {
opacity: 0.7;
}
/* Slider accent colors */
input[type='range'].accent-cyan { accent-color: #00b8d9; }
input[type='range'].accent-orange { accent-color: #f97316; }
input[type='range'].accent-purple { accent-color: #a855f7; }
/* Mode button active glow */
.mode-active {
box-shadow: 0 0 10px rgba(0, 184, 217, 0.4);
}

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: {
mono: ['"Courier New"', 'Courier', 'monospace'],
},
},
},
plugins: [],
};

View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 8080,
host: true,
},
preview: {
port: 8080,
host: true,
},
});