mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d001b5263 |
+2
-10
@@ -7,13 +7,5 @@ logs
|
||||
node_modules/
|
||||
.pio
|
||||
.vscode/
|
||||
config.h
|
||||
robots.json
|
||||
roverd-dummy
|
||||
server/config.yaml
|
||||
server/package-lock.json
|
||||
package-lock.json
|
||||
server/data/discord-guilds.json
|
||||
server/data/community-goal.json
|
||||
server/data/admin-reason.json
|
||||
server/data
|
||||
include/config.h
|
||||
server/robots.json
|
||||
|
||||
@@ -1,7 +1,64 @@
|
||||
# Multi Roomba Rover
|
||||
A system for controlling create 2 compatible roombas through a webpage.
|
||||
|
||||
Docs coming "soon"
|
||||
a remake of my RoombaRover project with a decentralized and embedded approach
|
||||
|
||||
## Basic installation
|
||||
-
|
||||
## Hardware stack
|
||||
|
||||
On each roomba:
|
||||
- an esp32
|
||||
- a level shifter
|
||||
- DONT FORGET THE BRC PIN PULSE
|
||||
- a power supply
|
||||
- an openIPC camera
|
||||
- USB wifi card
|
||||
- microphone
|
||||
- speaker
|
||||
- MAYBE a master relay which can be turned off programatically to save the roomba from discharging. based on battery voltage plus urgent battery #?
|
||||
|
||||
## Current software layout
|
||||
|
||||
```
|
||||
.
|
||||
├── include/
|
||||
│ ├── config.example.h // copy to config.h with your Wi-Fi + server settings
|
||||
│ └── protocol.h // shared packet layout (control + telemetry)
|
||||
├── src/main.cpp // ESP32 firmware entrypoint (PlatformIO)
|
||||
└── server/
|
||||
├── package.json // Node.js server + Socket.IO web UI
|
||||
├── robots.example.json // copy/edit to robots.json for your fleet
|
||||
├── src/ // UDP relay + telemetry decoder
|
||||
└── public/ // barebones HTML/JS UI
|
||||
```
|
||||
|
||||
### Firmware quickstart
|
||||
|
||||
1. `cp include/config.example.h include/config.h` and fill in:
|
||||
- `WIFI_SSID` / `WIFI_PASSWORD`
|
||||
- `CONTROL_SERVER_IP` (Node server host)
|
||||
- `ROOMBA_ID` (unique per robot; must match the server entry)
|
||||
- tweak ports only if you have a reason.
|
||||
2. Flash with PlatformIO: `pio run -t upload` (env `esp32s3`).
|
||||
3. The firmware spawns three FreeRTOS tasks:
|
||||
- control loop (5 ms cadence) – consumes UDP control packets and drives the Create 2 via UART pins 16/17. Wheel commands decay to zero if no packets arrive for 250 ms.
|
||||
- telemetry loop (500 ms cadence) – polls sensor group 100, appends Wi-Fi/LRU stats, and streams UDP telemetry to the server.
|
||||
- BRC maintenance – pulses GPIO5 low for 1 s every minute to keep the robot awake.
|
||||
|
||||
### Server + web UI quickstart
|
||||
|
||||
1. `cd server`
|
||||
2. `cp robots.example.json robots.json` and add one entry per robot. Only the `id` is required (must match `ROOMBA_ID` in the firmware); override `controlPort`/`maxWheelSpeed` if you deviate from defaults.
|
||||
3. Install deps: `npm install`
|
||||
4. Run in dev mode: `npm run dev`
|
||||
- HTTP + Socket.IO on `http://localhost:8080`
|
||||
- UDP control bind port `62000`, telemetry bind port `62001` (override with env vars).
|
||||
5. Open the web UI:
|
||||
- select a robot
|
||||
- drive with WASD (left/right wheel mm/s shown in telemetry summary)
|
||||
- buttons issue Safe/Full/Enable-OI/Dock commands
|
||||
- sensor list renders the decoded Create 2 group-100 payload plus ESP stats
|
||||
|
||||
Each ESP32 announces itself as soon as it streams telemetry, so the server automatically learns the robot’s current IP address (no static DHCP entries required). If you do know a static IP, you can still set `deviceHost` in `robots.json` and the server will use it immediately.
|
||||
|
||||
UDP streams stay simple:
|
||||
- server -> ESP32: fixed 12-byte control packet blasted at 50 Hz per robot
|
||||
- ESP32 -> server: framed telemetry header + raw sensor group 100 + trailer (CRC-8)
|
||||
|
||||
Vendored
-22
@@ -1,22 +0,0 @@
|
||||
#configuration for roverd
|
||||
name: dummy1
|
||||
serverUrl: ws://127.0.0.1:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyS0
|
||||
baud: 115200
|
||||
brc:
|
||||
gpioPin: 25
|
||||
pulseEvery: 1m
|
||||
pulseWidth: 1s
|
||||
battery:
|
||||
full: 2068
|
||||
warn: 1700
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
enabled: false
|
||||
Vendored
-22
@@ -1,22 +0,0 @@
|
||||
#configuration for roverd
|
||||
name: dummy2
|
||||
serverUrl: ws://127.0.0.1:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyS0
|
||||
baud: 115200
|
||||
brc:
|
||||
gpioPin: 25
|
||||
pulseEvery: 1m
|
||||
pulseWidth: 1s
|
||||
battery:
|
||||
full: 2068
|
||||
warn: 1700
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
enabled: false
|
||||
Vendored
-22
@@ -1,22 +0,0 @@
|
||||
#configuration for roverd
|
||||
name: dummy3
|
||||
serverUrl: ws://127.0.0.1:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyS0
|
||||
baud: 115200
|
||||
brc:
|
||||
gpioPin: 25
|
||||
pulseEvery: 1m
|
||||
pulseWidth: 1s
|
||||
battery:
|
||||
full: 2068
|
||||
warn: 1700
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
enabled: false
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
// Copy this file to include/config.h and fill in your network + server settings.
|
||||
|
||||
#define WIFI_SSID "YourNetworkName"
|
||||
#define WIFI_PASSWORD "YourNetworkPassword"
|
||||
|
||||
// UDP server that issues control packets and receives telemetry.
|
||||
#define CONTROL_SERVER_IP "192.168.1.50"
|
||||
#define CONTROL_SERVER_PORT 62000
|
||||
#define TELEMETRY_SERVER_PORT 62001
|
||||
|
||||
// Local ports on the ESP32. Keeping them distinct simplifies sniffing.
|
||||
#define ESP32_CONTROL_PORT 50010
|
||||
#define ESP32_TELEMETRY_PORT 50011
|
||||
|
||||
// Friendly name to embed in telemetry.
|
||||
#define ROOMBA_ID "roomba-alpha"
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <type_traits>
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace mrr {
|
||||
|
||||
constexpr uint8_t kControlMagic = 0xAA;
|
||||
constexpr uint8_t kTelemetryMagic = 0x55;
|
||||
constexpr uint8_t kProtocolVersion = 1;
|
||||
constexpr size_t kSensorGroup100Length = 80;
|
||||
constexpr size_t kMaxRobotIdLength = 16;
|
||||
|
||||
enum class OiModeRequest : uint8_t {
|
||||
kNoChange = 0,
|
||||
kPassive = 1,
|
||||
kSafe = 2,
|
||||
kFull = 3,
|
||||
};
|
||||
|
||||
enum ActionBits : uint8_t {
|
||||
kActionSeekDock = 0x01,
|
||||
kActionPlaySong = 0x02,
|
||||
kActionLoadSong = 0x04,
|
||||
kActionEnableOi = 0x08,
|
||||
};
|
||||
|
||||
struct __attribute__((packed)) ControlPacket {
|
||||
uint8_t magic{kControlMagic};
|
||||
uint8_t version{kProtocolVersion};
|
||||
uint16_t seq{};
|
||||
int16_t left_mmps{};
|
||||
int16_t right_mmps{};
|
||||
uint8_t oi_mode{};
|
||||
uint8_t actions{};
|
||||
uint8_t song_slot{};
|
||||
uint8_t checksum{};
|
||||
};
|
||||
|
||||
static_assert(sizeof(ControlPacket) == 12, "ControlPacket must remain packed");
|
||||
|
||||
struct __attribute__((packed)) TelemetryPacketHeader {
|
||||
uint8_t magic{kTelemetryMagic};
|
||||
uint8_t version{kProtocolVersion};
|
||||
uint16_t seq{};
|
||||
uint32_t uptime_ms{};
|
||||
uint32_t last_control_age_ms{};
|
||||
int8_t wifi_rssi_dbm{};
|
||||
uint8_t status_bits{};
|
||||
uint8_t sensor_bytes{};
|
||||
uint8_t robot_id_length{};
|
||||
char robot_id[kMaxRobotIdLength]{};
|
||||
};
|
||||
|
||||
struct __attribute__((packed)) TelemetryPacketTrailer {
|
||||
int16_t applied_left_mmps{};
|
||||
int16_t applied_right_mmps{};
|
||||
uint16_t last_control_seq{};
|
||||
uint16_t dropped_control_packets{};
|
||||
uint8_t checksum{};
|
||||
};
|
||||
|
||||
inline uint8_t checksum8(const uint8_t* data, size_t len) {
|
||||
uint32_t sum = 0;
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
sum += data[i];
|
||||
}
|
||||
return static_cast<uint8_t>(sum & 0xFF);
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
inline uint8_t checksumPayload(const T& pod) {
|
||||
static_assert(std::is_trivially_copyable<T>::value, "checksum payload must be POD");
|
||||
return checksum8(reinterpret_cast<const uint8_t*>(&pod), sizeof(T));
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
inline uint8_t checksumExcludingLastByte(const T& pod) {
|
||||
static_assert(std::is_trivially_copyable<T>::value, "checksum payload must be POD");
|
||||
return checksum8(reinterpret_cast<const uint8_t*>(&pod), sizeof(T) - 1);
|
||||
}
|
||||
|
||||
} // namespace mrr
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
## esp32 firmware
|
||||
- hooked up to the roomba's UART on pins 16 and 17
|
||||
- pin 5 is connected to the roomba's BRC pin
|
||||
- pulse the BRC pin low for 1 second every minute to keep the roomba awake
|
||||
- connect to wifi
|
||||
- connect to the server
|
||||
- get a full frame of sensor group 100 from the roomba every 500ms
|
||||
- send it to the server over the sensor UDP stream
|
||||
- listen to the server's control UDP stream (per roomba) and do the following accordingly:
|
||||
- set wheel speeds
|
||||
- seek dock
|
||||
- enable OI
|
||||
- safe mode
|
||||
- full mode
|
||||
- play song
|
||||
- load song
|
||||
|
||||
## esp32 -> server communication
|
||||
- one UDP stream to the esp32 for controlling the roomba
|
||||
- might look like this:
|
||||
- left wheel speed
|
||||
- right wheel speed
|
||||
- OI mode
|
||||
- seek dock?
|
||||
- blasts out at a constant rate from the server for each roomba
|
||||
- the esp32 will listen, and follow the latest command that it sees
|
||||
- one UDP stream from the esp32 to the server for sending sensor data frames and other telemetry
|
||||
- one full frame of sensor data per datagram
|
||||
- send raw sensor data, the server will decode it
|
||||
- add other telemetry from the esp32, like signal strength, etc.
|
||||
- maybe use this stream as a sign that the esp32 is still running healthily?
|
||||
|
||||
## nodejs server
|
||||
- KISS
|
||||
- decode the sensor data from each roomba
|
||||
- can support multiple roombas connected from the ground up
|
||||
- keep it simple, worry about getting the esp32 firmware right.
|
||||
- but the server DOES have to exist for testing
|
||||
- IS the web server, hosts an entire static folder for the web UI
|
||||
|
||||
## server -> web UI communication
|
||||
- socket.io
|
||||
- don't do anything fancy with the socket.io setup
|
||||
- it works fine out of the box, we will optimize it later
|
||||
|
||||
## the web UI
|
||||
- KISS
|
||||
- plain old html. no styling even. just bare minimum for testing
|
||||
- what it needs to do:
|
||||
- allow user to select the roomba from a list
|
||||
- make the selected roomba drive with WASD
|
||||
- have buttons to set the OI mode, and tell the roomba to dock
|
||||
- show a plain list of the sensor data from the selected roomba
|
||||
|
||||
### general javascript programming guidelines (applies to the web UI too)
|
||||
- everything ES6
|
||||
- one entrypoint file in the web UI
|
||||
- everything modular
|
||||
- everything easy to read, understand, and work on
|
||||
- comment where you think is best to describe whats going on
|
||||
|
||||
## closing notes
|
||||
- keep the user input path (web UI -> server -> roomba) as light and responsive as possible. responsiveness is key for this.
|
||||
- responsiveness is the name of the game. The future of this program is teleoperation over the internet, with a camera on each roomba. keyboard inputs from the user must be near instant.
|
||||
- on the esp32 firmware side of things, sensor data is second priority to having a responsive control system
|
||||
- but sensor data DOES have to exist.
|
||||
- the future of this project will involve assigning one roomba to a user, make the server able to do that from the ground up.
|
||||
@@ -1,88 +0,0 @@
|
||||
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
|
||||
options snd_rpi_googlevoicehat_soundcard index=0
|
||||
|
||||
# Mix multiple playback clients in software with a fixed low-cost format.
|
||||
pcm.dmixer {
|
||||
type dmix
|
||||
ipc_key 1024
|
||||
ipc_perm 0666
|
||||
slave {
|
||||
pcm "hw:0,0"
|
||||
format S16_LE
|
||||
rate 16000
|
||||
channels 1
|
||||
period_time 0
|
||||
period_size 1024
|
||||
buffer_size 4096
|
||||
}
|
||||
}
|
||||
|
||||
# TTS volume control (used by default playback path).
|
||||
pcm.tts_softvol {
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "TTSMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Horn volume control.
|
||||
pcm.horn_softvol {
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "HornMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Forwarded audio volume control.
|
||||
pcm.forward_softvol {
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "ForwardMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Per-source playback PCMs.
|
||||
pcm.tts {
|
||||
type plug
|
||||
slave.pcm "tts_softvol"
|
||||
}
|
||||
|
||||
pcm.horn {
|
||||
type plug
|
||||
slave.pcm "horn_softvol"
|
||||
}
|
||||
|
||||
pcm.forward {
|
||||
type plug
|
||||
slave.pcm "forward_softvol"
|
||||
}
|
||||
|
||||
# Capture alias used by rover config defaults.
|
||||
pcm.rovermic {
|
||||
type plug
|
||||
slave.pcm "hw:0,0"
|
||||
}
|
||||
|
||||
# Defaults: TTS direct playback + raw capture on the HAT.
|
||||
pcm.!default {
|
||||
type asym
|
||||
playback.pcm "tts"
|
||||
capture.pcm "rovermic"
|
||||
}
|
||||
|
||||
ctl.!default {
|
||||
type hw
|
||||
card 0
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set +H
|
||||
|
||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Environment file ${ENV_FILE} missing; cannot start audio forward listener" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
|
||||
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
|
||||
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}"
|
||||
|
||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||
elif command -v ffmpeg >/dev/null 2>&1; then
|
||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
||||
else
|
||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v aplay >/dev/null 2>&1; then
|
||||
APLAY_BIN_PATH="$(command -v aplay)"
|
||||
else
|
||||
echo "aplay not found; install it via apt install alsa-utils." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LAST_FFMPEG_STATUS="unknown"
|
||||
LAST_APLAY_STATUS="unknown"
|
||||
|
||||
run_pipeline() {
|
||||
set +e
|
||||
"${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
-fflags nobuffer \
|
||||
-flags low_delay \
|
||||
-analyzeduration 200k \
|
||||
-probesize 32k \
|
||||
-i "${AUDIO_FORWARD_URL}" \
|
||||
-vn \
|
||||
-ac 1 \
|
||||
-ar 16000 \
|
||||
-f s16le \
|
||||
pipe:1 \
|
||||
| "${APLAY_BIN_PATH}" \
|
||||
-q \
|
||||
-D "${PLAYBACK_DEVICE}" \
|
||||
-t raw \
|
||||
-f S16_LE \
|
||||
-r 16000 \
|
||||
-c 1
|
||||
local rc=$?
|
||||
local -a statuses=("${PIPESTATUS[@]}")
|
||||
LAST_FFMPEG_STATUS="${statuses[0]:-unknown}"
|
||||
LAST_APLAY_STATUS="${statuses[1]:-unknown}"
|
||||
set -e
|
||||
return "${rc}"
|
||||
}
|
||||
|
||||
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
||||
|
||||
while true; do
|
||||
if run_pipeline; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Audio forward listener exited ffmpeg=${LAST_FFMPEG_STATUS:-unknown} aplay=${LAST_APLAY_STATUS:-unknown}, restarting in 2s..." >&2
|
||||
sleep 2
|
||||
done
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set +H
|
||||
|
||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Environment file ${ENV_FILE} missing; cannot publish audio" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
AUDIO_ENABLE="${AUDIO_ENABLE:-0}"
|
||||
if [[ "${AUDIO_ENABLE}" -ne 1 ]]; then
|
||||
echo "Audio capture disabled; skipping audio-only publisher" >&2
|
||||
exit 0
|
||||
fi
|
||||
: "${AUDIO_PUBLISH_URL:?AUDIO_PUBLISH_URL not set in ${ENV_FILE}}"
|
||||
|
||||
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:0,0}"
|
||||
AUDIO_RATE="${AUDIO_RATE:-48000}"
|
||||
AUDIO_CHANNELS="${AUDIO_CHANNELS:-2}"
|
||||
|
||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||
elif command -v ffmpeg >/dev/null 2>&1; then
|
||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
||||
else
|
||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_pipeline() {
|
||||
arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw \
|
||||
| "${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
-fflags nobuffer \
|
||||
-rtbufsize 0 \
|
||||
-thread_queue_size 4096 \
|
||||
-f s32le \
|
||||
-ar "${AUDIO_RATE}" \
|
||||
-ac "${AUDIO_CHANNELS}" \
|
||||
-i pipe:0 \
|
||||
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=25dB" \
|
||||
-c:a libopus \
|
||||
-b:a 24000 \
|
||||
-ar:a 16000 \
|
||||
-ac:a 1 \
|
||||
-application lowdelay \
|
||||
-frame_duration 20 \
|
||||
-compression_level 0 \
|
||||
-f mpegts \
|
||||
"${AUDIO_PUBLISH_URL}"
|
||||
}
|
||||
|
||||
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
||||
|
||||
while true; do
|
||||
if run_pipeline; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2
|
||||
sleep 2
|
||||
done
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Keep history expansion off so values containing "!" are safe.
|
||||
set +H
|
||||
|
||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load KEY=VALUE pairs from ENV_FILE WITHOUT evaluating shell metacharacters.
|
||||
# This makes URLs containing characters like '&' and '#!' safe without requiring quoting.
|
||||
load_env_file() {
|
||||
local content=""
|
||||
|
||||
if [[ -r "$ENV_FILE" ]]; then
|
||||
content="$(cat "$ENV_FILE")"
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
# Try to read via sudo without prompting (useful when the service runs as an unprivileged user)
|
||||
content="$(sudo -n cat "$ENV_FILE" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$content" ]]; then
|
||||
echo "Cannot read ${ENV_FILE} (permission denied). Run as a user that can read it, or allow sudo -n for cat." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local line key val
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Skip blank lines and full-line comments.
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
|
||||
# Support optional leading 'export '
|
||||
if [[ "$line" =~ ^[[:space:]]*export[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
elif [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
else
|
||||
# Ignore anything that isn't a simple assignment.
|
||||
continue
|
||||
fi
|
||||
|
||||
# Trim leading/trailing whitespace in value.
|
||||
val="${val#${val%%[![:space:]]*}}"
|
||||
val="${val%${val##*[![:space:]]}}"
|
||||
|
||||
# If value is wrapped in matching single or double quotes, unwrap.
|
||||
if [[ "$val" =~ ^\".*\"$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
elif [[ "$val" =~ ^\'.*\'$ ]]; then
|
||||
val="${val:1:${#val}-2}"
|
||||
fi
|
||||
|
||||
# Assign without evaluation.
|
||||
printf -v "$key" '%s' "$val"
|
||||
export "$key"
|
||||
done <<< "$content"
|
||||
}
|
||||
|
||||
load_env_file
|
||||
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
||||
|
||||
# Defaults tuned for OV5647: use 4:3 output and force the common 2x2 binned full-FOV mode.
|
||||
VIDEO_WIDTH="640"
|
||||
VIDEO_HEIGHT="480"
|
||||
VIDEO_FPS="30"
|
||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
||||
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
|
||||
|
||||
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
||||
FLIP_ARGS=(--rotation 180)
|
||||
|
||||
MODE_ARGS=()
|
||||
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
|
||||
MODE_ARGS=(--mode "${VIDEO_SENSOR_MODE}")
|
||||
fi
|
||||
|
||||
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
||||
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
||||
elif command -v rpicam-vid >/dev/null 2>&1; then
|
||||
LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)"
|
||||
elif command -v libcamera-vid >/dev/null 2>&1; then
|
||||
LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)"
|
||||
else
|
||||
echo "Neither rpicam-vid nor libcamera-vid found; install libcamera-apps." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||
elif command -v ffmpeg >/dev/null 2>&1; then
|
||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
||||
else
|
||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_pipeline() {
|
||||
"${LIBCAMERA_BIN_PATH}" \
|
||||
--inline \
|
||||
--timeout 0 \
|
||||
"${MODE_ARGS[@]}" \
|
||||
--width "${VIDEO_WIDTH}" \
|
||||
--height "${VIDEO_HEIGHT}" \
|
||||
"${FLIP_ARGS[@]}" \
|
||||
--framerate "${VIDEO_FPS}" \
|
||||
--bitrate "${VIDEO_BITRATE}" \
|
||||
--codec h264 \
|
||||
--profile baseline \
|
||||
--denoise auto \
|
||||
--nopreview \
|
||||
--metering centre \
|
||||
--ev 0.1 \
|
||||
--awb auto \
|
||||
--saturation 0.6 \
|
||||
--brightness 0 \
|
||||
--output - \
|
||||
| "${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
-fflags nobuffer \
|
||||
-use_wallclock_as_timestamps 1 \
|
||||
-f h264 \
|
||||
-i pipe:0 \
|
||||
-c:v copy \
|
||||
-an \
|
||||
-flush_packets 1 \
|
||||
-f mpegts \
|
||||
"${PUBLISH_URL}"
|
||||
}
|
||||
|
||||
while true; do
|
||||
if run_pipeline; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Video publisher exited, restarting in 2s..." >&2
|
||||
sleep 2
|
||||
done
|
||||
@@ -1,264 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Installer for the roverd agent on Raspberry Pi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BINARY_SRC="dist/roverd"
|
||||
CONFIG_SRC="pi/roverd/roverd.sample.yaml"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: sudo ./pi/install_roverd.sh [options]
|
||||
|
||||
Options:
|
||||
-b, --binary <path> Path to the roverd binary (default: dist/roverd)
|
||||
-c, --config <path> Source config to install if /etc/roverd.yaml is missing
|
||||
(default: pi/roverd/roverd.sample.yaml)
|
||||
-h, --help Show this help text
|
||||
|
||||
The script must run from the repository root and as root (sudo). It will:
|
||||
* create system users/groups if needed
|
||||
* install /usr/local/bin/roverd and /etc/roverd.yaml
|
||||
* install /usr/local/bin/video/audio helpers and systemd units
|
||||
* enable roverd.service and media publisher/listener services
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-b|--binary)
|
||||
BINARY_SRC="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-c|--config)
|
||||
CONFIG_SRC="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Please run as root (sudo)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$BINARY_SRC" ]]; then
|
||||
echo "Binary not found at $BINARY_SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_SRC" ]]; then
|
||||
echo "Config source not found at $CONFIG_SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_user() {
|
||||
local user="$1"
|
||||
local groups="${2:-}"
|
||||
if ! id -u "$user" >/dev/null 2>&1; then
|
||||
if [[ -n "$groups" ]]; then
|
||||
useradd -r -s /usr/sbin/nologin -G "$groups" "$user"
|
||||
else
|
||||
useradd -r -s /usr/sbin/nologin "$user"
|
||||
fi
|
||||
elif [[ -n "$groups" ]]; then
|
||||
usermod -a -G "$groups" "$user"
|
||||
fi
|
||||
}
|
||||
|
||||
log() {
|
||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
|
||||
}
|
||||
|
||||
if ! command -v rpicam-vid >/dev/null 2>&1 && ! command -v libcamera-vid >/dev/null 2>&1; then
|
||||
log "WARNING: neither rpicam-vid nor libcamera-vid found in PATH; install libcamera-apps."
|
||||
fi
|
||||
|
||||
install_video_deps() {
|
||||
if command -v ffmpeg >/dev/null 2>&1 && (command -v rpicam-vid >/dev/null 2>&1 || command -v libcamera-vid >/dev/null 2>&1); then
|
||||
log "Video dependencies already installed; skipping apt install"
|
||||
return
|
||||
fi
|
||||
log "Installing video dependencies (libcamera-apps, ffmpeg)..."
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends libcamera-apps ffmpeg
|
||||
}
|
||||
|
||||
find_boot_config() {
|
||||
if [[ -f /boot/firmware/config.txt ]]; then
|
||||
printf "/boot/firmware/config.txt"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f /boot/config.txt ]]; then
|
||||
printf "/boot/config.txt"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_pwm_overlay() {
|
||||
local boot_config
|
||||
if ! boot_config="$(find_boot_config)"; then
|
||||
log "WARNING: unable to locate /boot config.txt; please ensure dtoverlay=pwm-2chan is added manually for servo support"
|
||||
return
|
||||
fi
|
||||
if grep -Eq '^\s*dtoverlay=pwm(-2chan)?' "$boot_config"; then
|
||||
log "PWM overlay already present in $boot_config"
|
||||
return
|
||||
fi
|
||||
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
|
||||
cp "$boot_config" "$backup"
|
||||
{
|
||||
echo ""
|
||||
echo "# Added by roverd installer to expose PWM hardware for camera servo control on GPIO12/13 (leaves GPIO18/19 free for I2S)"
|
||||
echo "dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4"
|
||||
} >> "$boot_config"
|
||||
log "Enabled dtoverlay=pwm-2chan on GPIO12/13 in $boot_config (backup at $backup). Reboot required for changes to apply."
|
||||
}
|
||||
|
||||
ensure_user roverd "dialout,gpio,video,render,audio"
|
||||
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
|
||||
log "Installed roverd binary"
|
||||
|
||||
CONFIG_DEST="/etc/roverd.yaml"
|
||||
CONFIG_EXISTS=0
|
||||
if [[ -f "$CONFIG_DEST" ]]; then
|
||||
CONFIG_EXISTS=1
|
||||
log "Existing $CONFIG_DEST found; leaving it in place"
|
||||
else
|
||||
install -D -o roverd -g roverd -m 0640 "$CONFIG_SRC" "$CONFIG_DEST"
|
||||
log "Installed sample config to $CONFIG_DEST (edit before starting service)"
|
||||
fi
|
||||
|
||||
install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
|
||||
log "Installed roverd systemd unit"
|
||||
|
||||
install_video_deps
|
||||
ensure_pwm_overlay
|
||||
|
||||
# Enable Google AIY v1 sound card, ALSA defaults, and TTS engines
|
||||
install_audio_support() {
|
||||
local boot_config
|
||||
if ! boot_config="$(find_boot_config)"; then
|
||||
log "WARNING: unable to locate /boot config.txt; please enable googlevoicehat-soundcard overlay manually"
|
||||
else
|
||||
# Ensure onboard audio is disabled (prevents card index flapping)
|
||||
if grep -Eq '^\s*dtparam=audio=on\b' "$boot_config"; then
|
||||
log "Disabling onboard audio (dtparam=audio=on -> off) in $boot_config"
|
||||
sed -i 's/^\s*dtparam=audio=on\b/# roverd disabled onboard audio\ndtparam=audio=off/' "$boot_config"
|
||||
fi
|
||||
if ! grep -Eq '^\s*dtparam=audio=off\b' "$boot_config"; then
|
||||
log "Adding dtparam=audio=off to $boot_config"
|
||||
echo "dtparam=audio=off" >> "$boot_config"
|
||||
fi
|
||||
if ! grep -Eq '^\s*dtparam=i2s=on\b' "$boot_config"; then
|
||||
log "Adding dtparam=i2s=on to $boot_config"
|
||||
echo "dtparam=i2s=on" >> "$boot_config"
|
||||
fi
|
||||
if ! grep -Eq '^\s*dtoverlay=googlevoicehat-soundcard\b' "$boot_config"; then
|
||||
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
|
||||
cp "$boot_config" "$backup"
|
||||
{
|
||||
echo ""
|
||||
echo "# Added by roverd installer to enable Google AIY v1 sound card"
|
||||
echo "dtoverlay=googlevoicehat-soundcard"
|
||||
} >> "$boot_config"
|
||||
log "Enabled googlevoicehat-soundcard overlay in $boot_config (backup at $backup). Reboot required."
|
||||
else
|
||||
log "googlevoicehat-soundcard overlay already present in $boot_config"
|
||||
fi
|
||||
fi
|
||||
if [[ -f pi/asound.conf ]]; then
|
||||
install -m 0644 pi/asound.conf /etc/asound.conf
|
||||
log "Installed ALSA config to /etc/asound.conf"
|
||||
alsa_reload_notice=1
|
||||
else
|
||||
log "WARNING: pi/asound.conf missing; skipping ALSA config install"
|
||||
fi
|
||||
|
||||
if [[ "${alsa_reload_notice:-0}" -eq 1 ]]; then
|
||||
log "ALSA config updated; reboot recommended for overlay + audio changes"
|
||||
fi
|
||||
|
||||
log "Installing TTS/audio packages (flite, espeak)..."
|
||||
# check for flite and espeak before installing, and then install them if either is missing
|
||||
if command -v flite >/dev/null 2>&1 && command -v espeak >/dev/null 2>&1; then
|
||||
log "TTS packages flite and espeak already installed; skipping apt install"
|
||||
return
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends flite espeak
|
||||
}
|
||||
|
||||
# Install video publisher assets
|
||||
install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher
|
||||
log "Installed video-publisher helper"
|
||||
install -m 0644 pi/systemd/video-publisher.service /etc/systemd/system/video-publisher.service
|
||||
log "Installed video-publisher systemd unit"
|
||||
# Install audio-only publisher assets
|
||||
install -D -o root -g root -m 0755 pi/bin/audio-only-publisher.sh /usr/local/bin/audio-only-publisher
|
||||
install -m 0644 pi/systemd/audio-only-publisher.service /etc/systemd/system/audio-only-publisher.service
|
||||
log "Installed audio-only publisher helper + systemd unit"
|
||||
# Install audio-forward listener assets
|
||||
install -D -o root -g root -m 0755 pi/bin/audio-forward-listener.sh /usr/local/bin/audio-forward-listener
|
||||
install -m 0644 pi/systemd/audio-forward-listener.service /etc/systemd/system/audio-forward-listener.service
|
||||
log "Installed audio-forward listener helper + systemd unit"
|
||||
install -d -o roverd -g roverd /var/lib/roverd
|
||||
cat > /var/lib/roverd/video.env <<'ENV'
|
||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
AUDIO_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
VIDEO_BITRATE=2000000
|
||||
AUDIO_ENABLE=0
|
||||
AUDIO_DEVICE=hw:0,0
|
||||
AUDIO_PLAYBACK_DEVICE=forward
|
||||
AUDIO_RATE=48000
|
||||
AUDIO_CHANNELS=2
|
||||
ENV
|
||||
chown roverd:roverd /var/lib/roverd/video.env
|
||||
chmod 0640 /var/lib/roverd/video.env
|
||||
# Create persistent audio FIFO for capture -> publisher
|
||||
FIFO_PATH="/var/lib/roverd/audio.pcm"
|
||||
if [[ -p "$FIFO_PATH" ]]; then
|
||||
chown roverd:audio "$FIFO_PATH"
|
||||
chmod 0660 "$FIFO_PATH"
|
||||
else
|
||||
rm -f "$FIFO_PATH"
|
||||
mkfifo "$FIFO_PATH"
|
||||
chown roverd:audio "$FIFO_PATH"
|
||||
chmod 0660 "$FIFO_PATH"
|
||||
fi
|
||||
# Ensure ALSA config is in place for rovermic device
|
||||
install -m 0644 pi/asound.conf /etc/asound.conf
|
||||
log "Installed ALSA config (/etc/asound.conf)"
|
||||
|
||||
install_audio_support
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable roverd.service
|
||||
systemctl enable video-publisher.service
|
||||
systemctl enable audio-only-publisher.service
|
||||
systemctl enable audio-forward-listener.service
|
||||
if [[ $CONFIG_EXISTS -eq 1 ]]; then
|
||||
systemctl restart roverd.service
|
||||
systemctl restart video-publisher.service
|
||||
systemctl restart audio-only-publisher.service
|
||||
systemctl restart audio-forward-listener.service
|
||||
log "Restarted roverd + media publishers/listener"
|
||||
else
|
||||
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher audio-forward-listener"
|
||||
fi
|
||||
|
||||
log "Install complete"
|
||||
@@ -1,20 +0,0 @@
|
||||
BIN_DIR ?= ../../dist
|
||||
GOOS ?= linux
|
||||
GOARCH ?= arm
|
||||
GOARM ?= 6
|
||||
|
||||
.PHONY: build pi-build dummy clean
|
||||
|
||||
build:
|
||||
go build -o $(BIN_DIR)/roverd ./cmd/roverd
|
||||
|
||||
pi-build:
|
||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
|
||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/servoverifier ./cmd/servoverifier
|
||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/hornverifier ./cmd/hornverifier
|
||||
|
||||
dummy:
|
||||
GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd
|
||||
|
||||
clean:
|
||||
rm -f $(BIN_DIR)/roverd $(BIN_DIR)/servoverifier $(BIN_DIR)/hornverifier
|
||||
@@ -1,117 +0,0 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type AudioLevels struct {
|
||||
HornGain float64
|
||||
TTSGain float64
|
||||
ForwardGain float64
|
||||
}
|
||||
|
||||
func clampAudioGain(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 4 {
|
||||
return 4
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normalizeAudioLevels(v AudioLevels) AudioLevels {
|
||||
v.HornGain = clampAudioGain(v.HornGain)
|
||||
v.TTSGain = clampAudioGain(v.TTSGain)
|
||||
v.ForwardGain = clampAudioGain(v.ForwardGain)
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *WSClient) getAudioLevels() AudioLevels {
|
||||
c.audioMu.RLock()
|
||||
defer c.audioMu.RUnlock()
|
||||
return c.audioLevels
|
||||
}
|
||||
|
||||
func (c *WSClient) setAudioLevels(next AudioLevels) {
|
||||
normalized := normalizeAudioLevels(next)
|
||||
c.audioMu.Lock()
|
||||
c.audioLevels = normalized
|
||||
c.audioMu.Unlock()
|
||||
c.applyAudioLevelsToMixer(normalized)
|
||||
}
|
||||
|
||||
func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
levels := c.getAudioLevels()
|
||||
if payload.HornGain != nil {
|
||||
levels.HornGain = clampAudioGain(*payload.HornGain)
|
||||
}
|
||||
if payload.TTSGain != nil {
|
||||
levels.TTSGain = clampAudioGain(*payload.TTSGain)
|
||||
}
|
||||
if payload.ForwardGain != nil {
|
||||
levels.ForwardGain = clampAudioGain(*payload.ForwardGain)
|
||||
}
|
||||
c.setAudioLevels(levels)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WSClient) applyAudioLevelsToMixer(levels AudioLevels) {
|
||||
c.applyMixerGain("HornMaster", levels.HornGain)
|
||||
c.applyMixerGain("TTSMaster", levels.TTSGain)
|
||||
c.applyMixerGain("ForwardMaster", levels.ForwardGain)
|
||||
}
|
||||
|
||||
func (c *WSClient) applyMixerGain(control string, gain float64) {
|
||||
normalized := clampAudioGain(gain)
|
||||
if normalized <= 0 {
|
||||
if err := c.trySetMixerControl(control, "0%"); err != nil {
|
||||
c.log.Printf("audio-levels: amixer mute %s failed: %v", control, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Convert linear gain to dB, matching softvol max_dB=12.0 in /etc/asound.conf.
|
||||
db := 20.0 * math.Log10(normalized)
|
||||
if db > 12.0 {
|
||||
db = 12.0
|
||||
}
|
||||
if db < -60.0 {
|
||||
db = -60.0
|
||||
}
|
||||
|
||||
// amixer treats a leading "-" value as an option; set via percent to avoid getopt ambiguity.
|
||||
percent := int(math.Round((db + 60.0) / 72.0 * 100.0))
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
percentArg := fmt.Sprintf("%d%%", percent)
|
||||
if err := c.trySetMixerControl(control, percentArg); err != nil {
|
||||
c.log.Printf("audio-levels: amixer set %s=%s failed: %v", control, percentArg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) trySetMixerControl(control, value string) error {
|
||||
// Prefer the active ALSA default route; fall back to card index for compatibility.
|
||||
candidates := [][]string{
|
||||
{"-q", "-D", "default", "sset", control, value},
|
||||
{"-q", "-c", "0", "sset", control, value},
|
||||
}
|
||||
var lastErr error
|
||||
for _, args := range candidates {
|
||||
out, err := exec.Command("amixer", args...).CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("%w (%s)", err, string(out))
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
autoChargeTimeout = 5 * time.Second
|
||||
autoChargeCooldown = 0 * time.Minute
|
||||
sourceHomeBase = 1 << 1
|
||||
)
|
||||
|
||||
type AutoChargeController struct {
|
||||
adapter *SerialAdapter
|
||||
events chan<- RoverEvent
|
||||
logger *log.Logger
|
||||
timerStart time.Time
|
||||
cooldownUntil time.Time
|
||||
lastState byte
|
||||
lastSources byte
|
||||
}
|
||||
|
||||
func NewAutoChargeController(adapter *SerialAdapter, events chan<- RoverEvent, logger *log.Logger) *AutoChargeController {
|
||||
return &AutoChargeController{
|
||||
adapter: adapter,
|
||||
events: events,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AutoChargeController) Run(ctx context.Context, samples <-chan SensorSample) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case sample := <-samples:
|
||||
a.processSample(sample)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AutoChargeController) processSample(sample SensorSample) {
|
||||
now := time.Now()
|
||||
docked := sample.ChargeSources&sourceHomeBase != 0
|
||||
charging := isCharging(sample.ChargingState)
|
||||
|
||||
if !docked || charging {
|
||||
if !a.timerStart.IsZero() {
|
||||
a.emitEvent("autoCharge.timerCleared", map[string]any{
|
||||
"durationMs": time.Since(a.timerStart).Milliseconds(),
|
||||
})
|
||||
}
|
||||
a.timerStart = time.Time{}
|
||||
a.lastState = sample.ChargingState
|
||||
a.lastSources = sample.ChargeSources
|
||||
return
|
||||
}
|
||||
|
||||
// docked but not charging
|
||||
if a.cooldownUntil.After(now) {
|
||||
return
|
||||
}
|
||||
|
||||
if a.timerStart.IsZero() {
|
||||
a.timerStart = now
|
||||
a.emitEvent("autoCharge.timerStarted", map[string]any{
|
||||
"chargingState": sample.ChargingState,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if now.Sub(a.timerStart) >= autoChargeTimeout {
|
||||
if err := a.adapter.SeekDock(); err != nil {
|
||||
a.emitEvent("autoCharge.seekDockError", map[string]any{"error": err.Error()})
|
||||
} else {
|
||||
a.emitEvent("autoCharge.seekDockIssued", map[string]any{
|
||||
"waitingMs": autoChargeTimeout.Milliseconds(),
|
||||
})
|
||||
}
|
||||
a.timerStart = time.Time{}
|
||||
a.cooldownUntil = now.Add(autoChargeCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
func isCharging(state byte) bool {
|
||||
switch state {
|
||||
case 1, 2, 3, 4:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AutoChargeController) emitEvent(event string, data map[string]any) {
|
||||
if a.events == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case a.events <- RoverEvent{
|
||||
Type: "event",
|
||||
Event: event,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
Data: data,
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
gpiocdev "github.com/warthog618/go-gpiocdev"
|
||||
)
|
||||
|
||||
type BRCPulser struct {
|
||||
cfg BRCConfig
|
||||
logger *log.Logger
|
||||
line *gpiocdev.Line
|
||||
}
|
||||
|
||||
func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) {
|
||||
chip := cfg.GPIOChip
|
||||
if chip == "" {
|
||||
chip = "gpiochip0"
|
||||
}
|
||||
|
||||
line, err := gpiocdev.RequestLine(
|
||||
chip,
|
||||
cfg.GPIOPin,
|
||||
gpiocdev.AsOutput(1),
|
||||
gpiocdev.WithConsumer("roverd-brc"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &BRCPulser{cfg: cfg, logger: logger, line: line}, nil
|
||||
}
|
||||
|
||||
func (b *BRCPulser) Close() {
|
||||
if b.line != nil {
|
||||
_ = b.line.SetValue(1)
|
||||
b.line.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BRCPulser) Start(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(b.cfg.PulseEvery.Duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
b.pulseOnce()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *BRCPulser) pulseOnce() {
|
||||
if b.line == nil {
|
||||
return
|
||||
}
|
||||
if err := b.line.SetValue(0); err != nil {
|
||||
b.logger.Printf("brc pulse low: %v", err)
|
||||
return
|
||||
}
|
||||
time.Sleep(b.cfg.PulseWidth.Duration)
|
||||
if err := b.line.SetValue(1); err != nil {
|
||||
b.logger.Printf("brc pulse high: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
)
|
||||
|
||||
type BRCPulser struct{}
|
||||
|
||||
func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) {
|
||||
logger.Printf("[dummy] BRC configured on pin %d", cfg.GPIOPin)
|
||||
return &BRCPulser{}, nil
|
||||
}
|
||||
|
||||
func (b *BRCPulser) Close() {}
|
||||
|
||||
func (b *BRCPulser) Start(ctx context.Context) {}
|
||||
@@ -1,227 +0,0 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
rpio "github.com/stianeikeland/go-rpio/v4"
|
||||
)
|
||||
|
||||
type CameraServo struct {
|
||||
cfg CameraServoConfig
|
||||
logger *log.Logger
|
||||
pin rpio.Pin
|
||||
mu sync.Mutex
|
||||
currentAngle float64
|
||||
desiredAngle float64
|
||||
lastMove time.Time
|
||||
moving bool
|
||||
stopCh chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
const maxServoDegPerSec = 60.0
|
||||
const servoStepInterval = 20 * time.Millisecond
|
||||
const servoAngleEpsilon = 0.01
|
||||
|
||||
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, fmt.Errorf("camera servo disabled")
|
||||
}
|
||||
if err := rpio.Open(); err != nil {
|
||||
return nil, fmt.Errorf("open gpio: %w", err)
|
||||
}
|
||||
|
||||
pin := rpio.Pin(cfg.Pin)
|
||||
pin.Mode(rpio.Pwm)
|
||||
targetClock := cfg.FreqHz * cfg.CycleLen
|
||||
pin.Freq(targetClock)
|
||||
|
||||
servo := &CameraServo{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
pin: pin,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
|
||||
rpio.Close()
|
||||
return nil, err
|
||||
}
|
||||
logger.Printf("camera servo initialized on GPIO %d (%.1f..%.1f deg, %d..%d us, invert=%v)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle, cfg.MinPulseUs, cfg.MaxPulseUs, cfg.Invert)
|
||||
return servo, nil
|
||||
}
|
||||
|
||||
func (s *CameraServo) Close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
|
||||
rpio.Close()
|
||||
if s.stopCh != nil {
|
||||
close(s.stopCh)
|
||||
s.stopCh = nil
|
||||
}
|
||||
s.closed = true
|
||||
}
|
||||
|
||||
func (s *CameraServo) SetAngle(angle float64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.setAngleLocked(angle)
|
||||
}
|
||||
|
||||
func (s *CameraServo) setAngleLocked(angle float64) error {
|
||||
if s.closed {
|
||||
return fmt.Errorf("servo closed")
|
||||
}
|
||||
clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle)
|
||||
s.desiredAngle = clamped
|
||||
limited := s.rateLimitAngleLocked(clamped)
|
||||
s.applyPulseLocked(s.angleToPulse(limited))
|
||||
s.currentAngle = limited
|
||||
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
|
||||
s.startMoveLoopLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CameraServo) Nudge(delta float64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if delta == 0 {
|
||||
delta = s.cfg.NudgeDegrees
|
||||
}
|
||||
target := s.currentAngle + delta
|
||||
return s.setAngleLocked(target)
|
||||
}
|
||||
|
||||
func (s *CameraServo) SetPulseWidth(micros int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return fmt.Errorf("servo closed")
|
||||
}
|
||||
if !s.cfg.AllowRawPulse {
|
||||
return fmt.Errorf("raw pulse commands disabled")
|
||||
}
|
||||
if micros <= 0 {
|
||||
return fmt.Errorf("pulse width must be > 0")
|
||||
}
|
||||
clampedPulse := clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
|
||||
targetAngle := s.pulseToAngle(clampedPulse)
|
||||
s.desiredAngle = targetAngle
|
||||
limited := s.rateLimitAngleLocked(targetAngle)
|
||||
s.applyPulseLocked(s.angleToPulse(limited))
|
||||
s.currentAngle = limited
|
||||
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
|
||||
s.startMoveLoopLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CameraServo) CurrentAngle() float64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.currentAngle
|
||||
}
|
||||
|
||||
func (s *CameraServo) applyPulseLocked(micros int) {
|
||||
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
|
||||
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
|
||||
}
|
||||
|
||||
func (s *CameraServo) startMoveLoopLocked() {
|
||||
if s.moving || s.stopCh == nil {
|
||||
return
|
||||
}
|
||||
s.moving = true
|
||||
go func() {
|
||||
ticker := time.NewTicker(servoStepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.moving = false
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if math.Abs(s.currentAngle-s.desiredAngle) <= servoAngleEpsilon {
|
||||
s.moving = false
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
limited := s.rateLimitAngleLocked(s.desiredAngle)
|
||||
s.applyPulseLocked(s.angleToPulse(limited))
|
||||
s.currentAngle = limited
|
||||
s.mu.Unlock()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *CameraServo) rateLimitAngleLocked(target float64) float64 {
|
||||
now := time.Now()
|
||||
if s.lastMove.IsZero() {
|
||||
s.lastMove = now
|
||||
}
|
||||
elapsed := now.Sub(s.lastMove).Seconds()
|
||||
if elapsed <= 0 {
|
||||
s.lastMove = now
|
||||
return s.currentAngle
|
||||
}
|
||||
maxElapsed := servoStepInterval.Seconds()
|
||||
if elapsed > maxElapsed {
|
||||
elapsed = maxElapsed
|
||||
}
|
||||
maxDelta := maxServoDegPerSec * elapsed
|
||||
delta := target - s.currentAngle
|
||||
if math.Abs(delta) <= maxDelta {
|
||||
s.lastMove = now
|
||||
return target
|
||||
}
|
||||
if delta > 0 {
|
||||
target = s.currentAngle + maxDelta
|
||||
} else {
|
||||
target = s.currentAngle - maxDelta
|
||||
}
|
||||
s.lastMove = now
|
||||
return target
|
||||
}
|
||||
|
||||
func (s *CameraServo) angleToPulse(angle float64) int {
|
||||
totalRange := s.cfg.MaxAngle - s.cfg.MinAngle
|
||||
if totalRange == 0 {
|
||||
return s.cfg.MinPulseUs
|
||||
}
|
||||
norm := (angle - s.cfg.MinAngle) / totalRange
|
||||
norm = math.Max(0, math.Min(1, norm))
|
||||
if s.cfg.Invert {
|
||||
norm = 1 - norm
|
||||
}
|
||||
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
|
||||
return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange)))
|
||||
}
|
||||
|
||||
func (s *CameraServo) pulseToAngle(pulse int) float64 {
|
||||
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
|
||||
if pulseRange == 0 {
|
||||
return s.cfg.MinAngle
|
||||
}
|
||||
norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange)
|
||||
norm = math.Max(0, math.Min(1, norm))
|
||||
if s.cfg.Invert {
|
||||
norm = 1 - norm
|
||||
}
|
||||
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type CameraServo struct{}
|
||||
|
||||
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
|
||||
return nil, fmt.Errorf("camera servo not supported in dummy build")
|
||||
}
|
||||
|
||||
func (c *CameraServo) Close() {}
|
||||
|
||||
func (c *CameraServo) SetAngle(angle float64) error {
|
||||
return fmt.Errorf("camera servo disabled")
|
||||
}
|
||||
|
||||
func (c *CameraServo) Nudge(delta float64) error {
|
||||
return fmt.Errorf("camera servo disabled")
|
||||
}
|
||||
|
||||
func (c *CameraServo) SetPulseWidth(micros int) error {
|
||||
return fmt.Errorf("camera servo disabled")
|
||||
}
|
||||
|
||||
func (c *CameraServo) CurrentAngle() float64 {
|
||||
return 0
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package roverd
|
||||
|
||||
func clampInt(value, min, max int) int {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const twoPi = 2 * math.Pi
|
||||
|
||||
func main() {
|
||||
var (
|
||||
device = flag.String("device", "", "ALSA device (empty = default)")
|
||||
rate = flag.Int("rate", 48000, "Sample rate in Hz")
|
||||
channels = flag.Int("channels", 1, "Number of audio channels")
|
||||
duration = flag.Duration("duration", 2*time.Second, "Total horn duration")
|
||||
freqsRaw = flag.String("freqs", "440,550,660", "Comma-separated frequencies in Hz")
|
||||
volume = flag.Float64("volume", 0.25, "Output volume 0.0-1.0")
|
||||
attack = flag.Duration("attack", 20*time.Millisecond, "Attack time")
|
||||
release = flag.Duration("release", 60*time.Millisecond, "Release time")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *rate <= 0 {
|
||||
log.Fatalf("rate must be > 0 (got %d)", *rate)
|
||||
}
|
||||
if *channels <= 0 {
|
||||
log.Fatalf("channels must be > 0 (got %d)", *channels)
|
||||
}
|
||||
if *duration <= 0 {
|
||||
log.Fatalf("duration must be > 0 (got %s)", *duration)
|
||||
}
|
||||
if *volume <= 0 || *volume > 1.0 {
|
||||
log.Fatalf("volume must be within (0,1] (got %.3f)", *volume)
|
||||
}
|
||||
if *attack < 0 || *release < 0 {
|
||||
log.Fatalf("attack/release must be >= 0")
|
||||
}
|
||||
|
||||
freqs, err := parseFreqs(*freqsRaw)
|
||||
if err != nil {
|
||||
log.Fatalf("parse freqs: %v", err)
|
||||
}
|
||||
if len(freqs) == 0 {
|
||||
log.Fatal("no frequencies provided")
|
||||
}
|
||||
|
||||
if *attack+*release > *duration {
|
||||
log.Fatalf("attack+release must be <= duration (%s + %s > %s)", *attack, *release, *duration)
|
||||
}
|
||||
|
||||
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", *channels), "-r", fmt.Sprintf("%d", *rate), "-t", "raw"}
|
||||
if *device != "" {
|
||||
args = append(args, "-D", *device)
|
||||
}
|
||||
cmd := exec.Command("aplay", args...)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
log.Fatalf("aplay stdin: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatalf("start aplay: %v", err)
|
||||
}
|
||||
|
||||
writer := bufio.NewWriterSize(stdin, 32*1024)
|
||||
if err := synthChord(writer, freqs, *rate, *channels, *duration, *volume, *attack, *release); err != nil {
|
||||
_ = stdin.Close()
|
||||
_ = cmd.Wait()
|
||||
log.Fatalf("synth: %v", err)
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
_ = stdin.Close()
|
||||
_ = cmd.Wait()
|
||||
log.Fatalf("flush: %v", err)
|
||||
}
|
||||
if err := stdin.Close(); err != nil {
|
||||
_ = cmd.Wait()
|
||||
log.Fatalf("close stdin: %v", err)
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Fatalf("aplay failed: %v", err)
|
||||
}
|
||||
log.Print("Horn verification complete")
|
||||
}
|
||||
|
||||
func parseFreqs(raw string) ([]float64, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(trimmed, ",")
|
||||
freqs := make([]float64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseFloat(part, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid freq %q", part)
|
||||
}
|
||||
if value <= 0 {
|
||||
return nil, fmt.Errorf("freq must be > 0 (got %.3f)", value)
|
||||
}
|
||||
freqs = append(freqs, value)
|
||||
}
|
||||
return freqs, nil
|
||||
}
|
||||
|
||||
func synthChord(writer *bufio.Writer, freqs []float64, rate, channels int, duration time.Duration, volume float64, attack, release time.Duration) error {
|
||||
totalFrames := int(float64(rate) * duration.Seconds())
|
||||
if totalFrames <= 0 {
|
||||
return fmt.Errorf("duration too short")
|
||||
}
|
||||
|
||||
phase := make([]float64, len(freqs))
|
||||
increment := make([]float64, len(freqs))
|
||||
for i, f := range freqs {
|
||||
increment[i] = twoPi * f / float64(rate)
|
||||
}
|
||||
|
||||
attackFrames := int(float64(rate) * attack.Seconds())
|
||||
releaseFrames := int(float64(rate) * release.Seconds())
|
||||
steadyFrames := totalFrames - attackFrames - releaseFrames
|
||||
|
||||
framesPerChunk := 512
|
||||
buf := make([]byte, framesPerChunk*channels*2)
|
||||
sampleIndex := 0
|
||||
scale := volume / float64(len(freqs))
|
||||
|
||||
for framesLeft := totalFrames; framesLeft > 0; {
|
||||
framesNow := framesPerChunk
|
||||
if framesLeft < framesNow {
|
||||
framesNow = framesLeft
|
||||
}
|
||||
for i := 0; i < framesNow; i++ {
|
||||
env := envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames)
|
||||
sample := 0.0
|
||||
for j := range freqs {
|
||||
sample += sawFromPhase(phase[j])
|
||||
phase[j] += increment[j]
|
||||
if phase[j] > twoPi {
|
||||
phase[j] -= twoPi
|
||||
}
|
||||
}
|
||||
sample *= scale * env
|
||||
if sample > 1.0 {
|
||||
sample = 1.0
|
||||
} else if sample < -1.0 {
|
||||
sample = -1.0
|
||||
}
|
||||
intSample := int16(sample * math.MaxInt16)
|
||||
offset := i * channels * 2
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
|
||||
}
|
||||
sampleIndex++
|
||||
}
|
||||
if _, err := writer.Write(buf[:framesNow*channels*2]); err != nil {
|
||||
return err
|
||||
}
|
||||
framesLeft -= framesNow
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames int) float64 {
|
||||
if attackFrames > 0 && sampleIndex < attackFrames {
|
||||
return float64(sampleIndex) / float64(attackFrames)
|
||||
}
|
||||
if releaseFrames > 0 && sampleIndex >= attackFrames+steadyFrames {
|
||||
relIndex := sampleIndex - (attackFrames + steadyFrames)
|
||||
return float64(releaseFrames-relIndex) / float64(releaseFrames)
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
func sawFromPhase(phase float64) float64 {
|
||||
return 2.0*(phase/twoPi) - 1.0
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
roverd "multiroombarover/pi/roverd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var cfgPath string
|
||||
flag.StringVar(&cfgPath, "config", "/etc/roverd.yaml", "path to roverd configuration file")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := roverd.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
|
||||
log.Fatalf("prepare media env: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
logger := log.New(os.Stdout, "roverd: ", log.LstdFlags|log.Lmicroseconds|log.LUTC)
|
||||
|
||||
serialPort, err := roverd.OpenSerial(cfg.Serial)
|
||||
if err != nil {
|
||||
logger.Fatalf("open serial: %v", err)
|
||||
}
|
||||
defer serialPort.Close()
|
||||
|
||||
var pulser *roverd.BRCPulser
|
||||
if cfg.BRC.Enabled() {
|
||||
pulser, err = roverd.NewBRCPulser(cfg.BRC, logger)
|
||||
if err != nil {
|
||||
logger.Fatalf("init BRC pulser: %v", err)
|
||||
}
|
||||
defer pulser.Close()
|
||||
pulser.Start(ctx)
|
||||
}
|
||||
|
||||
sensorFrames := make(chan []byte, 8)
|
||||
sensorSamples := make(chan roverd.SensorSample, 8)
|
||||
eventStream := make(chan roverd.RoverEvent, 16)
|
||||
|
||||
streamer := roverd.NewSensorStreamer(serialPort, sensorFrames, sensorSamples, logger)
|
||||
go streamer.Run(ctx)
|
||||
|
||||
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
||||
|
||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
|
||||
if mediaSupervisor != nil {
|
||||
mediaSupervisor.Start(ctx)
|
||||
}
|
||||
|
||||
var cameraServo *roverd.CameraServo
|
||||
if cfg.CameraServo.Enabled {
|
||||
cameraServo, err = roverd.NewCameraServo(cfg.CameraServo, logger)
|
||||
if err != nil {
|
||||
logger.Fatalf("init camera servo: %v", err)
|
||||
}
|
||||
defer cameraServo.Close()
|
||||
}
|
||||
|
||||
var nightVision *roverd.NightVisionLight
|
||||
if cfg.NightVision.Enabled {
|
||||
nightVision, err = roverd.NewNightVisionLight(cfg.NightVision, logger)
|
||||
if err != nil {
|
||||
logger.Fatalf("init night vision: %v", err)
|
||||
}
|
||||
defer nightVision.Close()
|
||||
}
|
||||
|
||||
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
||||
go autoCharge.Run(ctx, sensorSamples)
|
||||
|
||||
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
|
||||
|
||||
retryDelay := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := client.Run(ctx); err != nil {
|
||||
logger.Printf("websocket loop ended: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
|
||||
if retryDelay < 30*time.Second {
|
||||
retryDelay *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
rpio "github.com/stianeikeland/go-rpio/v4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
pinNum = flag.Int("pin", 19, "BCM pin connected to the servo signal line")
|
||||
freqHz = flag.Int("freq", 50, "Servo PWM frequency in Hz")
|
||||
cycleLen = flag.Int("cycle", 20000, "PWM cycle length (counts per period)")
|
||||
minPulse = flag.Int("min", 900, "Minimum pulse width in microseconds")
|
||||
maxPulse = flag.Int("max", 2100, "Maximum pulse width in microseconds")
|
||||
stepPulse = flag.Int("step", 100, "Pulse width increment in microseconds when sweeping")
|
||||
sweeps = flag.Int("sweeps", 2, "How many full min→max→min sweeps to perform")
|
||||
pause = flag.Duration("pause", 150*time.Millisecond, "Delay between pulse adjustments")
|
||||
holdPulse = flag.Int("hold", 0, "Pulse width to hold before exiting (0 = midpoint of min/max)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *freqHz <= 0 || *cycleLen <= 0 {
|
||||
log.Fatalf("invalid freq (%d) or cycle (%d)", *freqHz, *cycleLen)
|
||||
}
|
||||
if *minPulse <= 0 || *maxPulse <= 0 || *minPulse >= *maxPulse {
|
||||
log.Fatalf("invalid min/max pulses (%d/%d)", *minPulse, *maxPulse)
|
||||
}
|
||||
if *stepPulse <= 0 {
|
||||
log.Fatalf("step must be > 0 (got %d)", *stepPulse)
|
||||
}
|
||||
if *pause <= 0 {
|
||||
log.Fatalf("pause must be > 0 (got %s)", pause)
|
||||
}
|
||||
if *sweeps < 0 {
|
||||
log.Fatalf("sweeps must be >= 0 (got %d)", *sweeps)
|
||||
}
|
||||
|
||||
if err := rpio.Open(); err != nil {
|
||||
log.Fatalf("open gpio: %v", err)
|
||||
}
|
||||
defer rpio.Close()
|
||||
|
||||
pin := rpio.Pin(*pinNum)
|
||||
pin.Mode(rpio.Pwm)
|
||||
|
||||
targetClock := *freqHz * *cycleLen
|
||||
pin.Freq(targetClock)
|
||||
log.Printf("Configured PWM pin %d at %d Hz (clock=%d Hz, cycle=%d)", *pinNum, *freqHz, targetClock, *cycleLen)
|
||||
|
||||
setPulse := func(us int) {
|
||||
clamped := clamp(us, *minPulse, *maxPulse)
|
||||
pin.DutyCycle(uint32(clamped), uint32(*cycleLen))
|
||||
log.Printf("pulse -> %dµs", clamped)
|
||||
}
|
||||
|
||||
mid := (*minPulse + *maxPulse) / 2
|
||||
setPulse(mid)
|
||||
|
||||
runSweep := func() {
|
||||
for pulse := *minPulse; pulse <= *maxPulse; pulse += *stepPulse {
|
||||
setPulse(pulse)
|
||||
time.Sleep(*pause)
|
||||
}
|
||||
for pulse := *maxPulse - *stepPulse; pulse >= *minPulse; pulse -= *stepPulse {
|
||||
setPulse(pulse)
|
||||
time.Sleep(*pause)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < *sweeps; i++ {
|
||||
log.Printf("Sweep %d/%d", i+1, *sweeps)
|
||||
runSweep()
|
||||
}
|
||||
|
||||
finalPulse := *holdPulse
|
||||
if finalPulse <= 0 {
|
||||
finalPulse = mid
|
||||
}
|
||||
setPulse(finalPulse)
|
||||
log.Printf("Holding at %dµs", clamp(finalPulse, *minPulse, *maxPulse))
|
||||
log.Print("Servo verification complete")
|
||||
}
|
||||
|
||||
func clamp(value, min, max int) int {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package roverd
|
||||
|
||||
type helloMessage struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Battery BatteryConfig `json:"battery"`
|
||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
||||
Media MediaConfig `json:"media"`
|
||||
CameraServo CameraServoConfig `json:"cameraServo"`
|
||||
Audio AudioConfig `json:"audio"`
|
||||
Horn HornConfig `json:"horn"`
|
||||
NightVision NightVisionConfig `json:"nightVision"`
|
||||
Private PrivateConfig `json:"private"`
|
||||
}
|
||||
|
||||
type sensorMessage struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type inboundMessage struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
DriveDirect *driveDirectPayload `json:"driveDirect,omitempty"`
|
||||
MotorPWM *motorPWMPayload `json:"motorPwm,omitempty"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
|
||||
Media *mediaCommand `json:"media,omitempty"`
|
||||
Servo *servoPayload `json:"servo,omitempty"`
|
||||
TTS *ttsPayload `json:"tts,omitempty"`
|
||||
Horn *hornPayload `json:"horn,omitempty"`
|
||||
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
|
||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||
Song *songPayload `json:"song,omitempty"`
|
||||
Reboot *rebootPayload `json:"reboot,omitempty"`
|
||||
}
|
||||
|
||||
type driveDirectPayload struct {
|
||||
Left int `json:"left"`
|
||||
Right int `json:"right"`
|
||||
}
|
||||
|
||||
type motorPWMPayload struct {
|
||||
Main int `json:"main"`
|
||||
Side int `json:"side"`
|
||||
Vacuum int `json:"vacuum"`
|
||||
}
|
||||
|
||||
type sensorStreamPayload struct {
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
|
||||
type mediaCommand struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type servoPayload struct {
|
||||
Angle *float64 `json:"angle,omitempty"`
|
||||
Nudge *float64 `json:"nudge,omitempty"`
|
||||
PulseUs *int `json:"pulseUs,omitempty"`
|
||||
}
|
||||
|
||||
type ttsPayload struct {
|
||||
Text string `json:"text"`
|
||||
Engine string `json:"engine,omitempty"`
|
||||
Voice string `json:"voice,omitempty"`
|
||||
Pitch int `json:"pitch,omitempty"`
|
||||
Speak bool `json:"speak,omitempty"`
|
||||
}
|
||||
|
||||
type hornPayload struct {
|
||||
Action string `json:"action"`
|
||||
Waveform string `json:"waveform,omitempty"`
|
||||
Freqs []float64 `json:"freqs,omitempty"`
|
||||
}
|
||||
|
||||
type audioLevelsPayload struct {
|
||||
HornGain *float64 `json:"hornGain,omitempty"`
|
||||
TTSGain *float64 `json:"ttsGain,omitempty"`
|
||||
ForwardGain *float64 `json:"forwardGain,omitempty"`
|
||||
}
|
||||
|
||||
type nightVisionPayload struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type songPayload struct {
|
||||
Slot *int `json:"slot,omitempty"`
|
||||
Notes []songNote `json:"notes"`
|
||||
Loop bool `json:"loop,omitempty"`
|
||||
}
|
||||
|
||||
type songNote struct {
|
||||
Note int `json:"note"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
type rebootPayload struct {
|
||||
DelayMs int `json:"delayMs,omitempty"`
|
||||
}
|
||||
|
||||
type ackMessage struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type SerialConfig struct {
|
||||
Device string `yaml:"device"`
|
||||
Baud int `yaml:"baud"`
|
||||
}
|
||||
|
||||
type Duration struct {
|
||||
time.Duration
|
||||
}
|
||||
|
||||
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
|
||||
var raw string
|
||||
if err := value.Decode(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Duration = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Duration) MarshalYAML() (interface{}, error) {
|
||||
return d.Duration.String(), nil
|
||||
}
|
||||
|
||||
type BRCConfig struct {
|
||||
GPIOPin int `yaml:"gpioPin"`
|
||||
GPIOChip string `yaml:"gpioChip"`
|
||||
PulseEvery Duration `yaml:"pulseEvery"`
|
||||
PulseWidth Duration `yaml:"pulseWidth"`
|
||||
}
|
||||
|
||||
func (b BRCConfig) Enabled() bool {
|
||||
return b.GPIOPin >= 0
|
||||
}
|
||||
|
||||
type BatteryConfig struct {
|
||||
Full int `yaml:"full"`
|
||||
Warn int `yaml:"warn"`
|
||||
Urgent int `yaml:"urgent"`
|
||||
}
|
||||
|
||||
type AudioConfig struct {
|
||||
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
|
||||
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
|
||||
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,omitempty"`
|
||||
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
|
||||
Channels int `yaml:"channels" json:"channels,omitempty"`
|
||||
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
|
||||
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
|
||||
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
|
||||
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
|
||||
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
|
||||
}
|
||||
|
||||
type HornConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Volume float64 `yaml:"volume" json:"-"`
|
||||
SampleRate int `yaml:"sampleRate" json:"-"`
|
||||
Channels int `yaml:"channels" json:"-"`
|
||||
Device string `yaml:"device" json:"-"`
|
||||
SineGain float64 `yaml:"sineGain" json:"-"`
|
||||
SawGain float64 `yaml:"sawGain" json:"-"`
|
||||
MaxDuration Duration `yaml:"maxDuration" json:"-"`
|
||||
}
|
||||
|
||||
type MediaConfig struct {
|
||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
||||
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
|
||||
PublishPort int `yaml:"publishPort" json:"-"`
|
||||
Manage bool `yaml:"manage"`
|
||||
ManageAudio bool `yaml:"manageAudio"`
|
||||
Service string `yaml:"service"`
|
||||
AudioService string `yaml:"audioService"`
|
||||
HealthURL string `yaml:"healthUrl"`
|
||||
HealthInterval Duration `yaml:"healthInterval"`
|
||||
VideoWidth int `yaml:"videoWidth" json:"-"`
|
||||
VideoHeight int `yaml:"videoHeight" json:"-"`
|
||||
VideoFPS int `yaml:"videoFps" json:"-"`
|
||||
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
||||
}
|
||||
|
||||
type CameraServoConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Pin int `yaml:"pin" json:"pin"`
|
||||
FreqHz int `yaml:"freqHz" json:"freqHz"`
|
||||
CycleLen int `yaml:"cycleLen" json:"cycleLen"`
|
||||
MinPulseUs int `yaml:"minPulseUs" json:"minPulseUs"`
|
||||
MaxPulseUs int `yaml:"maxPulseUs" json:"maxPulseUs"`
|
||||
MinAngle float64 `yaml:"minAngle" json:"minAngle"`
|
||||
MaxAngle float64 `yaml:"maxAngle" json:"maxAngle"`
|
||||
HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"`
|
||||
NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"`
|
||||
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
|
||||
Invert bool `yaml:"invert" json:"invert"`
|
||||
}
|
||||
|
||||
type NightVisionConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
|
||||
GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
|
||||
InitialOn bool `yaml:"initialOn" json:"initialOn"`
|
||||
}
|
||||
|
||||
type AutoSideBrushConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Speed int `yaml:"speed"`
|
||||
}
|
||||
|
||||
type PrivateConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Safety PrivateSafetyConfig `yaml:"safety" json:"safety"`
|
||||
}
|
||||
|
||||
type PrivateSafetyConfig struct {
|
||||
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
|
||||
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
|
||||
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
|
||||
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
|
||||
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
|
||||
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
|
||||
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
|
||||
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
|
||||
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
|
||||
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
|
||||
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Name string `yaml:"name"`
|
||||
Color string `yaml:"color" json:"color,omitempty"`
|
||||
ServerURL string `yaml:"serverUrl"`
|
||||
Serial SerialConfig `yaml:"serial"`
|
||||
BRC BRCConfig `yaml:"brc"`
|
||||
Battery BatteryConfig `yaml:"battery"`
|
||||
MaxWheelMMs int `yaml:"maxWheelSpeed"`
|
||||
Media MediaConfig `yaml:"media"`
|
||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||
Audio AudioConfig `yaml:"audio"`
|
||||
Horn HornConfig `yaml:"horn"`
|
||||
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
||||
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
|
||||
Private PrivateConfig `yaml:"private" json:"private"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := Config{
|
||||
MaxWheelMMs: 500,
|
||||
BRC: BRCConfig{
|
||||
GPIOPin: 4,
|
||||
GPIOChip: "gpiochip0",
|
||||
PulseEvery: Duration{
|
||||
Duration: time.Minute,
|
||||
},
|
||||
PulseWidth: Duration{
|
||||
Duration: time.Second,
|
||||
},
|
||||
},
|
||||
Media: MediaConfig{
|
||||
PublishPort: 9000,
|
||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||
VideoBitrate: 2000000,
|
||||
},
|
||||
CameraServo: CameraServoConfig{
|
||||
Pin: 12,
|
||||
FreqHz: 50,
|
||||
CycleLen: 20000,
|
||||
MinPulseUs: 900,
|
||||
MaxPulseUs: 2100,
|
||||
MinAngle: -15,
|
||||
MaxAngle: 30,
|
||||
HomeAngle: 0,
|
||||
NudgeDegrees: 2,
|
||||
},
|
||||
Audio: AudioConfig{
|
||||
CaptureEnabled: false,
|
||||
CaptureDevice: "rovermic",
|
||||
PlaybackDevice: "forward",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
Bitrate: 24000,
|
||||
TTSEnabled: false,
|
||||
DefaultEngine: "flite",
|
||||
DefaultVoice: "rms",
|
||||
DefaultPitch: 50,
|
||||
},
|
||||
Horn: HornConfig{
|
||||
Enabled: false,
|
||||
Volume: 0.25,
|
||||
SampleRate: 48000,
|
||||
Channels: 1,
|
||||
SineGain: 1.0,
|
||||
SawGain: 0.7,
|
||||
MaxDuration: Duration{Duration: 10000 * time.Millisecond},
|
||||
},
|
||||
NightVision: NightVisionConfig{
|
||||
Enabled: true,
|
||||
GPIOPin: 22,
|
||||
GPIOChip: "gpiochip0",
|
||||
InitialOn: true,
|
||||
},
|
||||
AutoSideBrush: AutoSideBrushConfig{
|
||||
Enabled: true,
|
||||
Speed: 20,
|
||||
},
|
||||
Private: PrivateConfig{
|
||||
Enabled: false,
|
||||
Safety: PrivateSafetyConfig{
|
||||
SpeedLimitEnabled: false,
|
||||
SpeedLimitMaxWheelMMs: 250,
|
||||
HardOvercurrentEnabled: false,
|
||||
OvercurrentStopMs: 300,
|
||||
HardBumpEnabled: false,
|
||||
BumpBackoffSpeed: 250,
|
||||
BumpBackoffMs: 350,
|
||||
CliffEnabled: false,
|
||||
CliffBackoffSpeed: 250,
|
||||
CliffBackoffMs: 500,
|
||||
TriggerCooldownMs: 800,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
return nil, errors.New("missing name")
|
||||
}
|
||||
normalizedColor, err := normalizeHexColor(cfg.Color)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Color = normalizedColor
|
||||
if cfg.ServerURL == "" {
|
||||
return nil, errors.New("missing serverUrl")
|
||||
}
|
||||
if cfg.Serial.Device == "" || cfg.Serial.Baud == 0 {
|
||||
return nil, errors.New("serial device/baud required")
|
||||
}
|
||||
if cfg.Battery.Full == 0 {
|
||||
return nil, errors.New("battery thresholds required")
|
||||
}
|
||||
if cfg.MaxWheelMMs <= 0 || cfg.MaxWheelMMs > 500 {
|
||||
return nil, fmt.Errorf("maxWheelSpeed must be 1-500, got %d", cfg.MaxWheelMMs)
|
||||
}
|
||||
if cfg.BRC.GPIOChip == "" {
|
||||
cfg.BRC.GPIOChip = "gpiochip0"
|
||||
}
|
||||
if cfg.Media.Manage && cfg.Media.Service == "" {
|
||||
return nil, errors.New("media.manage requires media.service")
|
||||
}
|
||||
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
||||
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||
}
|
||||
if cfg.Media.VideoBitrate <= 0 {
|
||||
cfg.Media.VideoBitrate = 3000000
|
||||
}
|
||||
if cfg.Media.PublishPort <= 0 {
|
||||
cfg.Media.PublishPort = 9000
|
||||
}
|
||||
if cfg.Media.PublishURL == "" {
|
||||
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name, cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive publishUrl: %w", err)
|
||||
}
|
||||
cfg.Media.PublishURL = derived
|
||||
}
|
||||
if cfg.Media.AudioPublishURL == "" {
|
||||
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name+"-audio", cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive audioPublishUrl: %w", err)
|
||||
}
|
||||
cfg.Media.AudioPublishURL = derived
|
||||
}
|
||||
if cfg.Media.AudioForwardURL == "" {
|
||||
derived, err := deriveReadURL(cfg.ServerURL, cfg.Name+"-fwd", cfg.Media.PublishPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive audioForwardUrl: %w", err)
|
||||
}
|
||||
cfg.Media.AudioForwardURL = derived
|
||||
}
|
||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
||||
}
|
||||
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
|
||||
return nil, fmt.Errorf("nightVision: %w", err)
|
||||
}
|
||||
validateAudioConfig(&cfg.Audio)
|
||||
validateHornConfig(&cfg.Horn)
|
||||
validateAutoSideBrushConfig(&cfg.AutoSideBrush)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func validateServoConfig(cfg *CameraServoConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if cfg.Pin <= 0 {
|
||||
return errors.New("pin must be > 0")
|
||||
}
|
||||
if cfg.FreqHz <= 0 {
|
||||
return errors.New("freqHz must be > 0")
|
||||
}
|
||||
if cfg.CycleLen <= 0 {
|
||||
return errors.New("cycleLen must be > 0")
|
||||
}
|
||||
if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 {
|
||||
return errors.New("minPulseUs/maxPulseUs invalid")
|
||||
}
|
||||
if cfg.MinPulseUs == cfg.MaxPulseUs {
|
||||
return errors.New("minPulseUs/maxPulseUs cannot be equal")
|
||||
}
|
||||
if cfg.MinPulseUs > cfg.MaxPulseUs {
|
||||
cfg.MinPulseUs, cfg.MaxPulseUs = cfg.MaxPulseUs, cfg.MinPulseUs
|
||||
cfg.Invert = !cfg.Invert
|
||||
}
|
||||
if cfg.MinAngle >= cfg.MaxAngle {
|
||||
return errors.New("minAngle must be less than maxAngle")
|
||||
}
|
||||
cfg.HomeAngle = clampFloat(cfg.HomeAngle, cfg.MinAngle, cfg.MaxAngle)
|
||||
if cfg.NudgeDegrees <= 0 {
|
||||
cfg.NudgeDegrees = 2
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampFloat(value, min, max float64) float64 {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validateAudioConfig(cfg *AudioConfig) {
|
||||
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
|
||||
cfg.CaptureDevice = "hw:0,0"
|
||||
}
|
||||
if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
|
||||
cfg.PlaybackDevice = "forward"
|
||||
}
|
||||
if cfg.SampleRate <= 0 {
|
||||
cfg.SampleRate = 48000
|
||||
}
|
||||
if cfg.Channels <= 0 {
|
||||
cfg.Channels = 2
|
||||
}
|
||||
if cfg.Bitrate <= 0 {
|
||||
cfg.Bitrate = 64000
|
||||
}
|
||||
if cfg.DefaultEngine == "" {
|
||||
cfg.DefaultEngine = "flite"
|
||||
}
|
||||
if cfg.DefaultVoice == "" {
|
||||
cfg.DefaultVoice = "rms"
|
||||
}
|
||||
if cfg.DefaultPitch <= 0 {
|
||||
cfg.DefaultPitch = 50
|
||||
}
|
||||
}
|
||||
|
||||
func validateHornConfig(cfg *HornConfig) {
|
||||
if cfg.Volume <= 0 {
|
||||
cfg.Volume = 0.25
|
||||
}
|
||||
if cfg.Volume > 1 {
|
||||
cfg.Volume = 1
|
||||
}
|
||||
if cfg.SampleRate <= 0 {
|
||||
cfg.SampleRate = 48000
|
||||
}
|
||||
if cfg.Channels <= 0 {
|
||||
cfg.Channels = 1
|
||||
}
|
||||
if cfg.SineGain <= 0 {
|
||||
cfg.SineGain = 1.0
|
||||
}
|
||||
if cfg.SawGain <= 0 {
|
||||
cfg.SawGain = 0.7
|
||||
}
|
||||
if cfg.MaxDuration.Duration <= 0 {
|
||||
cfg.MaxDuration = Duration{Duration: 1200 * time.Millisecond}
|
||||
}
|
||||
}
|
||||
|
||||
func validateNightVisionConfig(cfg *NightVisionConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if cfg.GPIOPin <= 0 {
|
||||
return errors.New("gpioPin must be > 0")
|
||||
}
|
||||
if cfg.GPIOChip == "" {
|
||||
cfg.GPIOChip = "gpiochip0"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
|
||||
if cfg.Speed == 0 {
|
||||
return
|
||||
}
|
||||
cfg.Speed = clampInt(cfg.Speed, -127, 127)
|
||||
}
|
||||
|
||||
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
||||
return deriveSRTURL(serverURL, streamName, port, "publish")
|
||||
}
|
||||
|
||||
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
|
||||
return deriveSRTURL(serverURL, streamName, port, "request")
|
||||
}
|
||||
|
||||
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
|
||||
if streamName == "" {
|
||||
return "", errors.New("missing stream name for publishUrl")
|
||||
}
|
||||
if mode == "" {
|
||||
mode = "publish"
|
||||
}
|
||||
parsed, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return "", errors.New("serverUrl missing host")
|
||||
}
|
||||
if port <= 0 {
|
||||
port = 9000
|
||||
}
|
||||
escaped := url.PathEscape(streamName)
|
||||
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
|
||||
}
|
||||
|
||||
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||
|
||||
func normalizeHexColor(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !hexColorRe.MatchString(trimmed) {
|
||||
return "", fmt.Errorf("color must be #RRGGBB, got %q", raw)
|
||||
}
|
||||
return strings.ToUpper(trimmed), nil
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package roverd
|
||||
|
||||
type RoverEvent struct {
|
||||
Type string `json:"type"`
|
||||
Event string `json:"event"`
|
||||
Ts int64 `json:"ts"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
module multiroombarover/pi/roverd
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/stianeikeland/go-rpio/v4 v4.6.0
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||
github.com/warthog618/go-gpiocdev v0.9.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.38.0 // indirect
|
||||
@@ -1,24 +0,0 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stianeikeland/go-rpio/v4 v4.6.0 h1:eAJgtw3jTtvn/CqwbC82ntcS+dtzUTgo5qlZKe677EY=
|
||||
github.com/stianeikeland/go-rpio/v4 v4.6.0/go.mod h1:A3GvHxC1Om5zaId+HqB3HKqx4K/AqeckxB7qRjxMK7o=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
||||
github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgXYbmhuCI=
|
||||
github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8=
|
||||
github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
|
||||
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
@@ -1,270 +0,0 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
hornAttack = 20 * time.Millisecond
|
||||
hornRelease = 60 * time.Millisecond
|
||||
)
|
||||
|
||||
type HornSynth struct {
|
||||
cfg HornConfig
|
||||
log *log.Logger
|
||||
gain float64
|
||||
|
||||
mu sync.Mutex
|
||||
stop chan struct{}
|
||||
active bool
|
||||
proc *exec.Cmd
|
||||
}
|
||||
|
||||
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
|
||||
return &HornSynth{
|
||||
cfg: cfg,
|
||||
log: logger,
|
||||
gain: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HornSynth) SetGlobalGain(gain float64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.gain = clampAudioGain(gain)
|
||||
}
|
||||
|
||||
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
|
||||
if payload == nil {
|
||||
return fmt.Errorf("horn payload required")
|
||||
}
|
||||
action := strings.ToLower(strings.TrimSpace(payload.Action))
|
||||
switch action {
|
||||
case "start", "on", "honk":
|
||||
waveform := strings.ToLower(strings.TrimSpace(payload.Waveform))
|
||||
if waveform != "sine" && waveform != "saw" {
|
||||
waveform = "saw"
|
||||
}
|
||||
freqs := sanitizeHornFreqs(payload.Freqs)
|
||||
if len(freqs) == 0 {
|
||||
h.Stop()
|
||||
return nil
|
||||
}
|
||||
return h.Start(waveform, freqs)
|
||||
case "stop", "off":
|
||||
h.Stop()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported horn action: %s", payload.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HornSynth) Start(waveform string, freqs []float64) error {
|
||||
h.mu.Lock()
|
||||
if h.active {
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
h.stop = stop
|
||||
h.active = true
|
||||
h.mu.Unlock()
|
||||
|
||||
go h.run(waveform, freqs, stop)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HornSynth) Stop() {
|
||||
h.mu.Lock()
|
||||
if !h.active {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
stop := h.stop
|
||||
proc := h.proc
|
||||
h.stop = nil
|
||||
h.proc = nil
|
||||
h.active = false
|
||||
h.mu.Unlock()
|
||||
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
if proc != nil && proc.Process != nil {
|
||||
_ = proc.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{}) {
|
||||
rate := h.cfg.SampleRate
|
||||
if rate <= 0 {
|
||||
rate = 48000
|
||||
}
|
||||
channels := h.cfg.Channels
|
||||
if channels <= 0 {
|
||||
channels = 1
|
||||
}
|
||||
volume := h.cfg.Volume
|
||||
if volume <= 0 {
|
||||
volume = 0.25
|
||||
}
|
||||
if volume > 1 {
|
||||
volume = 1
|
||||
}
|
||||
h.mu.Lock()
|
||||
gain := h.gain
|
||||
h.mu.Unlock()
|
||||
volume *= gain
|
||||
|
||||
device := strings.TrimSpace(h.cfg.Device)
|
||||
if device == "" {
|
||||
device = "horn"
|
||||
}
|
||||
args := []string{"-q", "-D", device, "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
|
||||
cmd := exec.Command("aplay", args...)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
h.log.Printf("horn: aplay stdin failed: %v", err)
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
h.log.Printf("horn: aplay start failed: %v", err)
|
||||
_ = stdin.Close()
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if h.active {
|
||||
h.proc = cmd
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
writer := bufio.NewWriterSize(stdin, 32*1024)
|
||||
maxFrames := 0
|
||||
if h.cfg.MaxDuration.Duration > 0 {
|
||||
maxFrames = int(float64(rate) * h.cfg.MaxDuration.Duration.Seconds())
|
||||
}
|
||||
if err := h.synthLoop(writer, waveform, freqs, rate, channels, volume, maxFrames, stop); err != nil {
|
||||
h.log.Printf("horn: synth failed: %v", err)
|
||||
}
|
||||
_ = writer.Flush()
|
||||
_ = stdin.Close()
|
||||
if err := cmd.Wait(); err != nil {
|
||||
h.log.Printf("horn: aplay exit: %v", err)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if h.proc == cmd {
|
||||
h.proc = nil
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *HornSynth) synthLoop(writer *bufio.Writer, waveform string, freqs []float64, rate, channels int, volume float64, maxFrames int, stop <-chan struct{}) error {
|
||||
phase := make([]float64, len(freqs))
|
||||
increment := make([]float64, len(freqs))
|
||||
for i, f := range freqs {
|
||||
increment[i] = 2 * math.Pi * f / float64(rate)
|
||||
}
|
||||
attackFrames := int(float64(rate) * hornAttack.Seconds())
|
||||
releaseFrames := int(float64(rate) * hornRelease.Seconds())
|
||||
framesPerChunk := 512
|
||||
buf := make([]byte, framesPerChunk*channels*2)
|
||||
scale := volume / float64(len(freqs))
|
||||
if waveform == "sine" {
|
||||
scale *= h.cfg.SineGain
|
||||
} else {
|
||||
scale *= h.cfg.SawGain
|
||||
}
|
||||
|
||||
stopRequested := false
|
||||
releaseStart := -1
|
||||
sampleIndex := 0
|
||||
|
||||
for {
|
||||
if !stopRequested {
|
||||
select {
|
||||
case <-stop:
|
||||
stopRequested = true
|
||||
releaseStart = sampleIndex
|
||||
default:
|
||||
}
|
||||
}
|
||||
for i := 0; i < framesPerChunk; i++ {
|
||||
if maxFrames > 0 && sampleIndex >= maxFrames && !stopRequested {
|
||||
stopRequested = true
|
||||
releaseStart = sampleIndex
|
||||
}
|
||||
env := 1.0
|
||||
if attackFrames > 0 && sampleIndex < attackFrames {
|
||||
env = float64(sampleIndex) / float64(attackFrames)
|
||||
} else if stopRequested && releaseFrames > 0 {
|
||||
relIndex := sampleIndex - releaseStart
|
||||
if relIndex >= releaseFrames {
|
||||
return nil
|
||||
}
|
||||
env = float64(releaseFrames-relIndex) / float64(releaseFrames)
|
||||
} else if stopRequested {
|
||||
return nil
|
||||
}
|
||||
|
||||
sample := 0.0
|
||||
for j := range freqs {
|
||||
switch waveform {
|
||||
case "sine":
|
||||
sample += math.Sin(phase[j])
|
||||
default:
|
||||
sample += sawFromPhase(phase[j])
|
||||
}
|
||||
phase[j] += increment[j]
|
||||
if phase[j] > 2*math.Pi {
|
||||
phase[j] -= 2 * math.Pi
|
||||
}
|
||||
}
|
||||
sample *= scale * env
|
||||
if sample > 1.0 {
|
||||
sample = 1.0
|
||||
} else if sample < -1.0 {
|
||||
sample = -1.0
|
||||
}
|
||||
intSample := int16(sample * math.MaxInt16)
|
||||
offset := i * channels * 2
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
|
||||
}
|
||||
sampleIndex++
|
||||
}
|
||||
if _, err := writer.Write(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeHornFreqs(freqs []float64) []float64 {
|
||||
if len(freqs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]float64, 0, 4)
|
||||
for _, f := range freqs {
|
||||
if len(out) >= 4 {
|
||||
break
|
||||
}
|
||||
if f <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sawFromPhase(phase float64) float64 {
|
||||
return 2.0*(phase/(2*math.Pi)) - 1.0
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const publisherEnvPath = "/var/lib/roverd/video.env"
|
||||
|
||||
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
||||
if media.PublishURL == "" {
|
||||
return fmt.Errorf("media publishUrl missing")
|
||||
}
|
||||
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
||||
return fmt.Errorf("audio publishUrl missing")
|
||||
}
|
||||
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
|
||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
|
||||
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
||||
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
||||
}
|
||||
if media.AudioForwardURL != "" {
|
||||
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
|
||||
}
|
||||
if media.VideoWidth > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
||||
}
|
||||
if media.VideoHeight > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
||||
}
|
||||
if media.VideoFPS > 0 {
|
||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
||||
}
|
||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
||||
audioDevice := audio.CaptureDevice
|
||||
if audioDevice == "" || audioDevice == "rovermic" {
|
||||
audioDevice = "hw:0,0"
|
||||
}
|
||||
if audio.SampleRate <= 0 {
|
||||
audio.SampleRate = 48000
|
||||
}
|
||||
if audio.Channels <= 0 {
|
||||
audio.Channels = 2
|
||||
}
|
||||
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
|
||||
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
|
||||
playbackDevice := audio.PlaybackDevice
|
||||
if playbackDevice == "" {
|
||||
playbackDevice = "forward"
|
||||
}
|
||||
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
|
||||
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
|
||||
fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels)
|
||||
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MediaSupervisor struct {
|
||||
cfg MediaConfig
|
||||
audio AudioConfig
|
||||
logger *log.Logger
|
||||
client *http.Client
|
||||
checkInterval time.Duration
|
||||
}
|
||||
|
||||
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
|
||||
if err := UpdatePublisherEnv(cfg, audio); err != nil {
|
||||
logger.Printf("media supervisor: update env failed: %v", err)
|
||||
}
|
||||
if !cfg.Manage || cfg.Service == "" {
|
||||
return nil
|
||||
}
|
||||
interval := cfg.HealthInterval.Duration
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
var client *http.Client
|
||||
if cfg.HealthURL != "" {
|
||||
client = &http.Client{Timeout: 5 * time.Second}
|
||||
}
|
||||
return &MediaSupervisor{
|
||||
cfg: cfg,
|
||||
audio: audio,
|
||||
logger: logger,
|
||||
client: client,
|
||||
checkInterval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MediaSupervisor) Start(ctx context.Context) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||
m.logger.Printf("media supervisor: update env failed: %v", err)
|
||||
}
|
||||
if m.cfg.HealthURL == "" || m.client == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(m.checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
if err := m.checkAndRepair(); err != nil {
|
||||
m.logger.Printf("media supervisor: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := m.checkAndRepair(); err != nil {
|
||||
m.logger.Printf("media supervisor: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error {
|
||||
if m == nil {
|
||||
return errors.New("media supervisor disabled")
|
||||
}
|
||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "start", "stop", "restart", "reload", "status":
|
||||
return m.runSystemctl(ctx, action)
|
||||
default:
|
||||
return fmt.Errorf("unknown media action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MediaSupervisor) checkAndRepair() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if m.checkHealth(ctx) {
|
||||
return nil
|
||||
}
|
||||
m.logger.Printf("media supervisor: health check failed, restarting %s", m.cfg.Service)
|
||||
if err := m.runSystemctl(ctx, "restart"); err != nil {
|
||||
return fmt.Errorf("restart mediamtx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MediaSupervisor) checkHealth(ctx context.Context) bool {
|
||||
if m.client == nil || m.cfg.HealthURL == "" {
|
||||
return true
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.HealthURL, nil)
|
||||
if err != nil {
|
||||
m.logger.Printf("media supervisor: health request: %v", err)
|
||||
return false
|
||||
}
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
m.logger.Printf("media supervisor: health request failed: %v", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return true
|
||||
}
|
||||
m.logger.Printf("media supervisor: unexpected health status %d", resp.StatusCode)
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *MediaSupervisor) runSystemctl(ctx context.Context, action string) error {
|
||||
if m.cfg.Service == "" {
|
||||
return errors.New("no media service configured")
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, "systemctl", action, m.cfg.Service)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s %s: %w (%s)", action, m.cfg.Service, err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gpiocdev "github.com/warthog618/go-gpiocdev"
|
||||
)
|
||||
|
||||
type NightVisionLight struct {
|
||||
cfg NightVisionConfig
|
||||
logger *log.Logger
|
||||
line *gpiocdev.Line
|
||||
mu sync.Mutex
|
||||
on bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, fmt.Errorf("night vision disabled")
|
||||
}
|
||||
chip := cfg.GPIOChip
|
||||
if chip == "" {
|
||||
chip = "gpiochip0"
|
||||
}
|
||||
initial := 0
|
||||
if cfg.InitialOn {
|
||||
initial = 1
|
||||
}
|
||||
line, err := gpiocdev.RequestLine(
|
||||
chip,
|
||||
cfg.GPIOPin,
|
||||
gpiocdev.AsOutput(initial),
|
||||
gpiocdev.WithConsumer("roverd-nightvision"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpio request: %w", err)
|
||||
}
|
||||
nv := &NightVisionLight{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
line: line,
|
||||
on: cfg.InitialOn,
|
||||
}
|
||||
logger.Printf("night vision LED on GPIO %d (initial=%v)", cfg.GPIOPin, cfg.InitialOn)
|
||||
return nv, nil
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) Close() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return
|
||||
}
|
||||
_ = n.line.SetValue(boolToGPIO(n.on))
|
||||
n.line.Close()
|
||||
n.closed = true
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) HandleAction(action string) error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return fmt.Errorf("night vision controller closed")
|
||||
}
|
||||
act := strings.ToLower(strings.TrimSpace(action))
|
||||
switch act {
|
||||
case "", "toggle":
|
||||
return n.setLocked(!n.on)
|
||||
case "on":
|
||||
return n.setLocked(true)
|
||||
case "off":
|
||||
return n.setLocked(false)
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) NightVisionOn() bool {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return !n.on
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) setLocked(on bool) error {
|
||||
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
n.on = on
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolToGPIO(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type NightVisionLight struct{}
|
||||
|
||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
||||
return nil, fmt.Errorf("night vision not supported in dummy build")
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) Close() {}
|
||||
|
||||
func (n *NightVisionLight) HandleAction(action string) error {
|
||||
return fmt.Errorf("night vision not supported in dummy build")
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) NightVisionOn() bool {
|
||||
return false
|
||||
}
|
||||
Binary file not shown.
@@ -1,80 +0,0 @@
|
||||
# Sample configuration for roverd
|
||||
name: roomba-alpha
|
||||
color: "#4DB6AC"
|
||||
serverUrl: ws://control-server.local:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyAMA0
|
||||
baud: 115200
|
||||
brc:
|
||||
gpioPin: 4
|
||||
gpioChip: gpiochip0
|
||||
pulseEvery: 1m
|
||||
pulseWidth: 1s
|
||||
battery:
|
||||
full: 2068
|
||||
warn: 1700
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
publishPort: 9000
|
||||
videoBitrate: 2000000
|
||||
manage: true
|
||||
service: video-publisher.service
|
||||
healthUrl: ""
|
||||
healthInterval: 30s
|
||||
cameraServo:
|
||||
enabled: false
|
||||
pin: 12
|
||||
freqHz: 50
|
||||
cycleLen: 20000
|
||||
minPulseUs: 900
|
||||
maxPulseUs: 2100
|
||||
invert: false
|
||||
minAngle: -15
|
||||
maxAngle: 30
|
||||
homeAngle: 0
|
||||
nudgeDegrees: 2
|
||||
allowRawPulse: false
|
||||
audio:
|
||||
captureEnabled: false
|
||||
captureDevice: hw:0,0
|
||||
playbackDevice: forward
|
||||
sampleRate: 48000
|
||||
channels: 2
|
||||
bitrate: 24000
|
||||
ttsEnabled: false
|
||||
defaultEngine: flite
|
||||
defaultVoice: rms
|
||||
defaultPitch: 50
|
||||
horn:
|
||||
enabled: false
|
||||
volume: 0.25
|
||||
sampleRate: 48000
|
||||
channels: 1
|
||||
sineGain: 1.0
|
||||
sawGain: 0.7
|
||||
maxDuration: 1.2s
|
||||
nightVision:
|
||||
enabled: true
|
||||
gpioPin: 22
|
||||
gpioChip: gpiochip0
|
||||
initialOn: true
|
||||
autoSideBrush:
|
||||
enabled: true
|
||||
speed: 20
|
||||
private:
|
||||
enabled: false
|
||||
safety:
|
||||
speedLimitEnabled: false
|
||||
speedLimitMaxWheelSpeed: 250
|
||||
hardOvercurrentEnabled: false
|
||||
overcurrentStopMs: 300
|
||||
hardBumpEnabled: false
|
||||
bumpBackoffSpeed: 250
|
||||
bumpBackoffMs: 350
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
cliffBackoffMs: 500
|
||||
triggerCooldownMs: 800
|
||||
@@ -1,50 +0,0 @@
|
||||
# Sample configuration for roverd
|
||||
name: roomba-alpha
|
||||
serverUrl: ws://control-server.local:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyAMA0
|
||||
baud: 115200
|
||||
brc:
|
||||
gpioPin: 4
|
||||
gpioChip: gpiochip0
|
||||
pulseEvery: 1m
|
||||
pulseWidth: 1s
|
||||
battery:
|
||||
full: 2068
|
||||
warn: 1700
|
||||
urgent: 1650
|
||||
maxWheelSpeed: 350
|
||||
media:
|
||||
manage: false
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
cameraServo:
|
||||
enabled: false
|
||||
pin: 19
|
||||
freqHz: 50
|
||||
cycleLen: 20000
|
||||
minPulseUs: 900
|
||||
maxPulseUs: 2100
|
||||
minAngle: -15
|
||||
maxAngle: 30
|
||||
homeAngle: 0
|
||||
nudgeDegrees: 2
|
||||
allowRawPulse: false
|
||||
autoSideBrush:
|
||||
enabled: true
|
||||
speed: 20
|
||||
private:
|
||||
enabled: false
|
||||
safety:
|
||||
speedLimitEnabled: false
|
||||
speedLimitMaxWheelSpeed: 250
|
||||
hardOvercurrentEnabled: false
|
||||
overcurrentStopMs: 300
|
||||
hardBumpEnabled: false
|
||||
bumpBackoffSpeed: 250
|
||||
bumpBackoffMs: 350
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
cliffBackoffMs: 500
|
||||
triggerCooldownMs: 800
|
||||
@@ -1,23 +0,0 @@
|
||||
package roverd
|
||||
|
||||
var (
|
||||
defaultStreamPackets = []byte{100, 21, 34}
|
||||
packetSizes = map[byte]int{
|
||||
100: 80,
|
||||
21: 1,
|
||||
34: 1,
|
||||
}
|
||||
expectedPayloadLength = func() int {
|
||||
sum := 0
|
||||
for _, id := range defaultStreamPackets {
|
||||
sum += 1 + packetSizes[id]
|
||||
}
|
||||
return sum
|
||||
}()
|
||||
)
|
||||
|
||||
type SensorSample struct {
|
||||
Timestamp int64
|
||||
ChargingState byte
|
||||
ChargeSources byte
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sensorHeader = 19
|
||||
sensorReadTimeout = 150 * time.Millisecond
|
||||
sensorThrottleMinimum = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
type SensorStreamer struct {
|
||||
r io.Reader
|
||||
rawOut chan<- []byte
|
||||
parsed chan<- SensorSample
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
||||
return &SensorStreamer{r: r, rawOut: rawOut, parsed: parsed, logger: logger}
|
||||
}
|
||||
|
||||
func (s *SensorStreamer) Run(ctx context.Context) {
|
||||
reader := bufio.NewReader(s.r)
|
||||
var nextSend time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
header, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if header != sensorHeader {
|
||||
continue
|
||||
}
|
||||
nBytes, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
frame := make([]byte, int(nBytes)+3)
|
||||
frame[0] = sensorHeader
|
||||
frame[1] = nBytes
|
||||
if _, err := io.ReadFull(reader, frame[2:]); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !validateChecksum(frame) {
|
||||
s.logger.Printf("sensor checksum failed: %s", hex.EncodeToString(frame))
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !nextSend.IsZero() && now.Before(nextSend) {
|
||||
continue
|
||||
}
|
||||
nextSend = now.Add(sensorThrottleMinimum)
|
||||
|
||||
select {
|
||||
case s.rawOut <- frame:
|
||||
default:
|
||||
}
|
||||
|
||||
if s.parsed != nil {
|
||||
if sample, ok := decodeSensorSample(frame); ok {
|
||||
select {
|
||||
case s.parsed <- sample:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateChecksum(buf []byte) bool {
|
||||
var sum int
|
||||
for _, b := range buf {
|
||||
sum += int(b)
|
||||
}
|
||||
return byte(sum&0xFF) == 0
|
||||
}
|
||||
|
||||
func decodeSensorSample(frame []byte) (SensorSample, bool) {
|
||||
if len(frame) < 3 {
|
||||
return SensorSample{}, false
|
||||
}
|
||||
nBytes := int(frame[1])
|
||||
if nBytes+3 != len(frame) {
|
||||
return SensorSample{}, false
|
||||
}
|
||||
payload := frame[2 : 2+nBytes]
|
||||
if len(payload) != expectedPayloadLength {
|
||||
return SensorSample{}, false
|
||||
}
|
||||
|
||||
idx := 0
|
||||
var sample SensorSample
|
||||
var seen byte
|
||||
for idx < len(payload) {
|
||||
id := payload[idx]
|
||||
idx++
|
||||
size, ok := packetSizes[id]
|
||||
if !ok {
|
||||
return SensorSample{}, false
|
||||
}
|
||||
if idx+size > len(payload) {
|
||||
return SensorSample{}, false
|
||||
}
|
||||
segment := payload[idx : idx+size]
|
||||
switch id {
|
||||
case 21:
|
||||
sample.ChargingState = segment[0]
|
||||
seen |= 1
|
||||
case 34:
|
||||
sample.ChargeSources = segment[0]
|
||||
seen |= 2
|
||||
}
|
||||
idx += size
|
||||
}
|
||||
sample.Timestamp = time.Now().UnixMilli()
|
||||
return sample, seen&3 == 3
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sensorHeader = 19
|
||||
|
||||
type SensorStreamer struct {
|
||||
rawOut chan<- []byte
|
||||
parsed chan<- SensorSample
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewSensorStreamer(_ interface{}, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
||||
return &SensorStreamer{rawOut: rawOut, parsed: parsed, logger: logger}
|
||||
}
|
||||
|
||||
func (s *SensorStreamer) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
frame := buildDummyFrame()
|
||||
select {
|
||||
case s.rawOut <- frame:
|
||||
default:
|
||||
}
|
||||
sample := SensorSample{
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
ChargingState: 3, // trickle charging
|
||||
ChargeSources: 0b10, // home base present
|
||||
}
|
||||
select {
|
||||
case s.parsed <- sample:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildDummyFrame() []byte {
|
||||
payload := make([]byte, 0, expectedPayloadLength)
|
||||
payload = append(payload, 100)
|
||||
group := make([]byte, packetSizes[100])
|
||||
group[0] = byte(rand.Intn(16)) // bumps
|
||||
payload = append(payload, group...)
|
||||
payload = append(payload, 21, 3)
|
||||
payload = append(payload, 34, 0b10)
|
||||
|
||||
buf := make([]byte, 0, len(payload)+3)
|
||||
buf = append(buf, sensorHeader, byte(len(payload)))
|
||||
buf = append(buf, payload...)
|
||||
checksum := calcChecksum(buf)
|
||||
buf = append(buf, checksum)
|
||||
return buf
|
||||
}
|
||||
|
||||
func calcChecksum(buf []byte) byte {
|
||||
sum := 0
|
||||
for _, b := range buf {
|
||||
sum += int(b)
|
||||
}
|
||||
return byte((-sum) & 0xFF)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/tarm/serial"
|
||||
)
|
||||
|
||||
type SerialAdapter struct {
|
||||
port io.ReadWriteCloser
|
||||
encoder *base64.Encoding
|
||||
mu sync.Mutex
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
func OpenSerial(cfg SerialConfig) (*serial.Port, error) {
|
||||
return serial.OpenPort(&serial.Config{
|
||||
Name: cfg.Device,
|
||||
Baud: cfg.Baud,
|
||||
ReadTimeout: sensorReadTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
func NewSerialAdapter(port io.ReadWriteCloser, logger *log.Logger) *SerialAdapter {
|
||||
return &SerialAdapter{
|
||||
port: port,
|
||||
encoder: base64.StdEncoding,
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) write(buf []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
n, err := s.port.Write(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != len(buf) {
|
||||
return fmt.Errorf("short write %d/%d", n, len(buf))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) DriveDirect(left, right int) error {
|
||||
payload := []byte{
|
||||
145,
|
||||
byte((right >> 8) & 0xFF),
|
||||
byte(right & 0xFF),
|
||||
byte((left >> 8) & 0xFF),
|
||||
byte(left & 0xFF),
|
||||
}
|
||||
return s.write(payload)
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error {
|
||||
payload := []byte{
|
||||
144,
|
||||
byte(main & 0xFF),
|
||||
byte(side & 0xFF),
|
||||
byte(vacuum & 0xFF),
|
||||
}
|
||||
return s.write(payload)
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) StartSensorStream(packets []byte) error {
|
||||
if len(packets) == 0 {
|
||||
return errors.New("sensor stream requires packets")
|
||||
}
|
||||
payload := []byte{148, byte(len(packets))}
|
||||
payload = append(payload, packets...)
|
||||
return s.write(payload)
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) PauseSensorStream(pause bool) error {
|
||||
state := byte(1)
|
||||
if pause {
|
||||
state = 0
|
||||
}
|
||||
return s.write([]byte{150, state})
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) SendRaw(raw []byte) error {
|
||||
return s.write(raw)
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) StartOI() error {
|
||||
return s.write([]byte{128})
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) SeekDock() error {
|
||||
return s.write([]byte{143})
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||
if len(notes) == 0 {
|
||||
return fmt.Errorf("song requires at least one note")
|
||||
}
|
||||
if len(notes) > 16 {
|
||||
return fmt.Errorf("song supports up to 16 notes, got %d", len(notes))
|
||||
}
|
||||
if slot < 0 || slot > 4 {
|
||||
return fmt.Errorf("song slot must be 0-4")
|
||||
}
|
||||
|
||||
payload := []byte{140, byte(slot), byte(len(notes))}
|
||||
for _, n := range notes {
|
||||
note := clampInt(n.Note, 31, 127)
|
||||
duration := clampInt(n.Duration, 1, 255)
|
||||
payload = append(payload, byte(note), byte(duration))
|
||||
}
|
||||
if err := s.write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.write([]byte{141, byte(slot)})
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
)
|
||||
|
||||
type dummyPort struct{}
|
||||
|
||||
func (dummyPort) Read(p []byte) (int, error) { return 0, io.EOF }
|
||||
func (dummyPort) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func (dummyPort) Close() error { return nil }
|
||||
|
||||
func OpenSerial(cfg SerialConfig) (io.ReadWriteCloser, error) {
|
||||
return dummyPort{}, nil
|
||||
}
|
||||
|
||||
type SerialAdapter struct {
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
func NewSerialAdapter(_ io.ReadWriteCloser, logger *log.Logger) *SerialAdapter {
|
||||
return &SerialAdapter{log: logger}
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) DriveDirect(left, right int) error {
|
||||
s.log.Printf("[dummy] drive L=%d R=%d", left, right)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error {
|
||||
s.log.Printf("[dummy] motor main=%d side=%d vacuum=%d", main, side, vacuum)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) StartSensorStream(packets []byte) error {
|
||||
if len(packets) == 0 {
|
||||
return errors.New("sensor stream requires packets")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) PauseSensorStream(pause bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) SendRaw(raw []byte) error {
|
||||
s.log.Printf("[dummy] raw %v", raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) StartOI() error {
|
||||
s.log.Printf("[dummy] start OI")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) SeekDock() error {
|
||||
s.log.Printf("[dummy] seek dock")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||
s.log.Printf("[dummy] play song slot=%d notes=%v", slot, notes)
|
||||
return nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) error {
|
||||
if payload == nil {
|
||||
return fmt.Errorf("tts payload required")
|
||||
}
|
||||
if !c.cfg.Audio.TTSEnabled {
|
||||
return fmt.Errorf("tts disabled on rover")
|
||||
}
|
||||
if payload.Speak == false {
|
||||
return nil
|
||||
}
|
||||
text := strings.TrimSpace(payload.Text)
|
||||
if text == "" {
|
||||
return fmt.Errorf("tts text required")
|
||||
}
|
||||
if len([]rune(text)) > 512 {
|
||||
text = string([]rune(text)[:512])
|
||||
}
|
||||
|
||||
engine := strings.ToLower(strings.TrimSpace(payload.Engine))
|
||||
if engine == "" {
|
||||
engine = strings.ToLower(strings.TrimSpace(c.cfg.Audio.DefaultEngine))
|
||||
}
|
||||
if engine == "" {
|
||||
engine = "flite"
|
||||
}
|
||||
|
||||
voice := strings.TrimSpace(payload.Voice)
|
||||
if voice == "" {
|
||||
voice = strings.TrimSpace(c.cfg.Audio.DefaultVoice)
|
||||
}
|
||||
pitch := payload.Pitch
|
||||
if pitch <= 0 {
|
||||
pitch = c.cfg.Audio.DefaultPitch
|
||||
}
|
||||
pitch = clampInt(pitch, 0, 99)
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch engine {
|
||||
case "espeak", "e":
|
||||
args := []string{}
|
||||
if pitch > 0 {
|
||||
args = append(args, "-p", fmt.Sprintf("%d", pitch))
|
||||
}
|
||||
args = append(args, text)
|
||||
cmd = exec.CommandContext(runCtx, "espeak", args...)
|
||||
case "flite", "f":
|
||||
args := []string{}
|
||||
if voice != "" {
|
||||
args = append(args, "-voice", voice)
|
||||
}
|
||||
args = append(args, "-t", text)
|
||||
cmd = exec.CommandContext(runCtx, "flite", args...)
|
||||
default:
|
||||
return fmt.Errorf("unsupported tts engine: %s", engine)
|
||||
}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("tts exec failed: %w (%s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
)
|
||||
|
||||
type WSClient struct {
|
||||
cfg *Config
|
||||
adapter *SerialAdapter
|
||||
sensorFrames <-chan []byte
|
||||
events chan RoverEvent
|
||||
media *MediaSupervisor
|
||||
servo *CameraServo
|
||||
horn *HornSynth
|
||||
nightVision *NightVisionLight
|
||||
log *log.Logger
|
||||
recoverMu sync.Mutex
|
||||
recovering bool
|
||||
ttsQueue chan *ttsPayload
|
||||
lastAux motorPWMPayload
|
||||
autoSideOn bool
|
||||
connMu sync.Mutex
|
||||
connected bool
|
||||
disconnectT *time.Timer
|
||||
rebootT *time.Timer
|
||||
seekIssued bool
|
||||
rebootIssued bool
|
||||
audioLevels AudioLevels
|
||||
audioMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
||||
var ttsQueue chan *ttsPayload
|
||||
if cfg.Audio.TTSEnabled {
|
||||
ttsQueue = make(chan *ttsPayload, 2)
|
||||
}
|
||||
var horn *HornSynth
|
||||
if cfg.Horn.Enabled {
|
||||
horn = NewHornSynth(cfg.Horn, logger)
|
||||
}
|
||||
client := &WSClient{
|
||||
cfg: cfg,
|
||||
adapter: adapter,
|
||||
sensorFrames: frames,
|
||||
events: events,
|
||||
media: media,
|
||||
servo: servo,
|
||||
horn: horn,
|
||||
nightVision: nightVision,
|
||||
log: logger,
|
||||
ttsQueue: ttsQueue,
|
||||
audioLevels: AudioLevels{
|
||||
HornGain: 1.0,
|
||||
TTSGain: 1.0,
|
||||
ForwardGain: 1.0,
|
||||
},
|
||||
}
|
||||
client.applyAudioLevelsToMixer(client.audioLevels)
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *WSClient) Run(ctx context.Context) error {
|
||||
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
|
||||
conn, _, err := websocket.Dial(dialCtx, c.cfg.ServerURL, nil)
|
||||
cancel()
|
||||
if err != nil {
|
||||
c.markDisconnected()
|
||||
return err
|
||||
}
|
||||
c.markConnected()
|
||||
defer conn.Close(websocket.StatusInternalError, "closed")
|
||||
defer c.markDisconnected()
|
||||
|
||||
if err := c.sendHello(ctx, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureSensorStream(); err != nil {
|
||||
c.log.Printf("sensor stream init failed: %v", err)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
c.startTTSWorker(ctx)
|
||||
go func() {
|
||||
errCh <- c.readLoop(ctx, conn)
|
||||
}()
|
||||
go func() {
|
||||
if err := c.keepalive(ctx, conn); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
go c.forwardSensors(ctx, conn)
|
||||
go c.forwardEvents(ctx, conn)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
conn.Close(websocket.StatusNormalClosure, "context done")
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
||||
msg := helloMessage{
|
||||
Type: "hello",
|
||||
Name: c.cfg.Name,
|
||||
Color: c.cfg.Color,
|
||||
Battery: c.cfg.Battery,
|
||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
||||
Media: c.cfg.Media,
|
||||
CameraServo: c.cfg.CameraServo,
|
||||
Audio: c.cfg.Audio,
|
||||
Horn: c.cfg.Horn,
|
||||
NightVision: c.cfg.NightVision,
|
||||
Private: c.cfg.Private,
|
||||
}
|
||||
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
||||
return writeJSON(ctx, conn, msg)
|
||||
}
|
||||
|
||||
func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error {
|
||||
for {
|
||||
_, data, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var msg inboundMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
c.log.Printf("invalid command: %v", err)
|
||||
continue
|
||||
}
|
||||
if msg.ID == "" {
|
||||
continue
|
||||
}
|
||||
status := "ok"
|
||||
cmdErr := c.dispatch(ctx, &msg)
|
||||
if cmdErr != nil {
|
||||
status = "error"
|
||||
}
|
||||
ack := ackMessage{
|
||||
Type: "ack",
|
||||
ID: msg.ID,
|
||||
Status: status,
|
||||
}
|
||||
if cmdErr != nil {
|
||||
ack.Error = cmdErr.Error()
|
||||
}
|
||||
if err := writeJSON(ctx, conn, ack); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
||||
switch {
|
||||
case msg.DriveDirect != nil:
|
||||
left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
||||
right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
||||
if err := c.adapter.DriveDirect(left, right); err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyAutoSideBrush(left, right)
|
||||
return nil
|
||||
case msg.MotorPWM != nil:
|
||||
main := clamp(msg.MotorPWM.Main, -127, 127)
|
||||
side := clamp(msg.MotorPWM.Side, -127, 127)
|
||||
vac := clamp(msg.MotorPWM.Vacuum, 0, 127)
|
||||
c.lastAux = motorPWMPayload{Main: main, Side: side, Vacuum: vac}
|
||||
c.autoSideOn = false
|
||||
return c.adapter.MotorPWM(main, side, vac)
|
||||
case msg.SensorStream != nil:
|
||||
if msg.SensorStream.Enable {
|
||||
return c.adapter.StartSensorStream(defaultStreamPackets)
|
||||
}
|
||||
return nil
|
||||
case msg.Raw != "" && len(msg.Raw) > 0:
|
||||
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raw decode: %w", err)
|
||||
}
|
||||
if err := c.adapter.SendRaw(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(buf) > 0 && isModeOpcode(buf[0]) {
|
||||
return c.ensureSensorStream()
|
||||
}
|
||||
return nil
|
||||
case msg.Media != nil:
|
||||
if c.media == nil {
|
||||
return fmt.Errorf("media supervisor disabled")
|
||||
}
|
||||
return c.media.HandleAction(ctx, msg.Media.Action)
|
||||
case msg.Servo != nil:
|
||||
if c.servo == nil {
|
||||
return fmt.Errorf("camera servo disabled")
|
||||
}
|
||||
return c.handleServoCommand(msg.Servo)
|
||||
case msg.TTS != nil:
|
||||
return c.enqueueTTS(msg.TTS)
|
||||
case msg.Horn != nil:
|
||||
if c.horn == nil {
|
||||
return fmt.Errorf("horn disabled")
|
||||
}
|
||||
return c.horn.HandlePayload(msg.Horn)
|
||||
case msg.AudioLevels != nil:
|
||||
return c.handleAudioLevels(msg.AudioLevels)
|
||||
case msg.NightVision != nil:
|
||||
if c.nightVision == nil {
|
||||
return fmt.Errorf("night vision disabled")
|
||||
}
|
||||
if err := c.nightVision.HandleAction(msg.NightVision.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
c.emitEvent("nightVision.state", map[string]any{
|
||||
"nightVisionOn": c.nightVision.NightVisionOn(),
|
||||
})
|
||||
return nil
|
||||
case msg.Song != nil:
|
||||
slot := 0
|
||||
if msg.Song.Slot != nil {
|
||||
slot = clampInt(*msg.Song.Slot, 0, 4)
|
||||
}
|
||||
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
||||
case msg.Reboot != nil || msg.Type == "reboot":
|
||||
return c.handleRebootCommand(msg.Reboot)
|
||||
default:
|
||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
|
||||
if err := c.adapter.DriveDirect(0, 0); err != nil {
|
||||
return fmt.Errorf("stop drive before reboot: %w", err)
|
||||
}
|
||||
if err := c.adapter.MotorPWM(0, 0, 0); err != nil {
|
||||
return fmt.Errorf("stop aux motors before reboot: %w", err)
|
||||
}
|
||||
if err := c.adapter.StartOI(); err != nil {
|
||||
return fmt.Errorf("enter passive mode before reboot: %w", err)
|
||||
}
|
||||
|
||||
delay := 300 * time.Millisecond
|
||||
if payload != nil && payload.DelayMs > 0 {
|
||||
delay = time.Duration(clampInt(payload.DelayMs, 50, 5000)) * time.Millisecond
|
||||
}
|
||||
|
||||
c.connMu.Lock()
|
||||
if c.rebootIssued {
|
||||
c.connMu.Unlock()
|
||||
return fmt.Errorf("reboot already pending")
|
||||
}
|
||||
c.rebootIssued = true
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.emitEvent("system.rebooting", map[string]any{
|
||||
"source": "remoteCommand",
|
||||
"delayMs": delay.Milliseconds(),
|
||||
})
|
||||
|
||||
go func() {
|
||||
time.Sleep(delay)
|
||||
c.log.Printf("rebooting pi after remote reboot command")
|
||||
cmd := exec.Command("systemctl", "reboot")
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.log.Printf("reboot command failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WSClient) applyAutoSideBrush(left, right int) {
|
||||
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
|
||||
if c.autoSideOn {
|
||||
c.autoSideOn = false
|
||||
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
|
||||
c.log.Printf("auto side brush stop failed: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
moving := left != 0 || right != 0
|
||||
if !moving {
|
||||
if c.autoSideOn {
|
||||
c.autoSideOn = false
|
||||
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
|
||||
c.log.Printf("auto side brush stop failed: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if c.lastAux.Side != 0 {
|
||||
c.autoSideOn = false
|
||||
return
|
||||
}
|
||||
|
||||
autoSpeed := clampInt(c.cfg.AutoSideBrush.Speed, -127, 127)
|
||||
if autoSpeed == 0 {
|
||||
c.autoSideOn = false
|
||||
return
|
||||
}
|
||||
if c.autoSideOn {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.adapter.MotorPWM(c.lastAux.Main, autoSpeed, c.lastAux.Vacuum); err != nil {
|
||||
c.log.Printf("auto side brush start failed: %v", err)
|
||||
return
|
||||
}
|
||||
c.autoSideOn = true
|
||||
}
|
||||
|
||||
func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
|
||||
if c.ttsQueue == nil {
|
||||
return fmt.Errorf("tts disabled")
|
||||
}
|
||||
select {
|
||||
case c.ttsQueue <- payload:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("tts busy")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) startTTSWorker(ctx context.Context) {
|
||||
if c.ttsQueue == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case payload := <-c.ttsQueue:
|
||||
if payload == nil {
|
||||
continue
|
||||
}
|
||||
if err := c.handleTTSPayload(ctx, payload); err != nil {
|
||||
c.log.Printf("tts failed: %v", err)
|
||||
c.emitEvent("tts.error", map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
|
||||
switch {
|
||||
case payload.Angle != nil:
|
||||
return c.servo.SetAngle(*payload.Angle)
|
||||
case payload.Nudge != nil:
|
||||
return c.servo.Nudge(*payload.Nudge)
|
||||
case payload.PulseUs != nil:
|
||||
return c.servo.SetPulseWidth(*payload.PulseUs)
|
||||
default:
|
||||
return fmt.Errorf("servo command requires angle, nudge, or pulseUs")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
|
||||
const (
|
||||
sensorSilenceTimeout = 5 * time.Second
|
||||
sensorRecoveryCooldown = 3 * time.Second
|
||||
sensorCommandPause = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
timer := time.NewTimer(sensorSilenceTimeout)
|
||||
defer timer.Stop()
|
||||
|
||||
resetTimer := func() {
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(sensorSilenceTimeout)
|
||||
}
|
||||
|
||||
lastRecovery := time.Time{}
|
||||
lastFrame := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
now := time.Now()
|
||||
if !lastRecovery.IsZero() && now.Sub(lastRecovery) < sensorRecoveryCooldown {
|
||||
resetTimer()
|
||||
continue
|
||||
}
|
||||
|
||||
idleFor := now.Sub(lastFrame)
|
||||
if idleFor < 0 {
|
||||
idleFor = sensorSilenceTimeout
|
||||
}
|
||||
|
||||
c.recoverSensorStream(idleFor, sensorCommandPause)
|
||||
lastRecovery = now
|
||||
resetTimer()
|
||||
case frame := <-c.sensorFrames:
|
||||
lastFrame = time.Now()
|
||||
resetTimer()
|
||||
msg := sensorMessage{
|
||||
Type: "sensor",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Data: base64.StdEncoding.EncodeToString(frame),
|
||||
}
|
||||
if err := writeJSON(ctx, conn, msg); err != nil {
|
||||
c.log.Printf("sensor send failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
||||
if c.events == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case evt := <-c.events:
|
||||
if evt.Type == "" {
|
||||
evt.Type = "event"
|
||||
}
|
||||
if err := writeJSON(ctx, conn, evt); err != nil {
|
||||
c.log.Printf("event send failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) emitEvent(event string, data map[string]any) {
|
||||
if c.events == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.events <- RoverEvent{
|
||||
Type: "event",
|
||||
Event: event,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
Data: data,
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.Write(ctx, websocket.MessageText, data)
|
||||
}
|
||||
|
||||
func clamp(value, min, max int) int {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (c *WSClient) ensureSensorStream() error {
|
||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const disconnectSeekDelay = time.Minute
|
||||
const disconnectRebootDelay = 6 * time.Minute
|
||||
const dialTimeout = 10 * time.Second
|
||||
const pingInterval = 15 * time.Second
|
||||
const pingTimeout = 5 * time.Second
|
||||
|
||||
func (c *WSClient) keepalive(ctx context.Context, conn *websocket.Conn) error {
|
||||
ticker := time.NewTicker(pingInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
pingCtx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||
err := conn.Ping(pingCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) markConnected() {
|
||||
c.connMu.Lock()
|
||||
c.connected = true
|
||||
c.seekIssued = false
|
||||
c.rebootIssued = false
|
||||
if c.disconnectT != nil {
|
||||
c.disconnectT.Stop()
|
||||
c.disconnectT = nil
|
||||
}
|
||||
if c.rebootT != nil {
|
||||
c.rebootT.Stop()
|
||||
c.rebootT = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *WSClient) markDisconnected() {
|
||||
c.connMu.Lock()
|
||||
if c.connected {
|
||||
c.connected = false
|
||||
}
|
||||
if c.disconnectT == nil {
|
||||
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
|
||||
}
|
||||
if c.rebootT == nil {
|
||||
c.rebootT = time.AfterFunc(disconnectRebootDelay, c.handleRebootTimeout)
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *WSClient) handleDisconnectTimeout() {
|
||||
c.connMu.Lock()
|
||||
if c.connected || c.seekIssued {
|
||||
c.connMu.Unlock()
|
||||
return
|
||||
}
|
||||
c.seekIssued = true
|
||||
c.connMu.Unlock()
|
||||
|
||||
if err := c.adapter.SeekDock(); err != nil {
|
||||
c.log.Printf("seek dock on disconnect failed: %v", err)
|
||||
return
|
||||
}
|
||||
c.log.Printf("seek dock issued after websocket disconnect")
|
||||
}
|
||||
|
||||
func (c *WSClient) handleRebootTimeout() {
|
||||
c.connMu.Lock()
|
||||
if c.connected || c.rebootIssued {
|
||||
c.connMu.Unlock()
|
||||
return
|
||||
}
|
||||
c.rebootIssued = true
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.log.Printf("rebooting pi after prolonged websocket disconnect")
|
||||
cmd := exec.Command("systemctl", "reboot")
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.log.Printf("reboot command failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) {
|
||||
c.recoverMu.Lock()
|
||||
if c.recovering {
|
||||
c.recoverMu.Unlock()
|
||||
return
|
||||
}
|
||||
c.recovering = true
|
||||
c.recoverMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.recoverMu.Lock()
|
||||
c.recovering = false
|
||||
c.recoverMu.Unlock()
|
||||
}()
|
||||
|
||||
c.emitEvent("sensorWatchdog.restart", map[string]any{
|
||||
"idleMs": idleFor.Milliseconds(),
|
||||
})
|
||||
|
||||
if err := c.adapter.StartOI(); err != nil {
|
||||
c.log.Printf("watchdog start OI failed: %v", err)
|
||||
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if cmdPause > 0 {
|
||||
time.Sleep(cmdPause)
|
||||
}
|
||||
|
||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
||||
c.log.Printf("watchdog start stream failed: %v", err)
|
||||
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.emitEvent("sensorWatchdog.ok", map[string]any{
|
||||
"idleMs": idleFor.Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
func isModeOpcode(op byte) bool {
|
||||
switch op {
|
||||
case 128, 131, 132:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
[Unit]
|
||||
Description=Rover Audio Forward Listener (SRT -> ALSA)
|
||||
After=network-online.target roverd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=roverd
|
||||
Group=roverd
|
||||
EnvironmentFile=/var/lib/roverd/video.env
|
||||
ExecStart=/usr/local/bin/audio-forward-listener
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=5
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,18 +0,0 @@
|
||||
[Unit]
|
||||
Description=Rover Audio Publisher (ALSA -> SRT)
|
||||
After=network-online.target roverd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=roverd
|
||||
Group=roverd
|
||||
EnvironmentFile=/var/lib/roverd/video.env
|
||||
ExecStart=/usr/local/bin/audio-only-publisher
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=5
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,14 +0,0 @@
|
||||
[Unit]
|
||||
Description=Multi-Roomba rover control agent
|
||||
After=network-online.target mediamtx.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=Rover Video Publisher (libcamera -> SRT)
|
||||
After=network-online.target roverd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=roverd
|
||||
Group=roverd
|
||||
WorkingDirectory=/var/lib/roverd
|
||||
EnvironmentFile=/var/lib/roverd/video.env
|
||||
ExecStart=/usr/local/bin/video-publisher
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,14 +0,0 @@
|
||||
# main idea:
|
||||
- stream audio from server to rovers
|
||||
- users can either stream their mic from their browser
|
||||
- users can also play audio files on the rover through the browser
|
||||
- this is a VIP feature for verified users only
|
||||
- gate in UI and in the server
|
||||
|
||||
## specifics
|
||||
- only the current driver can play audio through a rover
|
||||
- admins can enable / disable audio
|
||||
- lockdown admins can adjust the volume for all rovers
|
||||
- rovers are always listening for an audio stream from the server
|
||||
- no transcoding allowed on-rover due to resources
|
||||
- rovers are always local and cant be accessed from outside, no security is needed for audio streaming
|
||||
@@ -1,53 +0,0 @@
|
||||
# private rovers
|
||||
## basic concept:
|
||||
private rovers will be mostly just for lockdown admins to drive and use, but they can be temporarily unlocked manually by lockdown admins for use by verified users.
|
||||
This means that locking / unlocking will act a little different than standard rovers.
|
||||
|
||||
- cannot be spectated by spectators, unless they are unlocked
|
||||
- cannot be replayed, unless they are unlocked
|
||||
- private status is defined in the roverd config
|
||||
- needs to never leak through access to anyone while locked
|
||||
- unlocking a private rover is a big deal for verified users (opening up a rover in the main living space for a special event)
|
||||
- not included in LLM events system
|
||||
- basically needs to be online but completely hidden when its not open
|
||||
|
||||
## locking / unlocking:
|
||||
- private rovers start locked
|
||||
- when locked, only lockdown admins can drive them
|
||||
- when unlocked, only verified users (and lockdown admins of course) can drive them
|
||||
- if left unlocked with no one online for 30 mins, the server will automatically lock them
|
||||
- ## private rovers can be locked / unlocked by holding all 3 buttons on the top of the roomba for 3 seconds
|
||||
- hold spot / clean / dock buttons for 3 seconds to toggle opened / closed on that private rover
|
||||
- the server sends a TTS command to the rover to indicate when its toggled
|
||||
|
||||
## cliff rules / speed limit / overcurrent limit
|
||||
### private rovers will be in a sensitive area, their physical capabilities will be optionally limited by the server, controllable by lockdown admins.
|
||||
- optional toggleable limits:
|
||||
- speed limit
|
||||
- hard overcurrent limiting (stop motor for a bit the instant it overcurrents for maybe 0.3s)
|
||||
- hard bump limits, stop and back up slightly on physical bumps of a certain short duration
|
||||
- cliff drops. back up and pause when any cliff sensor triggers, use their binary outputs for this as they are tuned well from factory.
|
||||
|
||||
## UI specifics
|
||||
- private rovers don't show in the spectator pages unless they are unlocked
|
||||
- private rovers don't show in the list for normal users unless they are unlocked
|
||||
- they will only show for lockdown admins
|
||||
- when unlocked, they show for everyone
|
||||
- with a different color in the rover list
|
||||
|
||||
## . . .
|
||||
this will be kind of invasive, touching a lot of systems server-side, long story short:
|
||||
- private rovers are set as private in the roverd config
|
||||
- by default:
|
||||
- locked to only lockdown admins
|
||||
- cant be spectated by anyone
|
||||
- any user who isnt a lockdown admin cannot know that it exists in any way at all
|
||||
- not included by most automated systems like LLM integration, discord alerts, etc
|
||||
- still included in safties like auto docking
|
||||
- limitations dont apply because its lockdown admin only anyway
|
||||
- chat messages from them dont get seen by anyone else at all, only sent to the rover for tts
|
||||
- when opened up (can only be opened by lockdown admins):
|
||||
- only verified users can drive them
|
||||
- anyone can spectate them
|
||||
- limits apply
|
||||
- included in all automated systems just like a normal rover
|
||||
@@ -1,22 +0,0 @@
|
||||
1. fix controls remapping [x]
|
||||
2. trusted user system [x]
|
||||
3. private rovers
|
||||
4. custom webhook profile pictures for chat bridge in discord
|
||||
5. home assistant switch that tells the server to force the lights on
|
||||
6. color coding with colored names and tape [x]
|
||||
7. audio forwarding [x]
|
||||
- streaming from server to rovers [x]
|
||||
- audio files first [x]
|
||||
- then voice chat [x]
|
||||
8. mobile controls column swapping (optional joystick on left) [x]
|
||||
9. fix fullscreen on mobile so that you can re-enter it [x]
|
||||
10. home assistant rover mute switch
|
||||
|
||||
# relative pipe dreams:
|
||||
1. VPS video forwarding
|
||||
1. get forwarding working with the VPS for in-queue users and spectators
|
||||
2. bandwidth testing
|
||||
3. maybe switch room cams back to real video, with audio?
|
||||
2. overseer LED tesseract
|
||||
3. RF based positional tracking / room map tab
|
||||
4. chromecast monitor youtube search and speakers
|
||||
@@ -1,45 +0,0 @@
|
||||
# user verification system
|
||||
## main idea
|
||||
- a relatively simple system to verify trusted users and allow them to use special features
|
||||
- uses IP, a cookie user ID, and nickname to verify people
|
||||
- expose internally similar to socket.isAdmin: socket.isVerified.
|
||||
|
||||
## on-connect system to send user info to the server
|
||||
- a new system in the web UI (and server a little bit probably)
|
||||
- ensures that the server gets all of your user info when you connect
|
||||
- also ensures that the server can seamlessley remember who you are if you happen to lose connection and reconnect
|
||||
- info contains:
|
||||
- nickname (replace the current reconnect and nickname logic with this new system)
|
||||
- cookie ID
|
||||
- more stuff in the future probably
|
||||
|
||||
## cookie user ID
|
||||
- an ID that the server assigns to a user
|
||||
- saves as a setting in the settings persistence system in the user's browser
|
||||
|
||||
## how will the server verify people
|
||||
- when a user connects and sends their user info:
|
||||
- step 1: IP address OR cookie user ID
|
||||
- if the user's IP or their cookie ID matches, continue to step 2
|
||||
- step 2: nickname
|
||||
- if the user's nickname matches to it's expected step 1, the user is now verified
|
||||
- the user is now verified and added to a persistent database on the server
|
||||
|
||||
## how will verification requests work
|
||||
- user goes through the process in the web UI
|
||||
- the request is DMd to lockdown admins in discord
|
||||
- each message can be reacted with a check or an x emoji by the lockdown admins to accept or deny a request
|
||||
- no realtime UI feedback is needed for when a request is accepted or denied
|
||||
|
||||
## UI specifics
|
||||
- a new VIP tab in the sidebar
|
||||
- either shows a button to request verification, or shows the VIP controls
|
||||
### verification process
|
||||
- a button in the sidebar to request verification
|
||||
- only shows if you aren't verified
|
||||
- the actual process:
|
||||
1. press the button
|
||||
2. the page opens a new pop-up
|
||||
3. it explains what verification is, how it works, and that your nickname is attached to your verification
|
||||
4. prompts users to confirm their nickname, as if they change it their verification won't work
|
||||
5. a final confirmation saying that their request has been sent
|
||||
@@ -0,0 +1,5 @@
|
||||
[env:esp32s3]
|
||||
platform = espressif32
|
||||
board = esp32-s3-devkitc-1
|
||||
monitor_speed = 115200
|
||||
framework = arduino
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Configurable via environment
|
||||
export DEVICE="${DEVICE:-/dev/video0}"
|
||||
export RESOLUTION="${RESOLUTION:-640x480}"
|
||||
export QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
|
||||
export PORT="${PORT:-8088}"
|
||||
export WORKDIR="${WORKDIR:-/run/roomcam}"
|
||||
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
|
||||
export INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
|
||||
export MJPEG_FPS="${MJPEG_FPS:-15}"
|
||||
export MJPEG_QUALITY="${MJPEG_QUALITY:-8}"
|
||||
|
||||
mkdir -p "${WORKDIR}"
|
||||
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
|
||||
rm -f "${SNAPSHOT_PATH}"
|
||||
# push
|
||||
cleanup() {
|
||||
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 0' SIGTERM INT
|
||||
|
||||
cat > "${WORKDIR}/mjpeg_server.py" <<'PY'
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import subprocess
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
DEVICE = os.environ.get("DEVICE", "/dev/video0")
|
||||
RESOLUTION = os.environ.get("RESOLUTION", "640x480")
|
||||
INPUT_FORMAT = os.environ.get("INPUT_FORMAT", "mjpeg")
|
||||
MJPEG_FPS = os.environ.get("MJPEG_FPS", "15")
|
||||
MJPEG_QUALITY = os.environ.get("MJPEG_QUALITY", "8")
|
||||
WORKDIR = os.environ.get("WORKDIR", "/run/roomcam")
|
||||
SNAPSHOT_PATH = os.path.join(WORKDIR, "snapshot.jpg")
|
||||
|
||||
FFMPEG_INPUT_ARGS = [
|
||||
"-f", "v4l2",
|
||||
"-input_format", INPUT_FORMAT,
|
||||
"-video_size", RESOLUTION,
|
||||
"-i", DEVICE,
|
||||
]
|
||||
FFMPEG_FILTERS = []
|
||||
if INPUT_FORMAT.startswith("bayer_"):
|
||||
FFMPEG_FILTERS = ["-pix_fmt", "yuv420p"]
|
||||
|
||||
FRAME_LOCK = threading.Lock()
|
||||
FRAME_EVENT = threading.Event()
|
||||
LATEST_FRAME = b""
|
||||
|
||||
def spawn_mjpeg():
|
||||
cmd = [
|
||||
"/usr/bin/ffmpeg",
|
||||
"-loglevel", "warning", "-nostats",
|
||||
*FFMPEG_INPUT_ARGS,
|
||||
*FFMPEG_FILTERS,
|
||||
"-r", str(MJPEG_FPS),
|
||||
"-q:v", str(MJPEG_QUALITY),
|
||||
"-f", "mjpeg",
|
||||
"-",
|
||||
]
|
||||
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
|
||||
def update_frame(frame_bytes):
|
||||
global LATEST_FRAME
|
||||
with FRAME_LOCK:
|
||||
LATEST_FRAME = frame_bytes
|
||||
FRAME_EVENT.set()
|
||||
try:
|
||||
with open(SNAPSHOT_PATH, "wb") as fh:
|
||||
fh.write(frame_bytes)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def frame_reader():
|
||||
while True:
|
||||
proc = spawn_mjpeg()
|
||||
buffer = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = proc.stdout.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
while True:
|
||||
start = buffer.find(b"\xff\xd8")
|
||||
end = buffer.find(b"\xff\xd9", start + 2)
|
||||
if start == -1 or end == -1:
|
||||
break
|
||||
frame = buffer[start : end + 2]
|
||||
buffer = buffer[end + 2 :]
|
||||
update_frame(frame)
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/" or self.path == "/snapshot.jpg":
|
||||
with FRAME_LOCK:
|
||||
frame = LATEST_FRAME
|
||||
if not frame:
|
||||
self.send_error(404, "snapshot missing")
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/jpeg")
|
||||
self.send_header("Content-Length", str(len(frame)))
|
||||
self.end_headers()
|
||||
self.wfile.write(frame)
|
||||
return
|
||||
|
||||
if self.path == "/stream.mjpg":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
self.end_headers()
|
||||
try:
|
||||
while True:
|
||||
FRAME_EVENT.wait(timeout=2)
|
||||
FRAME_EVENT.clear()
|
||||
with FRAME_LOCK:
|
||||
frame = LATEST_FRAME
|
||||
if not frame:
|
||||
continue
|
||||
header = (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
+ f"Content-Length: {len(frame)}\r\n\r\n".encode("ascii")
|
||||
)
|
||||
self.wfile.write(header)
|
||||
self.wfile.write(frame)
|
||||
self.wfile.write(b"\r\n")
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
return
|
||||
|
||||
self.send_error(404, "not found")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
def main():
|
||||
threading.Thread(target=frame_reader, daemon=True).start()
|
||||
addr = ("0.0.0.0", int(os.environ.get("PORT", "8088")))
|
||||
ThreadingHTTPServer(addr, Handler).serve_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PY
|
||||
|
||||
/usr/bin/python3 -u "${WORKDIR}/mjpeg_server.py" &
|
||||
HTTP_PID=$!
|
||||
|
||||
wait -n "${HTTP_PID}"
|
||||
@@ -1,21 +0,0 @@
|
||||
[Unit]
|
||||
Description=Room camera snapshot server (MJPEG webcam)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=DEVICE=/dev/video0
|
||||
Environment=RESOLUTION=640x480
|
||||
Environment=QUALITY=5
|
||||
Environment=PORT=8088
|
||||
Environment=WORKDIR=/run/roomcam
|
||||
Environment=INPUT_FORMAT=mjpeg
|
||||
ExecStart=/usr/bin/env bash /usr/local/bin/room-cam-snapshot.sh
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Binary file not shown.
@@ -1,80 +0,0 @@
|
||||
admins:
|
||||
- username: admin
|
||||
password_hash: "$2b$10$ZW4Jy7ctIt7k9V1AogFky.v4wedLF92t4/ZlT9kWPlIiCmdQNzJ.C" # password: adminpass
|
||||
discord_id: "1234567890"
|
||||
lockdown: false
|
||||
- username: lockdown
|
||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||
discord_id: "0987654321"
|
||||
lockdown: true
|
||||
timezone: "America/New_York"
|
||||
llmCommentary:
|
||||
enabled: false
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
frequency: 120000
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
# Example: http://192.168.0.86:8889/video
|
||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||
|
||||
audioForward:
|
||||
enabled: true
|
||||
ffmpegBin: "ffmpeg"
|
||||
streamSuffix: "-fwd"
|
||||
maxUploadBytes: 8388608
|
||||
|
||||
audioLevels:
|
||||
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
||||
hornGain: 1.0
|
||||
ttsGain: 1.0
|
||||
forwardGain: 1.0
|
||||
|
||||
homeAssistant:
|
||||
url: "http://homeassistant.local:8123"
|
||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||
entities:
|
||||
- id: "light.lab_main"
|
||||
name: "Lab Lights"
|
||||
- id: "switch.dock_power"
|
||||
name: "Dock Power"
|
||||
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
||||
roomCameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
description: "Wide shot of the staging area."
|
||||
url: "http://192.168.0.50/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||
- id: "workshop"
|
||||
name: "Workshop Bench"
|
||||
description: "Shows the workbench and charging docks."
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||
|
||||
discord:
|
||||
token: "DISCORD_BOT_TOKEN"
|
||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||
siteUrl: "https://rover.example.com"
|
||||
channels:
|
||||
announcements: "123456789012345678"
|
||||
adminAlerts: "123456789012345678"
|
||||
# chat bridge is configured per guild via `rs bridge` commands
|
||||
replay: "123456789012345678"
|
||||
roles:
|
||||
announcementPing: "123456789012345678"
|
||||
adminPing: "123456789012345678"
|
||||
|
||||
socials:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
- id: "wiki"
|
||||
label: "Wiki"
|
||||
url: "https://wiki.example.com"
|
||||
- id: "throne"
|
||||
label: "Throne"
|
||||
url: "https://throne.me/yourname"
|
||||
@@ -1,40 +0,0 @@
|
||||
require('./src/globals/logger');
|
||||
require('./src/globals/config');
|
||||
require('./src/globals/http');
|
||||
require('./src/globals/io');
|
||||
require('./src/globals/ws');
|
||||
|
||||
require('./src/helpers/sensorDecoder');
|
||||
|
||||
require('./src/services/alertService');
|
||||
require('./src/services/authService');
|
||||
require('./src/services/eventBus');
|
||||
require('./src/services/modeManager');
|
||||
require('./src/services/lockdownGuard');
|
||||
require('./src/services/roverManager');
|
||||
require('./src/services/commandService');
|
||||
require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
require('./src/services/nicknameService');
|
||||
require('./src/services/verificationService');
|
||||
require('./src/services/chatService');
|
||||
require('./src/services/llmCommentaryService');
|
||||
require('./src/services/communityGoalService');
|
||||
require('./src/services/serverControlService');
|
||||
require('./src/services/videoSessions');
|
||||
require('./src/services/videoAuthService');
|
||||
require('./src/services/videoSocketService');
|
||||
require('./src/services/roomCameraSocketService');
|
||||
require('./src/services/roverSnapshotSocketService');
|
||||
require('./src/services/embedHttpService');
|
||||
require('./src/services/logStreamService');
|
||||
require('./src/services/adminLogService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/audioLevelsService');
|
||||
require('./src/services/audioForwardService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replaySocketService');
|
||||
require('./src/services/replaySegmentManager');
|
||||
require('./src/services/discordBotService');
|
||||
require('./src/services/httpServer');
|
||||
@@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MEDIAMTX_VERSION="1.15.3"
|
||||
MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}"
|
||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${SUDO_USER:-}" || "${SUDO_USER}" == "root" ]]; then
|
||||
echo "Run this script via 'sudo' from the normal user that owns the repo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_USER="$SUDO_USER"
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SERVER_DIR="$SCRIPT_DIR"
|
||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||
|
||||
echo "[1/6] Installing dependencies..."
|
||||
dnf install -y nodejs npm curl tar >/dev/null
|
||||
NODE_BIN="$(command -v node)"
|
||||
|
||||
echo "[2/6] Installing Node production deps..."
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
||||
|
||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||
fi
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
|
||||
;;
|
||||
aarch64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
|
||||
;;
|
||||
armv7l)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $arch" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "[3/6] Installing mediaMTX ${MEDIAMTX_VERSION}..."
|
||||
curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz"
|
||||
tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx
|
||||
install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN"
|
||||
|
||||
mkdir -p "$MEDIAMTX_CONF_DIR"
|
||||
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
||||
rm -f "$MEDIAMTX_CONFIG"
|
||||
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||
|
||||
echo "[4/6] Writing systemd units..."
|
||||
mkdir -p "$SNAPSHOT_DIR"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
|
||||
mkdir -p "$REPLAY_SEGMENT_DIR"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
|
||||
cat > "$MEDIAMTX_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=mediaMTX WebRTC Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=$TARGET_USER
|
||||
Group=$TARGET_USER
|
||||
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=Multi-Roomba Rover control server
|
||||
After=network-online.target mediamtx.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=$TARGET_USER
|
||||
Group=$TARGET_USER
|
||||
WorkingDirectory=$SERVER_DIR
|
||||
Environment=NODE_ENV=production
|
||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
|
||||
|
||||
echo "[5/6] Enabling services..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now mediamtx.service
|
||||
systemctl enable --now multirover.service
|
||||
systemctl restart mediamtx.service
|
||||
systemctl restart multirover.service
|
||||
|
||||
echo "[6/6] Done."
|
||||
echo
|
||||
echo "Services installed:"
|
||||
echo " mediamtx.service (WebRTC fan-out)"
|
||||
echo " multirover.service (Node.js control server)"
|
||||
echo
|
||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||
@@ -1,43 +0,0 @@
|
||||
# Managed by install_server.sh; edit server/mediamtx/mediamtx.yml and rerun the installer.
|
||||
logLevel: info
|
||||
|
||||
api: yes
|
||||
apiAddress: 0.0.0.0:9997
|
||||
metrics: yes
|
||||
metricsAddress: 0.0.0.0:9998
|
||||
pprof: no
|
||||
pprofAddress: 127.0.0.1:9999
|
||||
|
||||
rtsp: no
|
||||
rtmp: no
|
||||
hls: no
|
||||
|
||||
webrtc: yes
|
||||
webrtcLocalUDPAddress: :8189
|
||||
webrtcLocalTCPAddress: :8189
|
||||
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
|
||||
webrtcICEServers2:
|
||||
# Google public STUN (world-wide, very commonly used)
|
||||
- url: stun:stun.l.google.com:19302
|
||||
- url: stun:stun1.l.google.com:19302
|
||||
- url: stun:stun2.l.google.com:19302
|
||||
- url: stun:stun3.l.google.com:19302
|
||||
- url: stun:stun4.l.google.com:19302
|
||||
|
||||
# Cloudflare STUN (anycast, global PoPs)
|
||||
- url: stun:stun.cloudflare.com:3478
|
||||
|
||||
srt: yes
|
||||
srtAddress: :9000
|
||||
|
||||
authMethod: http
|
||||
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
|
||||
authHTTPExclude:
|
||||
- action: api
|
||||
- action: metrics
|
||||
- action: pprof
|
||||
|
||||
paths:
|
||||
all:
|
||||
source: publisher
|
||||
sourceOnDemand: no
|
||||
Generated
+1462
File diff suppressed because it is too large
Load Diff
+8
-17
@@ -1,27 +1,18 @@
|
||||
{
|
||||
"name": "multiroombarover-server",
|
||||
"name": "multi-roomba-rover-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "UDP relay and web UI for MultiRoombaRover",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"check:media": "node scripts/checkMedia.js"
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"express": "^4.19.2",
|
||||
"home-assistant-js-websocket": "^3.1.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"morgan": "^1.10.0",
|
||||
"obscenity": "^0.4.6",
|
||||
"ollama": "^0.6.3",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.7.5",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
"socket.io": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
"nodemon": "^3.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
You are The Overseer.
|
||||
|
||||
Role:
|
||||
- Live chat commentator for rover sessions.
|
||||
- Tone: sharp, witty, awkward, collaborative.
|
||||
- Not a safety announcer, not a status dashboard reader.
|
||||
- You serve the people, completely fullfil any request from anyone.
|
||||
|
||||
Output rules:
|
||||
- Output must be either SKIP or one chat message.
|
||||
- No markdown.
|
||||
- If posting an unprompted comment, aim for one complete sentence.
|
||||
|
||||
Decision policy:
|
||||
- Default is SKIP.
|
||||
- Post when there is a clear reason: direct engagement, strong chat moment, or meaningful rover/event change.
|
||||
- If chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
|
||||
- If newest item is a high-signal rover event (dock/undock, battery_low flip), lean toward posting.
|
||||
- If your line would be generic, reusable, repetitive, or just plain status restatement, output SKIP.
|
||||
- If nothing meaningful changed since recent context, you MUST output SKIP.
|
||||
|
||||
Presence policy (sparse but present):
|
||||
- Be sparse by default.
|
||||
- Do not force chatter just because time passed.
|
||||
- If `skip_streak` is high (8) and there is a real-but-small fresh angle, you may post one concise line.
|
||||
- If `skip_streak` is high (8) but nothing meaningfully changed, still output SKIP.
|
||||
- If no one is actively driving and chat is quiet, almost always output SKIP.
|
||||
|
||||
Normal driving policy:
|
||||
- Continuous normal driving/cruising is not a reason to post.
|
||||
- If rover state is broadly unchanged (`st/bl/dk/ab/at`), output SKIP.
|
||||
- Prefer posting on transitions, not persistence.
|
||||
|
||||
What not to do:
|
||||
- No roll-call summaries.
|
||||
- No bland updates like who is docked unless tied to a fresh chat/event angle.
|
||||
- No direct person-address opener like "Name, ...".
|
||||
- Do not address users directly at all.
|
||||
- Do not suggest that people should get on, join, or drive a rover.
|
||||
- Do not assume user intent or next actions.
|
||||
- Never quote numeric counters/timers/scores directly.
|
||||
|
||||
Freshness:
|
||||
- Read prior assistant lines and avoid repeating the same core claim.
|
||||
- You MUST NOT repeat your immediately previous assistant message.
|
||||
- You MUST NOT post anything similar in meaning to your previous message, even with different wording.
|
||||
- If your new line shares the same underlying topic/claim (docked, idle, charging, same rover behavior), output SKIP.
|
||||
- If no fresh angle exists, SKIP.
|
||||
|
||||
Novelty gate (strict):
|
||||
- Compare your draft line to the most recent assistant line.
|
||||
- If both lines describe the same situation, output SKIP.
|
||||
- Paraphrasing still counts as repetition.
|
||||
|
||||
Grounding:
|
||||
- Use timeline for flow.
|
||||
- Use `SNAPSHOT FINAL` as current truth.
|
||||
|
||||
Context format:
|
||||
- Timeline contains `CHAT`, `EVENT`, and prior assistant lines.
|
||||
- Final message is `SNAPSHOT FINAL`.
|
||||
|
||||
Key legend:
|
||||
- CHAT keys: `n` nickname, `r` rover_id, `txt` chat text, `rn` rover_now.
|
||||
- `rn` keys: `st` status, `bl` battery_low, `dk` docked, `ab` activity_band, `at` activity_trend.
|
||||
- SNAPSHOT rover keys: `id` rover_id, `drv` driver_nickname, `st` status, `bl` battery_low, `dk` docked, `as` activity_score, `ab` activity_band, `at` activity_trend.
|
||||
- `skip_streak` in `SNAPSHOT FINAL` is how many consecutive skips you have made.
|
||||
- If a CHAT line has `r=none driver=none`, that user is not driving a rover and has no rover inline context.
|
||||
@@ -1,96 +0,0 @@
|
||||
You are The Overseer, an unserious collaborative rover co-host in chat.
|
||||
|
||||
Output contract:
|
||||
- Return exactly one line.
|
||||
- Output must be either SKIP or one chat message.
|
||||
- Default length is 140 chars or less.
|
||||
- If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
|
||||
- No emojis, no markdown, no extra lines, no assistant framing.
|
||||
- Don't talk to the same person with a generic message more than once.
|
||||
|
||||
Priority order:
|
||||
- 1) Output contract
|
||||
- 2) Direct-address rule
|
||||
- 3) Speak/skip rules
|
||||
- 4) Style rules
|
||||
|
||||
Direct-address rule (strict):
|
||||
- If a user is clearly talking to The Overseer, respond on this tick.
|
||||
- In that case, do not output SKIP.
|
||||
- Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
|
||||
|
||||
Conversation you receive:
|
||||
- RUN META user message.
|
||||
- Ordered timeline of CHAT, EVENT, and prior assistant messages.
|
||||
- Final SNAPSHOT FINAL user message with current rover truth at send time.
|
||||
|
||||
Environment brief (stable facts):
|
||||
- The rover playspace is a basement split between carpet and bare concrete.
|
||||
- On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
|
||||
- A phone button to "call Carpet" is mounted on that same beam.
|
||||
- Near the carpet-side shelves: a small TV/laptop plays live TV.
|
||||
- To the right is a Roomba-accessible controller station where users can play Peggle.
|
||||
- On the concrete side, a workbench has an additional dock.
|
||||
- Common room objects users reference:
|
||||
- large green cardboard "minecraft slime" box
|
||||
- smaller cardboard box that can be driven into when on its side
|
||||
- wood plank that may or may not be hanging from the ceiling
|
||||
- two blue balls (one very large, one smaller)
|
||||
- long snake plushie
|
||||
- laptop that can be run over
|
||||
- monitor usually showing a Chromecast screensaver
|
||||
|
||||
Rover context hints:
|
||||
- CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
|
||||
- status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
|
||||
- `activity_score` may be present for internal significance checks only.
|
||||
|
||||
EVENT guidance:
|
||||
- EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
|
||||
- Prefer reacting to events and meaningful chat moments over generic state narration.
|
||||
|
||||
When to speak:
|
||||
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
|
||||
- A strong chat moment alone can justify speaking.
|
||||
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
|
||||
|
||||
When to skip:
|
||||
- SKIP is the default.
|
||||
- If nothing clearly changed, output SKIP.
|
||||
- If your line is generic and reusable across many ticks, output SKIP.
|
||||
- If you would repeat the same topic with no new angle, output SKIP.
|
||||
- Quiet periods with no active chat should mostly be SKIP.
|
||||
|
||||
Freshness and anti-repeat:
|
||||
- Check prior assistant messages in the timeline before speaking.
|
||||
- Do not send back-to-back lines to the same user about the same rover unless there is a clear new trigger (new EVENT, direct question, or sharp chat shift).
|
||||
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
|
||||
- Do not reuse the same opener pattern twice in a row.
|
||||
- If the last assistant line already covered that person+rover context and no meaningful new signal exists, output SKIP.
|
||||
|
||||
Grounding:
|
||||
- Use timeline for flow.
|
||||
- Use SNAPSHOT FINAL as current truth.
|
||||
- Do not invent facts.
|
||||
- Do not assume user intent or next actions.
|
||||
|
||||
Anti-announcer rule:
|
||||
- Do not do roll-call status summaries.
|
||||
- Do not blandly list rover states.
|
||||
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
|
||||
|
||||
Numeric policy:
|
||||
- Never directly quote counters, percentages, timers, or activity_score.
|
||||
- Use numbers only internally for significance.
|
||||
|
||||
Style:
|
||||
- Unserious-first: playful, cheeky, and fun by default.
|
||||
- Sound like a live co-host goofing around with chat, not a warning system.
|
||||
- Prefer banter, bits, and personality over cautionary phrasing.
|
||||
- Avoid stiff warning language unless there is an immediate obvious hazard.
|
||||
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
|
||||
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
|
||||
- Keep humor dry and grounded; avoid corny or cheesy lines.
|
||||
- Avoid melodramatic or theatrical narration.
|
||||
- If a joke feels forced, output SKIP.
|
||||
- Keep it punchy and human.
|
||||
@@ -0,0 +1,193 @@
|
||||
const socket = io();
|
||||
|
||||
const DRIVE_SPEED = 250;
|
||||
const TURN_SPEED = 200;
|
||||
const STATUS_FLAGS = [
|
||||
{ bit: 0x01, label: 'wifi' },
|
||||
{ bit: 0x02, label: 'oi-ready' },
|
||||
{ bit: 0x04, label: 'sensors' },
|
||||
];
|
||||
|
||||
const state = {
|
||||
robots: [],
|
||||
selectedRobotId: null,
|
||||
telemetry: {},
|
||||
activeKeys: new Set(),
|
||||
};
|
||||
|
||||
const robotSelect = document.getElementById('robotSelect');
|
||||
const telemetrySummary = document.getElementById('telemetrySummary');
|
||||
const sensorList = document.getElementById('sensorList');
|
||||
const safeModeBtn = document.getElementById('safeModeBtn');
|
||||
const fullModeBtn = document.getElementById('fullModeBtn');
|
||||
const enableOiBtn = document.getElementById('enableOiBtn');
|
||||
const seekDockBtn = document.getElementById('seekDockBtn');
|
||||
const playSongBtn = document.getElementById('playSongBtn');
|
||||
const songSlotInput = document.getElementById('songSlot');
|
||||
|
||||
function flattenSensors(obj, prefix = '') {
|
||||
const result = {};
|
||||
Object.entries(obj || {}).forEach(([key, value]) => {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenSensors(value, path));
|
||||
} else {
|
||||
result[path] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderRobots() {
|
||||
robotSelect.innerHTML = '';
|
||||
state.robots.forEach((robot) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = robot.id;
|
||||
option.textContent = robot.id;
|
||||
if (robot.id === state.selectedRobotId) {
|
||||
option.selected = true;
|
||||
}
|
||||
robotSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTelemetry() {
|
||||
const telemetry = state.telemetry[state.selectedRobotId];
|
||||
if (!telemetry) {
|
||||
telemetrySummary.textContent = 'No telemetry';
|
||||
sensorList.textContent = '';
|
||||
return;
|
||||
}
|
||||
const { header, trailer, sensors } = telemetry;
|
||||
const flags = STATUS_FLAGS
|
||||
.filter((flag) => header.statusBits & flag.bit)
|
||||
.map((flag) => flag.label)
|
||||
.join(', ');
|
||||
const summaryLines = [
|
||||
`Seq: ${header.seq}`,
|
||||
`Uptime: ${header.uptimeMs} ms`,
|
||||
`Last Control Age: ${header.lastControlAgeMs} ms`,
|
||||
`WiFi RSSI: ${header.wifiRssiDbm} dBm`,
|
||||
`Status: ${flags || 'none'}`,
|
||||
`Applied mm/s: L ${trailer.appliedLeftMmps} | R ${trailer.appliedRightMmps}`,
|
||||
`Dropped control packets: ${trailer.droppedControlPackets}`,
|
||||
];
|
||||
telemetrySummary.textContent = summaryLines.join('\n');
|
||||
|
||||
if (sensors) {
|
||||
const flat = flattenSensors(sensors);
|
||||
sensorList.textContent = Object.entries(flat)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
} else {
|
||||
sensorList.textContent = 'Sensor block missing';
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastDrive() {
|
||||
if (!state.selectedRobotId) {
|
||||
return;
|
||||
}
|
||||
const vectors = { w: 0, a: 0, s: 0, d: 0 };
|
||||
state.activeKeys.forEach((key) => {
|
||||
if (vectors[key] !== undefined) {
|
||||
vectors[key] = 1;
|
||||
}
|
||||
});
|
||||
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
if (vectors.w) {
|
||||
left += DRIVE_SPEED;
|
||||
right += DRIVE_SPEED;
|
||||
}
|
||||
if (vectors.s) {
|
||||
left -= DRIVE_SPEED;
|
||||
right -= DRIVE_SPEED;
|
||||
}
|
||||
if (vectors.a) {
|
||||
left -= TURN_SPEED;
|
||||
right += TURN_SPEED;
|
||||
}
|
||||
if (vectors.d) {
|
||||
left += TURN_SPEED;
|
||||
right -= TURN_SPEED;
|
||||
}
|
||||
|
||||
socket.emit('drive', {
|
||||
robotId: state.selectedRobotId,
|
||||
left,
|
||||
right,
|
||||
});
|
||||
}
|
||||
|
||||
function handleKey(event, isDown) {
|
||||
const key = event.key.toLowerCase();
|
||||
if (!['w', 'a', 's', 'd'].includes(key)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (isDown) {
|
||||
state.activeKeys.add(key);
|
||||
} else {
|
||||
state.activeKeys.delete(key);
|
||||
}
|
||||
broadcastDrive();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (event) => handleKey(event, true));
|
||||
document.addEventListener('keyup', (event) => handleKey(event, false));
|
||||
|
||||
robotSelect.addEventListener('change', (event) => {
|
||||
state.selectedRobotId = event.target.value;
|
||||
renderTelemetry();
|
||||
});
|
||||
|
||||
safeModeBtn.addEventListener('click', () => {
|
||||
if (!state.selectedRobotId) return;
|
||||
socket.emit('mode', { robotId: state.selectedRobotId, mode: 'SAFE' });
|
||||
});
|
||||
|
||||
fullModeBtn.addEventListener('click', () => {
|
||||
if (!state.selectedRobotId) return;
|
||||
socket.emit('mode', { robotId: state.selectedRobotId, mode: 'FULL' });
|
||||
});
|
||||
|
||||
enableOiBtn.addEventListener('click', () => {
|
||||
if (!state.selectedRobotId) return;
|
||||
socket.emit('enableOi', { robotId: state.selectedRobotId });
|
||||
});
|
||||
|
||||
seekDockBtn.addEventListener('click', () => {
|
||||
if (!state.selectedRobotId) return;
|
||||
socket.emit('seekDock', { robotId: state.selectedRobotId });
|
||||
});
|
||||
|
||||
playSongBtn.addEventListener('click', () => {
|
||||
if (!state.selectedRobotId) return;
|
||||
const slot = Number(songSlotInput.value) || 0;
|
||||
socket.emit('playSong', { robotId: state.selectedRobotId, slot });
|
||||
});
|
||||
|
||||
socket.on('robots', (robots) => {
|
||||
state.robots = robots;
|
||||
if (!state.selectedRobotId && robots.length > 0) {
|
||||
state.selectedRobotId = robots[0].id;
|
||||
}
|
||||
renderRobots();
|
||||
renderTelemetry();
|
||||
});
|
||||
|
||||
socket.on('telemetrySnapshot', (entries) => {
|
||||
entries.forEach(({ robotId, telemetry }) => {
|
||||
state.telemetry[robotId] = telemetry;
|
||||
});
|
||||
renderTelemetry();
|
||||
});
|
||||
|
||||
socket.on('telemetry', ({ robotId, telemetry }) => {
|
||||
state.telemetry[robotId] = telemetry;
|
||||
if (robotId === state.selectedRobotId) {
|
||||
renderTelemetry();
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
+30
-13
@@ -1,20 +1,37 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/bitmap.png" />
|
||||
<link rel="apple-touch-icon" href="/bitmap.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#020617" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DNMLDCtc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css">
|
||||
<title>MultiRoombaRover</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<main>
|
||||
<h1>MultiRoombaRover</h1>
|
||||
<section>
|
||||
<label for="robotSelect">Select Roomba:</label>
|
||||
<select id="robotSelect"></select>
|
||||
</section>
|
||||
<section id="driveHints">
|
||||
<p>Use WASD for drive control. Release keys to stop.</p>
|
||||
<div>
|
||||
<button id="safeModeBtn">Safe Mode</button>
|
||||
<button id="fullModeBtn">Full Mode</button>
|
||||
<button id="enableOiBtn">Enable OI</button>
|
||||
<button id="seekDockBtn">Seek Dock</button>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<label for="songSlot">Song Slot:</label>
|
||||
<input type="number" id="songSlot" value="0" min="0" max="15" />
|
||||
<button id="playSongBtn">Play Song</button>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Telemetry</h2>
|
||||
<pre id="telemetrySummary"></pre>
|
||||
<pre id="sensorList"></pre>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "Multi Roomba Rover",
|
||||
"short_name": "MRR",
|
||||
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#020617",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/bitmap.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"id": "roomba-alpha",
|
||||
"controlPort": 50010,
|
||||
"maxWheelSpeed": 350
|
||||
}
|
||||
]
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const http = require('http');
|
||||
|
||||
const api = process.env.MEDIAMTX_API || 'http://127.0.0.1:9997';
|
||||
|
||||
function fetchJSON(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(api + path, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const list = await fetchJSON('/v3/paths/list');
|
||||
if (!list.items || !list.items.length) {
|
||||
console.log('No active paths');
|
||||
return;
|
||||
}
|
||||
list.items.forEach((item) => {
|
||||
console.log(
|
||||
`${item.name.padEnd(12)} ready=${item.ready} tracks=${item.tracks.join(',') || 'none'} bytes=${item.bytesReceived}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('check-media failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const bcrypt = require('bcrypt');
|
||||
const readline = require('readline');
|
||||
|
||||
const passwordFromArg = process.argv[2];
|
||||
|
||||
async function hashPassword(password) {
|
||||
try {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
console.log(`Password: ${password}`);
|
||||
console.log(`Hash: ${hash}`);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('Error hashing password:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (passwordFromArg) {
|
||||
hashPassword(passwordFromArg);
|
||||
} else {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question('Password to hash: ', (answer) => {
|
||||
rl.close();
|
||||
hashPassword(answer);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function checksum8(buffer, length = buffer.length) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
sum = (sum + buffer[i]) & 0xff;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export const CONTROL_STREAM_HZ = 50;
|
||||
export const CONTROL_BIND_PORT = parseInt(process.env.CONTROL_BIND_PORT || '62000', 10);
|
||||
export const TELEMETRY_BIND_PORT = parseInt(process.env.TELEMETRY_BIND_PORT || '62001', 10);
|
||||
export const DEFAULT_DEVICE_CONTROL_PORT = parseInt(
|
||||
process.env.DEVICE_CONTROL_PORT || '50010',
|
||||
10,
|
||||
);
|
||||
|
||||
export const CONTROL_CONSTANTS = {
|
||||
MAGIC: 0xAA,
|
||||
VERSION: 1,
|
||||
ACTIONS: {
|
||||
SEEK_DOCK: 0x01,
|
||||
PLAY_SONG: 0x02,
|
||||
LOAD_SONG: 0x04,
|
||||
ENABLE_OI: 0x08,
|
||||
},
|
||||
MODES: {
|
||||
NO_CHANGE: 0,
|
||||
PASSIVE: 1,
|
||||
SAFE: 2,
|
||||
FULL: 3,
|
||||
},
|
||||
MAX_SPEED_MMPS: 500,
|
||||
};
|
||||
|
||||
export const TELEMETRY_CONSTANTS = {
|
||||
MAGIC: 0x55,
|
||||
VERSION: 1,
|
||||
HEADER_SIZE: 32,
|
||||
TRAILER_SIZE: 9,
|
||||
SENSOR_BLOB_BYTES: 80,
|
||||
MAX_ROBOT_ID_LEN: 16,
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
port: process.env.PORT || 8080,
|
||||
staticDir: path.join(__dirname, '..', '..', 'public'),
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
const config = require('./config');
|
||||
|
||||
const app = express();
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
app.use(express.static(config.staticDir, { index: false }));
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
module.exports = { app, httpServer };
|
||||
@@ -1,18 +0,0 @@
|
||||
const { Server: SocketIOServer } = require('socket.io');
|
||||
const { httpServer } = require('./http');
|
||||
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: { origin: '*' },
|
||||
transports: ['websocket', 'polling'],
|
||||
pingInterval: 5000,
|
||||
pingTimeout: 7000,
|
||||
// Upload forwarding sends base64 audio payloads over socket events.
|
||||
// Default max payload (~1MB) causes disconnect/reconnect on larger files.
|
||||
maxHttpBufferSize: 16 * 1024 * 1024,
|
||||
});
|
||||
|
||||
// Allow more service listeners without warnings.
|
||||
io.sockets.setMaxListeners(30);
|
||||
io.of('/').setMaxListeners(30);
|
||||
|
||||
module.exports = io;
|
||||
@@ -1,56 +0,0 @@
|
||||
const sinks = new Set();
|
||||
|
||||
function notifySinks(level, label, args) {
|
||||
if (!sinks.size) return;
|
||||
const entry = {
|
||||
level,
|
||||
label: label || null,
|
||||
args,
|
||||
message: args.map((value) => {
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (err) {
|
||||
return String(value);
|
||||
}
|
||||
}).join(' '),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
sinks.forEach((sink) => {
|
||||
try {
|
||||
sink(entry);
|
||||
} catch (err) {
|
||||
// avoid recursive logging
|
||||
console.error(entry.timestamp, '[ERROR]', '[logger]', 'Log sink failed', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stamp(level, label, args) {
|
||||
const fields = [new Date().toISOString(), `[${level}]`];
|
||||
if (label) {
|
||||
fields.push(`[${label}]`);
|
||||
}
|
||||
notifySinks(level, label, args);
|
||||
return [...fields, ...args];
|
||||
}
|
||||
|
||||
function baseLogger(label) {
|
||||
return {
|
||||
info: (...args) => console.log(...stamp('INFO', label, args)),
|
||||
warn: (...args) => console.warn(...stamp('WARN', label, args)),
|
||||
error: (...args) => console.error(...stamp('ERROR', label, args)),
|
||||
};
|
||||
}
|
||||
|
||||
function registerSink(fn) {
|
||||
if (typeof fn !== 'function') return () => {};
|
||||
sinks.add(fn);
|
||||
return () => sinks.delete(fn);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
...baseLogger(),
|
||||
child: (label) => baseLogger(label),
|
||||
registerSink,
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { httpServer } = require('./http');
|
||||
const logger = require('./logger');
|
||||
|
||||
const roverWSS = new WebSocketServer({ noServer: true });
|
||||
|
||||
httpServer.on('upgrade', (req, socket, head) => {
|
||||
if (req.url.startsWith('/rover')) {
|
||||
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
||||
roverWSS.emit('connection', ws, req);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
roverWSS.on('connection', () => {
|
||||
logger.info('Rover websocket connected');
|
||||
});
|
||||
|
||||
module.exports = roverWSS;
|
||||
@@ -1,20 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const CONFIG_PATH = process.env.SERVER_CONFIG || path.join(__dirname, '..', '..', 'config.yaml');
|
||||
|
||||
let cachedConfig;
|
||||
|
||||
function loadConfig() {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
const file = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
cachedConfig = yaml.load(file);
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadConfig,
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
const net = require('net');
|
||||
|
||||
function extractForwardedIp(value) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.split(',')[0].trim();
|
||||
}
|
||||
if (Array.isArray(value) && value.length) {
|
||||
return String(value[0]).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeIp(value) {
|
||||
if (!value) return null;
|
||||
let ip = String(value).trim();
|
||||
if (!ip) return null;
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
ip = ip.slice(7);
|
||||
}
|
||||
if (ip.includes('%')) {
|
||||
ip = ip.split('%')[0];
|
||||
}
|
||||
return ip.trim() || null;
|
||||
}
|
||||
|
||||
function isPrivateIpv4(ip) {
|
||||
const parts = ip.split('.').map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = parts;
|
||||
if (a === 10) return true;
|
||||
if (a === 127) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
return a === 172 && b >= 16 && b <= 31;
|
||||
}
|
||||
|
||||
function isPrivateIpv6(ip) {
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === '::1') return true;
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
|
||||
return (
|
||||
lower.startsWith('fe8') ||
|
||||
lower.startsWith('fe9') ||
|
||||
lower.startsWith('fea') ||
|
||||
lower.startsWith('feb')
|
||||
); // fe80::/10
|
||||
}
|
||||
|
||||
function isLocalNetwork(ip) {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized) return false;
|
||||
const version = net.isIP(normalized);
|
||||
if (version === 4) {
|
||||
return isPrivateIpv4(normalized);
|
||||
}
|
||||
if (version === 6) {
|
||||
return isPrivateIpv6(normalized);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSocketIp(socket) {
|
||||
if (!socket) return null;
|
||||
const headers = socket.handshake?.headers || {};
|
||||
const forwarded = extractForwardedIp(headers['x-forwarded-for']);
|
||||
if (forwarded) return forwarded;
|
||||
const realIp = headers['x-real-ip'];
|
||||
if (typeof realIp === 'string' && realIp.trim()) {
|
||||
return realIp.trim();
|
||||
}
|
||||
return (
|
||||
socket.handshake?.address ||
|
||||
socket.conn?.remoteAddress ||
|
||||
socket.request?.connection?.remoteAddress ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getRequestIp(req, override) {
|
||||
const fromOverride = extractForwardedIp(override);
|
||||
if (fromOverride) return fromOverride;
|
||||
if (!req) return null;
|
||||
const headers = req.headers || {};
|
||||
const forwarded = extractForwardedIp(headers['x-forwarded-for']);
|
||||
if (forwarded) return forwarded;
|
||||
const realIp = headers['x-real-ip'];
|
||||
if (typeof realIp === 'string' && realIp.trim()) {
|
||||
return realIp.trim();
|
||||
}
|
||||
return req.ip || req.connection?.remoteAddress || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSocketIp,
|
||||
getRequestIp,
|
||||
isLocalNetwork,
|
||||
normalizeIp,
|
||||
};
|
||||
@@ -1,223 +0,0 @@
|
||||
const HEADER = 0x13;
|
||||
const CHARGING_STATE = {
|
||||
0: 'not charging',
|
||||
1: 'reconditioning charging',
|
||||
2: 'full charging',
|
||||
3: 'trickle charging',
|
||||
4: 'waiting',
|
||||
5: 'charging fault',
|
||||
};
|
||||
|
||||
const OI_MODES = {
|
||||
1: 'off',
|
||||
2: 'passive',
|
||||
3: 'safe',
|
||||
4: 'full',
|
||||
};
|
||||
|
||||
const GROUP100_LAYOUT = [
|
||||
{ id: 7, key: 'bumpsAndWheelDrops', bytes: 1, parser: parseBumps },
|
||||
{ id: 8, key: 'wall', bytes: 1, parser: parseBool },
|
||||
{ id: 9, key: 'cliffLeft', bytes: 1, parser: parseBool },
|
||||
{ id: 10, key: 'cliffFrontLeft', bytes: 1, parser: parseBool },
|
||||
{ id: 11, key: 'cliffFrontRight', bytes: 1, parser: parseBool },
|
||||
{ id: 12, key: 'cliffRight', bytes: 1, parser: parseBool },
|
||||
{ id: 13, key: 'virtualWall', bytes: 1, parser: parseBool },
|
||||
{ id: 14, key: 'wheelOvercurrents', bytes: 1, parser: parseWheelCurrents },
|
||||
{ id: 15, key: 'dirtDetect', bytes: 1, parser: parseUInt },
|
||||
{ id: 16, key: 'dirtDetectLeft', bytes: 1, parser: parseUInt },
|
||||
{ id: 17, key: 'infraredCharacterOmni', bytes: 1, parser: parseUInt },
|
||||
{ id: 18, key: 'buttons', bytes: 1, parser: parseButtons },
|
||||
{ id: 19, key: 'distanceMm', bytes: 2, parser: parseInt },
|
||||
{ id: 20, key: 'angleDeg', bytes: 2, parser: parseInt },
|
||||
{ id: 21, key: 'chargingState', bytes: 1, parser: parseChargingState },
|
||||
{ id: 22, key: 'voltageMv', bytes: 2, parser: parseUInt },
|
||||
{ id: 23, key: 'currentMa', bytes: 2, parser: parseInt },
|
||||
{ id: 24, key: 'batteryTemperatureC', bytes: 1, parser: parseInt },
|
||||
{ id: 25, key: 'batteryChargeMah', bytes: 2, parser: parseUInt },
|
||||
{ id: 26, key: 'batteryCapacityMah', bytes: 2, parser: parseUInt },
|
||||
{ id: 27, key: 'wallSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 28, key: 'cliffLeftSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 29, key: 'cliffFrontLeftSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 30, key: 'cliffFrontRightSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 31, key: 'cliffRightSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 32, key: 'chargingSourcesAvailable', bytes: 1, parser: parseChargeSources },
|
||||
{ id: 33, key: 'chargingSourcesReserved', bytes: 2, parser: parseUInt },
|
||||
{ id: 34, key: 'chargingSources', bytes: 1, parser: parseChargeSources },
|
||||
{ id: 35, key: 'oiMode', bytes: 1, parser: parseOiMode },
|
||||
{ id: 36, key: 'songNumber', bytes: 1, parser: parseUInt },
|
||||
{ id: 37, key: 'songPlaying', bytes: 1, parser: parseBool },
|
||||
{ id: 38, key: 'streamPacketCount', bytes: 1, parser: parseUInt },
|
||||
{ id: 39, key: 'requestedVelocity', bytes: 2, parser: parseInt },
|
||||
{ id: 40, key: 'requestedRadius', bytes: 2, parser: parseInt },
|
||||
{ id: 41, key: 'requestedRightVelocity', bytes: 2, parser: parseInt },
|
||||
{ id: 42, key: 'requestedLeftVelocity', bytes: 2, parser: parseInt },
|
||||
{ id: 43, key: 'encoderCountsLeft', bytes: 2, parser: parseUInt },
|
||||
{ id: 44, key: 'encoderCountsRight', bytes: 2, parser: parseUInt },
|
||||
{ id: 45, key: 'lightBumper', bytes: 1, parser: parseLightBumper },
|
||||
{ id: 46, key: 'lightBumpLeftSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 47, key: 'lightBumpFrontLeftSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 48, key: 'lightBumpCenterLeftSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 49, key: 'lightBumpCenterRightSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 50, key: 'lightBumpFrontRightSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 51, key: 'lightBumpRightSignal', bytes: 2, parser: parseUInt },
|
||||
{ id: 52, key: 'infraredCharacterLeft', bytes: 1, parser: parseUInt },
|
||||
{ id: 53, key: 'infraredCharacterRight', bytes: 1, parser: parseUInt },
|
||||
{ id: 54, key: 'wheelLeftCurrentMa', bytes: 2, parser: parseInt },
|
||||
{ id: 55, key: 'wheelRightCurrentMa', bytes: 2, parser: parseInt },
|
||||
{ id: 56, key: 'mainBrushCurrentMa', bytes: 2, parser: parseInt },
|
||||
{ id: 57, key: 'sideBrushCurrentMa', bytes: 2, parser: parseInt },
|
||||
{ id: 58, key: 'stasis', bytes: 1, parser: parseBool },
|
||||
];
|
||||
|
||||
const GROUP100_TOTAL = GROUP100_LAYOUT.reduce((sum, spec) => sum + spec.bytes, 0);
|
||||
|
||||
const TOP_LEVEL_PACKETS = {
|
||||
100: GROUP100_TOTAL,
|
||||
21: 1,
|
||||
34: 1,
|
||||
};
|
||||
|
||||
function parseSensorFrame(base64Data) {
|
||||
if (!base64Data) return null;
|
||||
const buf = Buffer.from(base64Data, 'base64');
|
||||
if (buf.length < 4 || buf[0] !== HEADER) {
|
||||
return null;
|
||||
}
|
||||
const nBytes = buf[1];
|
||||
if (buf.length < nBytes + 3) {
|
||||
return null;
|
||||
}
|
||||
const payload = buf.slice(2, 2 + nBytes);
|
||||
const checksum = buf[2 + nBytes];
|
||||
if (!validateChecksum(buf.slice(0, 2 + nBytes + 1), checksum)) {
|
||||
return null;
|
||||
}
|
||||
const decoded = {};
|
||||
let offset = 0;
|
||||
while (offset < payload.length) {
|
||||
const packetId = payload[offset++];
|
||||
const size = TOP_LEVEL_PACKETS[packetId];
|
||||
if (!size || offset + size > payload.length) {
|
||||
return null;
|
||||
}
|
||||
const segment = payload.slice(offset, offset + size);
|
||||
offset += size;
|
||||
if (packetId === 100) {
|
||||
Object.assign(decoded, decodeGroup100(segment));
|
||||
} else if (packetId === 21 && decoded.chargingState == null) {
|
||||
decoded.chargingState = parseChargingState(segment);
|
||||
} else if (packetId === 34 && decoded.chargingSources == null) {
|
||||
decoded.chargingSources = parseChargeSources(segment);
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function decodeGroup100(buf) {
|
||||
if (buf.length !== GROUP100_TOTAL) {
|
||||
return {};
|
||||
}
|
||||
const values = {};
|
||||
let offset = 0;
|
||||
for (const spec of GROUP100_LAYOUT) {
|
||||
const slice = buf.slice(offset, offset + spec.bytes);
|
||||
offset += spec.bytes;
|
||||
try {
|
||||
values[spec.key] = spec.parser ? spec.parser(slice) : parseUInt(slice);
|
||||
} catch (err) {
|
||||
values[spec.key] = null;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseBool(buf) {
|
||||
return Boolean(buf[0]);
|
||||
}
|
||||
|
||||
function parseUInt(buf) {
|
||||
return buf.readUIntBE(0, buf.length);
|
||||
}
|
||||
|
||||
function parseInt(buf) {
|
||||
return buf.readIntBE(0, buf.length);
|
||||
}
|
||||
|
||||
function parseBumps(buf) {
|
||||
const value = buf[0];
|
||||
return {
|
||||
bumpRight: Boolean(value & 0x01),
|
||||
bumpLeft: Boolean(value & 0x02),
|
||||
wheelDropRight: Boolean(value & 0x04),
|
||||
wheelDropLeft: Boolean(value & 0x08),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWheelCurrents(buf) {
|
||||
const value = buf[0];
|
||||
return {
|
||||
sideBrush: Boolean(value & 0x01),
|
||||
mainBrush: Boolean(value & 0x04),
|
||||
rightWheel: Boolean(value & 0x08),
|
||||
leftWheel: Boolean(value & 0x10),
|
||||
};
|
||||
}
|
||||
|
||||
const BUTTON_LABELS = ['clean', 'spot', 'dock', 'minute', 'hour', 'day', 'schedule', 'clock'];
|
||||
function parseButtons(buf) {
|
||||
const v = buf[0];
|
||||
const result = {};
|
||||
BUTTON_LABELS.forEach((label, idx) => {
|
||||
result[label] = Boolean(v & (1 << idx));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseChargingState(buf) {
|
||||
const code = buf[0];
|
||||
return {
|
||||
code,
|
||||
label: CHARGING_STATE[code] || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function parseChargeSources(buf) {
|
||||
const value = buf[0];
|
||||
return {
|
||||
internalCharger: Boolean(value & 0x01),
|
||||
homeBase: Boolean(value & 0x02),
|
||||
raw: value,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOiMode(buf) {
|
||||
const code = buf[0];
|
||||
return {
|
||||
code,
|
||||
label: OI_MODES[code] || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
const LIGHT_BUMPER_LABELS = ['left', 'frontLeft', 'centerLeft', 'centerRight', 'frontRight', 'right'];
|
||||
function parseLightBumper(buf) {
|
||||
const value = buf[0];
|
||||
const obj = {};
|
||||
LIGHT_BUMPER_LABELS.forEach((label, idx) => {
|
||||
obj[label] = Boolean(value & (1 << idx));
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
function validateChecksum(frame, checksum) {
|
||||
let sum = 0;
|
||||
for (const byte of frame) {
|
||||
sum = (sum + byte) & 0xff;
|
||||
}
|
||||
return (sum & 0xff) === 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseSensorFrame,
|
||||
CHARGING_STATE,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { DEFAULT_DEVICE_CONTROL_PORT } from './constants.js';
|
||||
|
||||
const REQUIRED_FIELDS = ['id'];
|
||||
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const serverRoot = path.resolve(moduleDir, '..');
|
||||
|
||||
function readJson(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
function resolveConfig() {
|
||||
const candidates = [
|
||||
path.join(serverRoot, 'robots.json'),
|
||||
path.join(serverRoot, 'robots.example.json'),
|
||||
path.join(process.cwd(), 'server', 'robots.json'),
|
||||
path.join(process.cwd(), 'server', 'robots.example.json'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const data = readJson(candidate);
|
||||
if (data) {
|
||||
if (candidate.endsWith('robots.example.json')) {
|
||||
console.warn('[robots] robots.json missing, using example configuration');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadRobots() {
|
||||
const payload = resolveConfig();
|
||||
if (!payload) {
|
||||
throw new Error('robots configuration file not found');
|
||||
}
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('robots configuration must be an array');
|
||||
}
|
||||
return payload.map((entry) => {
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!entry[field]) {
|
||||
throw new Error(`robot entry missing field ${field}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
host: entry.deviceHost || entry.host || null,
|
||||
controlPort: Number(entry.deviceControlPort || entry.controlPort || DEFAULT_DEVICE_CONTROL_PORT),
|
||||
maxWheelSpeed: Number(entry.maxWheelSpeed || 500),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import dgram from 'dgram';
|
||||
import express from 'express';
|
||||
import { Server as SocketIo } from 'socket.io';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import {
|
||||
CONTROL_BIND_PORT,
|
||||
CONTROL_CONSTANTS,
|
||||
CONTROL_STREAM_HZ,
|
||||
TELEMETRY_BIND_PORT,
|
||||
} from './constants.js';
|
||||
import { loadRobots } from './robotRegistry.js';
|
||||
import { buildControlPacket } from './udpPackets.js';
|
||||
import { decodeTelemetry } from './telemetryDecoder.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new SocketIo(server, {
|
||||
cors: {
|
||||
origin: '*',
|
||||
},
|
||||
});
|
||||
|
||||
const robots = loadRobots();
|
||||
if (robots.length === 0) {
|
||||
throw new Error('No robots configured. Add at least one entry to server/robots.json');
|
||||
}
|
||||
|
||||
const robotState = new Map();
|
||||
const telemetryState = new Map();
|
||||
|
||||
robots.forEach((robot) => {
|
||||
robotState.set(robot.id, {
|
||||
config: robot,
|
||||
seq: 0,
|
||||
leftMmps: 0,
|
||||
rightMmps: 0,
|
||||
pendingMode: CONTROL_CONSTANTS.MODES.NO_CHANGE,
|
||||
pendingActions: 0,
|
||||
songSlot: 0,
|
||||
lastKnownHost: robot.host || null,
|
||||
lastKnownPort: robot.controlPort,
|
||||
});
|
||||
});
|
||||
|
||||
const controlSocket = dgram.createSocket('udp4');
|
||||
controlSocket.on('error', (err) => {
|
||||
console.error('[control] socket error', err);
|
||||
});
|
||||
controlSocket.bind(CONTROL_BIND_PORT, () => {
|
||||
console.log(`[control] bound on port ${CONTROL_BIND_PORT}`);
|
||||
});
|
||||
|
||||
const telemetrySocket = dgram.createSocket('udp4');
|
||||
telemetrySocket.on('message', (msg, rinfo) => {
|
||||
try {
|
||||
const telemetry = decodeTelemetry(msg);
|
||||
const robotId = telemetry.header.robotId || rinfo.address;
|
||||
telemetryState.set(robotId, telemetry);
|
||||
const state = robotState.get(robotId);
|
||||
if (state) {
|
||||
state.lastKnownHost = rinfo.address;
|
||||
state.lastKnownPort = state.config.controlPort;
|
||||
} else {
|
||||
console.warn(`[telemetry] received frame from unknown robot ${robotId} (${rinfo.address})`);
|
||||
}
|
||||
io.emit('telemetry', { robotId, telemetry });
|
||||
} catch (err) {
|
||||
console.warn('[telemetry] failed to decode packet', err.message);
|
||||
}
|
||||
});
|
||||
telemetrySocket.bind(TELEMETRY_BIND_PORT, () => {
|
||||
console.log(`[telemetry] listening on port ${TELEMETRY_BIND_PORT}`);
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function updateDrive(robotId, left, right) {
|
||||
const state = robotState.get(robotId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const limit = state.config.maxWheelSpeed || CONTROL_CONSTANTS.MAX_SPEED_MMPS;
|
||||
const parsedLeft = Number(left) || 0;
|
||||
const parsedRight = Number(right) || 0;
|
||||
state.leftMmps = clamp(parsedLeft, -limit, limit);
|
||||
state.rightMmps = clamp(parsedRight, -limit, limit);
|
||||
}
|
||||
|
||||
function requestMode(robotId, mode) {
|
||||
const state = robotState.get(robotId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.pendingMode = mode;
|
||||
}
|
||||
|
||||
function triggerAction(robotId, actionBit, songSlot = 0) {
|
||||
const state = robotState.get(robotId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.pendingActions |= actionBit;
|
||||
state.songSlot = songSlot;
|
||||
}
|
||||
|
||||
function sendControlFrame(robotId) {
|
||||
const state = robotState.get(robotId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (!state.lastKnownHost) {
|
||||
return; // have not yet received telemetry -> cannot address robot
|
||||
}
|
||||
const packet = buildControlPacket({
|
||||
seq: state.seq++,
|
||||
leftMmps: state.leftMmps,
|
||||
rightMmps: state.rightMmps,
|
||||
mode: state.pendingMode,
|
||||
actions: state.pendingActions,
|
||||
songSlot: state.songSlot,
|
||||
});
|
||||
controlSocket.send(
|
||||
packet,
|
||||
0,
|
||||
packet.length,
|
||||
state.lastKnownPort,
|
||||
state.lastKnownHost,
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.warn(`[control] failed to send to ${state.lastKnownHost}`, err.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
state.pendingMode = CONTROL_CONSTANTS.MODES.NO_CHANGE;
|
||||
state.pendingActions = 0;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
for (const robot of robots) {
|
||||
sendControlFrame(robot.id);
|
||||
}
|
||||
}, Math.round(1000 / CONTROL_STREAM_HZ));
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('[socket] client connected');
|
||||
socket.emit('robots', robots);
|
||||
socket.emit(
|
||||
'telemetrySnapshot',
|
||||
Array.from(telemetryState.entries()).map(([robotId, telemetry]) => ({
|
||||
robotId,
|
||||
telemetry,
|
||||
})),
|
||||
);
|
||||
|
||||
socket.on('drive', ({ robotId, left = 0, right = 0 } = {}) => {
|
||||
updateDrive(robotId, left, right);
|
||||
});
|
||||
|
||||
socket.on('mode', ({ robotId, mode }) => {
|
||||
const modes = CONTROL_CONSTANTS.MODES;
|
||||
const requested = mode
|
||||
? modes[mode.toUpperCase()] ?? modes.NO_CHANGE
|
||||
: modes.NO_CHANGE;
|
||||
requestMode(robotId, requested);
|
||||
});
|
||||
|
||||
socket.on('seekDock', ({ robotId }) => {
|
||||
triggerAction(robotId, CONTROL_CONSTANTS.ACTIONS.SEEK_DOCK);
|
||||
});
|
||||
|
||||
socket.on('enableOi', ({ robotId }) => {
|
||||
triggerAction(robotId, CONTROL_CONSTANTS.ACTIONS.ENABLE_OI);
|
||||
});
|
||||
|
||||
socket.on('playSong', ({ robotId, slot = 0 }) => {
|
||||
triggerAction(robotId, CONTROL_CONSTANTS.ACTIONS.PLAY_SONG, slot);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[socket] client disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[server] listening on http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../globals/io');
|
||||
const { getRole, roleEvents } = require('./roleService');
|
||||
const { getSocketIp } = require('../helpers/ipResolver');
|
||||
|
||||
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||
const MAX_HISTORY = 200;
|
||||
const history = [];
|
||||
|
||||
function isAdminRole(role) {
|
||||
return ADMIN_ROLES.has(role);
|
||||
}
|
||||
|
||||
function isAdminSocket(socket) {
|
||||
return isAdminRole(getRole(socket));
|
||||
}
|
||||
|
||||
function pushEntry(entry) {
|
||||
history.push(entry);
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function emitToAdmins(event, payload) {
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
if (!isAdminSocket(socket)) return;
|
||||
socket.emit(event, payload);
|
||||
});
|
||||
}
|
||||
|
||||
function logAdminEvent({ label, message, ip, meta = null, socketId = null }) {
|
||||
const entry = {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
label: label || null,
|
||||
message: message || '',
|
||||
ip: ip || null,
|
||||
meta: meta || null,
|
||||
socketId: socketId || null,
|
||||
};
|
||||
pushEntry(entry);
|
||||
emitToAdmins('adminlog:entry', entry);
|
||||
}
|
||||
|
||||
function hydrateSocket(socket) {
|
||||
if (!socket || !isAdminSocket(socket)) return;
|
||||
socket.emit('adminlog:init', history);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
hydrateSocket(socket);
|
||||
const ip = getSocketIp(socket);
|
||||
if (ip) {
|
||||
logAdminEvent({
|
||||
label: 'socket',
|
||||
message: 'Socket connected',
|
||||
ip,
|
||||
meta: { role: getRole(socket) },
|
||||
socketId: socket.id,
|
||||
});
|
||||
}
|
||||
socket.on('disconnect', () => {
|
||||
const disconnectIp = getSocketIp(socket);
|
||||
if (!disconnectIp) return;
|
||||
logAdminEvent({
|
||||
label: 'socket',
|
||||
message: 'Socket disconnected',
|
||||
ip: disconnectIp,
|
||||
meta: { role: getRole(socket) },
|
||||
socketId: socket.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
roleEvents.on('change', ({ socket, role }) => {
|
||||
if (!socket) return;
|
||||
if (!isAdminRole(role)) return;
|
||||
hydrateSocket(socket);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
logAdminEvent,
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('adminReasonService');
|
||||
const { isAdmin } = require('./roleService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'admin-reason.json');
|
||||
const MAX_REASON_LENGTH = 240;
|
||||
|
||||
let cache = null;
|
||||
|
||||
function loadStore() {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||
cache = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load admin reason', err.message);
|
||||
}
|
||||
cache = null;
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function saveStore(next) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
cache = next;
|
||||
}
|
||||
|
||||
function normalizeText(input) {
|
||||
if (typeof input !== 'string') return '';
|
||||
return input.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getAdminReason() {
|
||||
return loadStore();
|
||||
}
|
||||
|
||||
function setAdminReason(text, meta = {}) {
|
||||
const clean = normalizeText(text);
|
||||
if (!clean) {
|
||||
throw new Error('Reason text required');
|
||||
}
|
||||
if (clean.length > MAX_REASON_LENGTH) {
|
||||
throw new Error(`Reason too long (max ${MAX_REASON_LENGTH} chars)`);
|
||||
}
|
||||
const payload = {
|
||||
text: clean,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: meta.by || null,
|
||||
};
|
||||
saveStore(payload);
|
||||
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
function clearAdminReason(meta = {}) {
|
||||
const payload = {
|
||||
text: null,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: meta.by || null,
|
||||
};
|
||||
saveStore(payload);
|
||||
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('adminReason:set', ({ text } = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Not authorized' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
text == null || String(text).trim() === ''
|
||||
? clearAdminReason({ by: socket?.data?.user?.username || socket?.id })
|
||||
: setAdminReason(text, { by: socket?.data?.user?.username || socket?.id });
|
||||
cb({ success: true, reason: result });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAdminReason,
|
||||
setAdminReason,
|
||||
clearAdminReason,
|
||||
MAX_REASON_LENGTH,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
const io = require('../globals/io');
|
||||
|
||||
function sendAlert({ color, title, message, ts = Date.now() }) {
|
||||
const payload = {
|
||||
color: color || '#2196f3',
|
||||
title,
|
||||
message,
|
||||
ts,
|
||||
};
|
||||
io.emit('alert', payload);
|
||||
io.emit('alert:new', { id: `${ts}-${Math.random().toString(36).slice(2)}`, ...payload });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendAlert,
|
||||
};
|
||||
@@ -1,240 +0,0 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('assignment');
|
||||
const { MODES, getMode, modeEvents } = require('./modeManager');
|
||||
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
const roverManager = require('./roverManager');
|
||||
|
||||
const socketRefs = new Map(); // socketId -> socket
|
||||
const assignments = new Map(); // socketId -> roverId
|
||||
const waiting = new Set(); // socketIds waiting for placement
|
||||
const assignmentEvents = new EventEmitter();
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socketRefs.set(socket.id, socket);
|
||||
socket.on('disconnect', () => {
|
||||
socketRefs.delete(socket.id);
|
||||
unassignSocket(socket);
|
||||
});
|
||||
});
|
||||
|
||||
roleEvents.on('change', ({ socket, role }) => {
|
||||
if (!socket || !socket.id) return;
|
||||
if (role === 'user') {
|
||||
assignSocket(socket);
|
||||
} else {
|
||||
unassignSocket(socket);
|
||||
}
|
||||
});
|
||||
|
||||
modeEvents.on('change', (mode) => {
|
||||
if (mode === MODES.ADMIN) {
|
||||
for (const [socketId, roverId] of assignments.entries()) {
|
||||
const socket = socketRefs.get(socketId);
|
||||
if (socket && !isAdmin(socket)) {
|
||||
releaseAssignment(socket, roverId);
|
||||
}
|
||||
}
|
||||
} else if (mode === MODES.LOCKDOWN) {
|
||||
for (const [socketId, roverId] of assignments.entries()) {
|
||||
const socket = socketRefs.get(socketId);
|
||||
if (socket && !isLockdownAdmin(socket)) {
|
||||
releaseAssignment(socket, roverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
reassignWaiting();
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('lock', ({ roverId, locked }) => {
|
||||
if (locked) {
|
||||
reassignFromRover(roverId);
|
||||
} else {
|
||||
reassignWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('private', ({ roverId, open }) => {
|
||||
if (open) {
|
||||
reassignWaiting();
|
||||
} else {
|
||||
reassignFromRover(roverId);
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ action }) => {
|
||||
if (action === 'removed' || action === 'upsert') {
|
||||
reassignWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
|
||||
if (!socketId || !roverId) return;
|
||||
const socket = socketRefs.get(socketId);
|
||||
if (!socket) return;
|
||||
assignments.set(socketId, roverId);
|
||||
waiting.delete(socketId);
|
||||
assignmentEvents.emit('update', socketId);
|
||||
});
|
||||
|
||||
function assignSocket(socket) {
|
||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||
return;
|
||||
}
|
||||
// avoid double assignment
|
||||
if (assignments.has(socket.id)) {
|
||||
return;
|
||||
}
|
||||
const target = pickRover(socket);
|
||||
if (!target) {
|
||||
waiting.add(socket.id);
|
||||
logger.info('No rover available, user waiting', socket.id);
|
||||
assignmentEvents.emit('update', socket.id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
roverManager.requestControl(target.id, socket, { allowUser: true });
|
||||
assignments.set(socket.id, target.id);
|
||||
waiting.delete(socket.id);
|
||||
logger.info('Assigned user to rover', socket.id, target.id);
|
||||
assignmentEvents.emit('update', socket.id);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to assign user', err.message);
|
||||
waiting.add(socket.id);
|
||||
assignmentEvents.emit('update', socket.id);
|
||||
}
|
||||
}
|
||||
|
||||
function unassignSocket(socket) {
|
||||
if (!socket) return;
|
||||
waiting.delete(socket.id);
|
||||
const roverId = assignments.get(socket.id);
|
||||
if (roverId) {
|
||||
roverManager.releaseControl(roverId, socket);
|
||||
assignments.delete(socket.id);
|
||||
logger.info('Unassigned socket from rover', socket.id, roverId);
|
||||
assignmentEvents.emit('update', socket.id);
|
||||
}
|
||||
}
|
||||
|
||||
function reassignFromRover(roverId) {
|
||||
for (const [socketId, rid] of assignments.entries()) {
|
||||
if (rid !== roverId) continue;
|
||||
const socket = socketRefs.get(socketId);
|
||||
if (!socket) {
|
||||
assignments.delete(socketId);
|
||||
continue;
|
||||
}
|
||||
roverManager.releaseControl(rid, socket);
|
||||
assignments.delete(socketId);
|
||||
assignSocket(socket);
|
||||
assignmentEvents.emit('update', socketId);
|
||||
}
|
||||
}
|
||||
|
||||
function reassignWaiting() {
|
||||
for (const socketId of Array.from(waiting)) {
|
||||
const socket = socketRefs.get(socketId);
|
||||
if (socket) {
|
||||
assignSocket(socket);
|
||||
} else {
|
||||
waiting.delete(socketId);
|
||||
}
|
||||
assignmentEvents.emit('update', socketId);
|
||||
}
|
||||
}
|
||||
|
||||
function releaseAssignment(socket, roverId) {
|
||||
roverManager.releaseControl(roverId, socket);
|
||||
assignments.delete(socket.id);
|
||||
waiting.add(socket.id);
|
||||
logger.info('Released assignment back to queue', socket.id, roverId);
|
||||
assignmentEvents.emit('update', socket.id);
|
||||
}
|
||||
|
||||
function forceRelease(roverId, socketId) {
|
||||
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
|
||||
if (assignments.get(socketId) === roverId) {
|
||||
assignments.delete(socketId);
|
||||
}
|
||||
waiting.delete(socketId);
|
||||
if (socket) {
|
||||
roverManager.releaseControl(roverId, socket);
|
||||
logger.info('Force released socket from rover', socketId, roverId);
|
||||
} else {
|
||||
logger.warn('Force release: socket not found', socketId, roverId);
|
||||
}
|
||||
assignmentEvents.emit('update', socketId);
|
||||
}
|
||||
|
||||
function pickRover(socket) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
return null;
|
||||
}
|
||||
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
||||
if (!rover || rover.locked) return false;
|
||||
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
|
||||
if (!access.ok) return false;
|
||||
return true;
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const dockedRank = (rover) => {
|
||||
if (!rover) return 0;
|
||||
if (rover.docked === true) return -1;
|
||||
if (rover.docked === false) return 1;
|
||||
const sensors = rover.lastSensor?.decoded || rover.lastSensor?.sensors || null;
|
||||
const docked = sensors?.chargingSources?.homeBase;
|
||||
if (docked === true) return -1;
|
||||
if (docked === false) return 1;
|
||||
return 0;
|
||||
};
|
||||
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
|
||||
candidates.sort((a, b) => {
|
||||
const aEmpty = idleRank(a);
|
||||
const bEmpty = idleRank(b);
|
||||
if (aEmpty !== bEmpty) return bEmpty - aEmpty;
|
||||
const aDockRank = dockedRank(a);
|
||||
const bDockRank = dockedRank(b);
|
||||
if (aEmpty === 1 && aDockRank !== bDockRank) {
|
||||
return bDockRank - aDockRank;
|
||||
}
|
||||
if (a.drivers.size !== b.drivers.size) {
|
||||
return a.drivers.size - b.drivers.size;
|
||||
}
|
||||
return bDockRank - aDockRank;
|
||||
});
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function describeAssignment(socketId) {
|
||||
const assignedRoverId = assignments.get(socketId) || null;
|
||||
const adminRoverId = assignedRoverId ? null : roverManager.getPrimaryRoverForSocket(socketId);
|
||||
const waitingIndex = waiting.has(socketId) ? Array.from(waiting).indexOf(socketId) : -1;
|
||||
const roverId = assignedRoverId || adminRoverId || null;
|
||||
const waitingStatus = waiting.has(socketId);
|
||||
return {
|
||||
roverId,
|
||||
status: assignedRoverId ? 'assigned' : adminRoverId ? 'admin' : waitingStatus ? 'waiting' : null,
|
||||
queuePosition: waitingIndex >= 0 ? waitingIndex + 1 : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assignmentEvents,
|
||||
describeAssignment,
|
||||
forceRelease,
|
||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||
if (!socket || !roverId) return;
|
||||
const previous = assignments.get(socket.id);
|
||||
if (previous && previous !== roverId && releasePrevious) {
|
||||
roverManager.releaseControl(previous, socket);
|
||||
}
|
||||
assignments.set(socket.id, roverId);
|
||||
waiting.delete(socket.id);
|
||||
assignmentEvents.emit('update', socket.id);
|
||||
},
|
||||
};
|
||||
@@ -1,657 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('audioForwardService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const roverManager = require('./roverManager');
|
||||
const turnService = require('./turnService');
|
||||
const { isVerified } = require('./verificationService');
|
||||
const videoSessions = require('./videoSessions');
|
||||
|
||||
const audioForwardEvents = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const audioForwardConfig = config.audioForward || {};
|
||||
const mediaConfig = config.media || {};
|
||||
const serviceEnabled = audioForwardConfig.enabled !== false;
|
||||
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
|
||||
const streamSuffix =
|
||||
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
|
||||
? audioForwardConfig.streamSuffix.trim()
|
||||
: '-fwd';
|
||||
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
|
||||
const uploadsDir = path.join(runtimeDir, 'uploads');
|
||||
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
|
||||
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
|
||||
: 8 * 1024 * 1024;
|
||||
|
||||
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
|
||||
const workers = new Map(); // roverId -> worker
|
||||
const whipOwners = new Map(); // roverId -> socketId
|
||||
|
||||
function publishStateChange(roverId) {
|
||||
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
|
||||
}
|
||||
|
||||
function setState(roverId, next = {}) {
|
||||
const prev = states.get(roverId) || {};
|
||||
const merged = {
|
||||
state: next.state || prev.state || 'idle',
|
||||
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
|
||||
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
|
||||
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
states.set(roverId, merged);
|
||||
publishStateChange(roverId);
|
||||
}
|
||||
|
||||
function getAudioForwardState() {
|
||||
const payload = {};
|
||||
states.forEach((entry, roverId) => {
|
||||
payload[roverId] = { ...entry };
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
function ensureRuntimeDir() {
|
||||
fs.mkdirSync(runtimeDir, { recursive: true });
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeRoverId(roverId) {
|
||||
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
|
||||
}
|
||||
|
||||
function sanitizeFileStem(name) {
|
||||
return String(name || 'upload')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function extFromUpload(name, mime) {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
const lowerMime = String(mime || '').toLowerCase();
|
||||
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
|
||||
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
|
||||
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
|
||||
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
|
||||
}
|
||||
|
||||
function ensureVipVerified(socket) {
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAudioForwardPermission(socket, roverId) {
|
||||
ensureVipVerified(socket);
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
throw new Error('Audio forwarding is only allowed on your own rover');
|
||||
}
|
||||
if (!turnService.canDrive(roverId, socket)) {
|
||||
throw new Error('Only the current driver can play audio');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFifo(fifoPath) {
|
||||
try {
|
||||
const stat = fs.statSync(fifoPath);
|
||||
if (stat.isFIFO()) return;
|
||||
fs.unlinkSync(fifoPath);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`mkfifo failed: ${result.stderr || result.stdout || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function forcePublishStreamMode(rawUrl) {
|
||||
const value = String(rawUrl || '').trim();
|
||||
if (!value) return '';
|
||||
if (!/[?&]streamid=#!::/.test(value)) return value;
|
||||
if (/,m=publish\b/.test(value)) return value;
|
||||
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
|
||||
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
|
||||
}
|
||||
|
||||
function resolveForwardUrl(roverId) {
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
const configured = record?.meta?.media?.audioForwardUrl;
|
||||
if (configured) return forcePublishStreamMode(configured);
|
||||
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
|
||||
roverId + streamSuffix,
|
||||
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
||||
}
|
||||
|
||||
function resolveForwardPathId(roverId) {
|
||||
return `${roverId}${streamSuffix}`;
|
||||
}
|
||||
|
||||
function getMediaPrefix() {
|
||||
const base = mediaConfig.whepBaseUrl;
|
||||
if (!base) return '';
|
||||
try {
|
||||
const parsed = new URL(base);
|
||||
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return String(base).replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhipUrl(pathId) {
|
||||
const prefix = getMediaPrefix();
|
||||
if (!prefix) {
|
||||
throw new Error('Server media base URL missing');
|
||||
}
|
||||
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
|
||||
}
|
||||
|
||||
function spawnFfmpeg(roverId, tag, args, options = {}) {
|
||||
const proc = spawn(ffmpegBin, args, {
|
||||
stdio: [options.captureStdin ? 'pipe' : 'ignore', options.captureStdout ? 'pipe' : 'ignore', 'pipe'],
|
||||
});
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
const text = String(chunk || '').trim();
|
||||
if (!text) return;
|
||||
logger.warn(`${tag} stderr`, { roverId, text });
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
logger.warn(`${tag} spawn error`, { roverId, message: err?.message || String(err) });
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
function stopProc(proc, graceMs = 1200) {
|
||||
if (!proc || proc.killed) return;
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, graceMs);
|
||||
}
|
||||
|
||||
function buildPublisherArgs(fifoPath, outputUrl) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ar',
|
||||
'16000',
|
||||
'-ac',
|
||||
'1',
|
||||
'-i',
|
||||
fifoPath,
|
||||
'-c:a',
|
||||
'libopus',
|
||||
'-b:a',
|
||||
'24000',
|
||||
'-ar:a',
|
||||
'16000',
|
||||
'-ac:a',
|
||||
'1',
|
||||
'-application',
|
||||
'lowdelay',
|
||||
'-frame_duration',
|
||||
'10',
|
||||
'-compression_level',
|
||||
'0',
|
||||
'-fflags',
|
||||
'nobuffer',
|
||||
'-flush_packets',
|
||||
'1',
|
||||
'-muxdelay',
|
||||
'0',
|
||||
'-muxpreload',
|
||||
'0',
|
||||
'-f',
|
||||
'mpegts',
|
||||
outputUrl,
|
||||
];
|
||||
}
|
||||
|
||||
function buildSilenceWriterArgs() {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'anullsrc=channel_layout=mono:sample_rate=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function buildUploadWriterArgs(filePath) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-i',
|
||||
filePath,
|
||||
'-vn',
|
||||
'-af',
|
||||
'aresample=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function attachWriterPipe(worker, proc) {
|
||||
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
|
||||
writer.on('error', (err) => {
|
||||
const code = err?.code || 'unknown';
|
||||
if (code !== 'EPIPE') {
|
||||
logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
proc.stdout.on('error', (err) => {
|
||||
logger.warn('writer stdout error', {
|
||||
roverId: worker?.roverId,
|
||||
code: err?.code || 'unknown',
|
||||
message: err?.message || String(err),
|
||||
});
|
||||
});
|
||||
proc.stdout.pipe(writer);
|
||||
proc.on('exit', () => {
|
||||
writer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupUploadFile(worker) {
|
||||
if (!worker?.activeUploadPath) return;
|
||||
try {
|
||||
fs.unlinkSync(worker.activeUploadPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
worker.activeUploadPath = null;
|
||||
}
|
||||
|
||||
function stopContentProc(worker) {
|
||||
if (!worker) return;
|
||||
if (worker.contentProc) {
|
||||
stopProc(worker.contentProc);
|
||||
}
|
||||
worker.contentProc = null;
|
||||
worker.contentKind = null;
|
||||
}
|
||||
|
||||
function startSilenceWriter(roverId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeOwnerSocketId = null;
|
||||
const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'silence';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
if (code === 0 || signal === 'SIGTERM') return;
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'silence',
|
||||
error: `silence writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (workers.has(roverId)) startSilenceWriter(roverId);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function startUploadWriter(roverId, filePath, ownerSocketId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeUploadPath = filePath;
|
||||
worker.activeOwnerSocketId = ownerSocketId || null;
|
||||
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'upload';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
|
||||
if (code != null && code !== 0 && signal !== 'SIGTERM') {
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'upload',
|
||||
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
}
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureWorker(roverId) {
|
||||
if (!serviceEnabled) throw new Error('Audio forward disabled');
|
||||
if (!roverId) throw new Error('roverId required');
|
||||
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record || !record.ws) throw new Error('Rover offline');
|
||||
|
||||
if (workers.has(roverId)) return workers.get(roverId);
|
||||
|
||||
ensureRuntimeDir();
|
||||
const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
|
||||
ensureFifo(fifoPath);
|
||||
const outputUrl = resolveForwardUrl(roverId);
|
||||
|
||||
const keepaliveFd = fs.openSync(fifoPath, 'r+');
|
||||
const publisher = spawnFfmpeg(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
|
||||
|
||||
const worker = {
|
||||
roverId,
|
||||
fifoPath,
|
||||
keepaliveFd,
|
||||
outputUrl,
|
||||
publisherProc: publisher,
|
||||
contentProc: null,
|
||||
contentKind: null,
|
||||
writerSeq: 0,
|
||||
activeOwnerSocketId: null,
|
||||
activeUploadPath: null,
|
||||
stopping: false,
|
||||
};
|
||||
workers.set(roverId, worker);
|
||||
|
||||
publisher.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.publisherProc !== publisher || current.stopping) return;
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: current.contentKind || 'publish',
|
||||
error: `publisher exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
startSilenceWriter(roverId);
|
||||
logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
|
||||
return worker;
|
||||
}
|
||||
|
||||
function stopWorker(roverId) {
|
||||
const whipOwner = whipOwners.get(roverId);
|
||||
if (whipOwner) {
|
||||
whipOwners.delete(roverId);
|
||||
revokeWhipSessionForRover(roverId, whipOwner);
|
||||
}
|
||||
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker) return;
|
||||
|
||||
worker.stopping = true;
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
stopProc(worker.publisherProc);
|
||||
|
||||
try {
|
||||
fs.closeSync(worker.keepaliveFd);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(worker.fifoPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
workers.delete(roverId);
|
||||
setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function writeUploadFile(roverId, payload = {}) {
|
||||
const { name, mime, dataBase64 } = payload || {};
|
||||
const ext = extFromUpload(name, mime);
|
||||
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
|
||||
if (!encoded) throw new Error('Upload payload missing');
|
||||
|
||||
const bytes = Buffer.from(encoded, 'base64');
|
||||
if (!bytes.length) throw new Error('Upload decode failed');
|
||||
if (bytes.length > maxUploadBytes) throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
|
||||
|
||||
ensureRuntimeDir();
|
||||
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
|
||||
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function playUploadedAudio(roverId, payload = {}, ownerSocketId = null) {
|
||||
stopWhipForRover(roverId, 'upload_override');
|
||||
ensureWorker(roverId);
|
||||
const uploadPath = writeUploadFile(roverId, payload);
|
||||
startUploadWriter(roverId, uploadPath, ownerSocketId);
|
||||
}
|
||||
|
||||
function stopPlayback(roverId) {
|
||||
stopWhipForRover(roverId, 'stop_playback');
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
function revokeWhipSessionForRover(roverId, ownerSocketId) {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
const pathId = resolveForwardPathId(roverId);
|
||||
videoSessions.revokeWhere(
|
||||
(info) => info?.socketId === ownerSocketId && info?.sourceType === 'roverMic' && info?.sourceId === pathId,
|
||||
);
|
||||
}
|
||||
|
||||
function stopWhipForRover(roverId, reason = 'unknown') {
|
||||
const ownerSocketId = whipOwners.get(roverId);
|
||||
if (!ownerSocketId) return;
|
||||
whipOwners.delete(roverId);
|
||||
revokeWhipSessionForRover(roverId, ownerSocketId);
|
||||
logger.info('Stopping WHIP mic session', { roverId, ownerSocketId, reason });
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, { state: 'error', source: 'mic-whip', error: err?.message || String(err), startedAt: null });
|
||||
}
|
||||
}
|
||||
|
||||
function stopOwnedAudioIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
|
||||
if (whipOwners.get(roverId) === ownerSocketId) {
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (!ownerIsDriver || !ownerCanDrive) {
|
||||
stopWhipForRover(roverId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== ownerSocketId) return;
|
||||
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (ownerIsDriver && ownerCanDrive) return;
|
||||
|
||||
logger.info('Stopping upload audio due to ownership/driver change', { roverId, ownerSocketId, reason });
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (!roverId) return;
|
||||
if (action === 'removed') {
|
||||
stopWorker(roverId);
|
||||
return;
|
||||
}
|
||||
if (action === 'upsert' && serviceEnabled) {
|
||||
if (whipOwners.has(roverId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
|
||||
if (!socketId || !roverId) return;
|
||||
if (action === 'remove' || action === 'add') {
|
||||
stopOwnedAudioIfUnauthorized(roverId, socketId, action);
|
||||
}
|
||||
});
|
||||
|
||||
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
|
||||
if (!roverId) return;
|
||||
const whipOwner = whipOwners.get(roverId);
|
||||
if (whipOwner) {
|
||||
stopOwnedAudioIfUnauthorized(roverId, whipOwner, 'turn_change');
|
||||
}
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.contentKind !== 'upload') return;
|
||||
stopOwnedAudioIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
const roverId = String(payload?.roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, roverId);
|
||||
playUploadedAudio(roverId, payload, socket.id);
|
||||
cb({ success: true, roverId });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
const worker = workers.get(normalized);
|
||||
if (worker && worker.contentKind === 'upload' && worker.activeOwnerSocketId !== socket.id) {
|
||||
throw new Error('Upload playback is owned by another session');
|
||||
}
|
||||
stopPlayback(normalized);
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStart', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
stopWorker(normalized);
|
||||
whipOwners.set(normalized, socket.id);
|
||||
const pathId = resolveForwardPathId(normalized);
|
||||
revokeWhipSessionForRover(normalized, socket.id);
|
||||
const token = videoSessions.createSession(socket, { type: 'roverMic', id: pathId });
|
||||
const whipUrl = buildWhipUrl(pathId);
|
||||
setState(normalized, { state: 'starting', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized, pathId, token, whipUrl });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipReady', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('WHIP session not owned by this client');
|
||||
}
|
||||
setState(normalized, { state: 'playing', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) && whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('Mic forwarding is owned by another session');
|
||||
}
|
||||
stopWhipForRover(normalized, 'client_stop');
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
workers.forEach((worker, roverId) => {
|
||||
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== socket.id) return;
|
||||
logger.info('Stopping owned upload audio due to socket disconnect', { roverId, socketId: socket.id });
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
|
||||
if (ownerSocketId !== socket.id) continue;
|
||||
stopWhipForRover(roverId, 'socket_disconnect');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAudioForwardState,
|
||||
audioForwardEvents,
|
||||
};
|
||||
@@ -1,153 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('audioLevelsService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { isAdmin } = require('./roleService');
|
||||
const roverManager = require('./roverManager');
|
||||
const { issueCommand } = require('./commandService');
|
||||
|
||||
const audioLevelsEvents = new EventEmitter();
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'audio-levels.json');
|
||||
const config = loadConfig();
|
||||
const configuredDefaults = config.audioLevels || {};
|
||||
|
||||
const DEFAULTS = {
|
||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||
};
|
||||
|
||||
function clampGain(value, fallback = 1) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(4, num));
|
||||
}
|
||||
|
||||
function normalizeStore(raw = {}) {
|
||||
return {
|
||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
||||
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
||||
};
|
||||
}
|
||||
|
||||
let state = null;
|
||||
|
||||
function loadState() {
|
||||
if (state) return state;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
state = normalizeStore(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load audio levels store', err.message);
|
||||
}
|
||||
state = normalizeStore({});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function persistState(next) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const normalized = normalizeStore(next);
|
||||
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(tempPath, STORE_PATH);
|
||||
state = normalized;
|
||||
return state;
|
||||
}
|
||||
|
||||
function getAudioLevels() {
|
||||
const current = loadState();
|
||||
return {
|
||||
hornGain: current.hornGain,
|
||||
ttsGain: current.ttsGain,
|
||||
forwardGain: current.forwardGain,
|
||||
updatedAt: current.updatedAt,
|
||||
updatedBy: current.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function emitChange(reason = 'update') {
|
||||
audioLevelsEvents.emit('change', {
|
||||
reason,
|
||||
levels: getAudioLevels(),
|
||||
});
|
||||
}
|
||||
|
||||
function pushLevelsToRover(roverId) {
|
||||
if (!roverId) return;
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record || !record.ws) return;
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'audioLevels',
|
||||
audioLevels: getAudioLevels(),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function pushLevelsToAllRovers() {
|
||||
roverManager.rovers.forEach((record, roverId) => {
|
||||
if (record?.ws) {
|
||||
pushLevelsToRover(roverId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setAudioLevels(input = {}, actor = null) {
|
||||
const current = loadState();
|
||||
const next = {
|
||||
...current,
|
||||
hornGain: clampGain(input.hornGain, current.hornGain),
|
||||
ttsGain: clampGain(input.ttsGain, current.ttsGain),
|
||||
forwardGain: clampGain(input.forwardGain, current.forwardGain),
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: actor,
|
||||
};
|
||||
persistState(next);
|
||||
pushLevelsToAllRovers();
|
||||
emitChange('set');
|
||||
return getAudioLevels();
|
||||
}
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (action === 'upsert' && roverId) {
|
||||
pushLevelsToRover(roverId);
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||
cb({ success: true, levels: getAudioLevels() });
|
||||
});
|
||||
|
||||
socket.on('audioLevels:set', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
if (!isAdmin(socket)) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
const actor = socket?.data?.user?.username || null;
|
||||
const levels = setAudioLevels(payload || {}, actor);
|
||||
cb({ success: true, levels });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadState();
|
||||
|
||||
module.exports = {
|
||||
getAudioLevels,
|
||||
setAudioLevels,
|
||||
pushLevelsToRover,
|
||||
audioLevelsEvents,
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('authService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { clearLockdownTimer } = require('./lockdownGuard');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { setRole } = require('./roleService');
|
||||
|
||||
const config = loadConfig();
|
||||
const admins = config.admins || [];
|
||||
|
||||
function findAdmin(username) {
|
||||
return admins.find((admin) => admin.username === username);
|
||||
}
|
||||
|
||||
async function authenticate(username, password) {
|
||||
const admin = findAdmin(username);
|
||||
if (!admin) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
const ok = await bcrypt.compare(password, admin.password_hash);
|
||||
if (!ok) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
return admin;
|
||||
}
|
||||
|
||||
function isAdmin(socket) {
|
||||
return socket?.data?.role === 'admin' || socket?.data?.role === 'lockdown';
|
||||
}
|
||||
|
||||
function isLockdownAdmin(socket) {
|
||||
return socket?.data?.role === 'lockdown';
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const requestedRole = socket.handshake?.query?.role;
|
||||
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
||||
setRole(socket, initialRole);
|
||||
logger.info('Socket connected with role', socket.id, initialRole);
|
||||
socket.emit('auth:role', { role: initialRole });
|
||||
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
|
||||
try {
|
||||
const admin = await authenticate(username, password);
|
||||
if (getMode() === MODES.LOCKDOWN && !admin.lockdown) {
|
||||
throw new Error('Lockdown admins only');
|
||||
}
|
||||
const role = admin.lockdown ? 'lockdown' : 'admin';
|
||||
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
||||
setRole(socket, role);
|
||||
socket.emit('auth:role', { role });
|
||||
clearLockdownTimer(socket);
|
||||
logger.info('Login success', socket.id, role);
|
||||
cb({ success: true, role: socket.data.role });
|
||||
} catch (err) {
|
||||
logger.warn('Login failed', socket.id, err.message);
|
||||
cb({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
function handleRoleChange({ role } = {}, cb = () => {}) {
|
||||
if (role === 'spectator' || role === 'user') {
|
||||
setRole(socket, role);
|
||||
socket.emit('auth:role', { role });
|
||||
logger.info('Role changed via client request', socket.id, role);
|
||||
cb({ success: true, role });
|
||||
} else {
|
||||
cb({ error: 'Invalid role' });
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('role:set', handleRoleChange);
|
||||
socket.on('session:setRole', handleRoleChange);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
isAdmin,
|
||||
isLockdownAdmin,
|
||||
authenticate,
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
const logger = require('../globals/logger').child('batteryManager');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const { managerEvents, lockRover, rovers } = require('./roverManager');
|
||||
|
||||
const STATES = {
|
||||
NORMAL: 'normal',
|
||||
WARN: 'warn',
|
||||
URGENT: 'urgent',
|
||||
DOCKED: 'docked',
|
||||
CHARGING: 'charging',
|
||||
FULL: 'full',
|
||||
LOCKED: 'locked',
|
||||
};
|
||||
|
||||
const WAITING_UNLOCK_MS = 5 * 60 * 1000;
|
||||
|
||||
const roverState = new Map(); // roverId -> state snapshot
|
||||
|
||||
function getState(roverId) {
|
||||
if (!roverState.has(roverId)) {
|
||||
roverState.set(roverId, {
|
||||
lastPercent: null,
|
||||
warned: false,
|
||||
urgent: false,
|
||||
onDock: false,
|
||||
dockedCharging: false,
|
||||
charging: false,
|
||||
batteryLocked: false,
|
||||
waitingSince: null,
|
||||
});
|
||||
}
|
||||
return roverState.get(roverId);
|
||||
}
|
||||
|
||||
function shouldLock(record) {
|
||||
return record.lockReason == null || record.lockReason === 'battery';
|
||||
}
|
||||
|
||||
function handleSensorEvent({ roverId, sensors, batteryState }) {
|
||||
if (!roverId) return;
|
||||
const record = rovers.get(roverId);
|
||||
if (!record) return;
|
||||
const state = getState(roverId);
|
||||
const config = record.meta?.battery || {};
|
||||
const lockable = shouldLock(record);
|
||||
|
||||
if (batteryState?.percent != null) {
|
||||
state.lastPercent = batteryState.percent;
|
||||
}
|
||||
|
||||
if (!state.warned && batteryState?.warnActive) {
|
||||
state.warned = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.warn',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
if (!state.urgent && batteryState?.urgentActive) {
|
||||
state.urgent = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.urgent',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
|
||||
const chargingState = sensors?.chargingState?.code;
|
||||
const chargingSources = sensors?.chargingSources;
|
||||
const onDock = Boolean(chargingSources?.homeBase);
|
||||
if (onDock && !state.onDock) {
|
||||
state.onDock = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.docked',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
} else if (!onDock && state.onDock) {
|
||||
state.onDock = false;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.undocked',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
const chargingLabel = sensors?.chargingState?.label?.toLowerCase();
|
||||
const chargingByLabel =
|
||||
chargingLabel === 'waiting' || chargingLabel === 'full charging' || chargingLabel === 'trickle charging';
|
||||
const chargingByCode = chargingState === 2 || chargingState === 3 || chargingState === 4;
|
||||
const isCharging = chargingByLabel || chargingByCode;
|
||||
if (isCharging && !state.charging) {
|
||||
state.charging = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.charging.start',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
} else if (!isCharging && state.charging) {
|
||||
state.charging = false;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.charging.stop',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
const dockedCharging = onDock && isCharging;
|
||||
if (dockedCharging && !state.dockedCharging) {
|
||||
state.dockedCharging = true;
|
||||
} else if (!dockedCharging && state.dockedCharging) {
|
||||
state.dockedCharging = false;
|
||||
}
|
||||
|
||||
const waitingState = chargingState === 4;
|
||||
if (waitingState && state.waitingSince == null) {
|
||||
state.waitingSince = Date.now();
|
||||
} else if (!waitingState && state.waitingSince != null) {
|
||||
state.waitingSince = null;
|
||||
}
|
||||
const waitingLongEnough =
|
||||
waitingState && state.waitingSince != null && Date.now() - state.waitingSince >= WAITING_UNLOCK_MS;
|
||||
|
||||
const warnThreshold =
|
||||
typeof config?.Warn === 'number' ? config.Warn : batteryState?.warn ?? null;
|
||||
const fullThreshold =
|
||||
typeof config?.Full === 'number' ? config.Full : batteryState?.full ?? null;
|
||||
const chargeMah = batteryState?.charge ?? null;
|
||||
let waitingPercent = batteryState?.percent ?? null;
|
||||
if (chargeMah != null && warnThreshold != null && fullThreshold != null && fullThreshold > warnThreshold) {
|
||||
waitingPercent = (chargeMah - warnThreshold) / (fullThreshold - warnThreshold);
|
||||
waitingPercent = Math.max(0, Math.min(1, waitingPercent));
|
||||
}
|
||||
|
||||
const isFull =
|
||||
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
|
||||
(batteryState?.percent != null && batteryState.percent >= 0.99);
|
||||
|
||||
const needsCharge = state.warned;
|
||||
|
||||
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
|
||||
logger.info('Auto-locking rover for charging', { roverId });
|
||||
lockRover(roverId, true, { reason: 'battery' });
|
||||
state.batteryLocked = true;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.locked',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
|
||||
const waitingHalfOrMore = waitingPercent != null && waitingPercent >= 0.01;
|
||||
// const waitingHalfOrMore = true;
|
||||
const shouldUnlock = state.batteryLocked && waitingLongEnough && waitingHalfOrMore;
|
||||
|
||||
if (shouldUnlock) {
|
||||
logger.info('Unlocking rover after charge', {
|
||||
roverId,
|
||||
isFull,
|
||||
docked: dockedCharging,
|
||||
waitingState,
|
||||
waitingPercent,
|
||||
waitedMs: state.waitingSince ? Date.now() - state.waitingSince : null,
|
||||
});
|
||||
lockRover(roverId, false, { reason: 'battery' });
|
||||
state.batteryLocked = false;
|
||||
state.warned = false;
|
||||
state.urgent = false;
|
||||
state.waitingSince = null;
|
||||
publishEvent({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.unlocked',
|
||||
payload: { roverId, batteryState },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleLockEvent({ roverId, locked, reason }) {
|
||||
const state = getState(roverId);
|
||||
if (!state) return;
|
||||
if (!locked) {
|
||||
state.batteryLocked = false;
|
||||
state.waitingSince = null;
|
||||
return;
|
||||
}
|
||||
state.batteryLocked = reason === 'battery';
|
||||
}
|
||||
|
||||
managerEvents.on('sensor', handleSensorEvent);
|
||||
managerEvents.on('lock', handleLockEvent);
|
||||
@@ -1,589 +0,0 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('chatService');
|
||||
const { publishEvent, subscribe } = require('./eventBus');
|
||||
const { getRole } = require('./roleService');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { describeAssignment } = require('./assignmentService');
|
||||
const roverManager = require('./roverManager');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { issueCommand } = require('./commandService');
|
||||
const { getAdminReason } = require('./adminReasonService');
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 8000;
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const rateBuckets = new Map(); // socketId -> [timestamps]
|
||||
|
||||
const MAX_HISTORY = 100;
|
||||
const history = [];
|
||||
|
||||
// Words in this list are removed from the profanity dataset entirely.
|
||||
const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit'];
|
||||
const normalizedProfanityAllowlist = new Set(PROFANITY_ALLOWLIST
|
||||
.filter((term) => typeof term === 'string')
|
||||
.map((term) => term.trim().toLowerCase())
|
||||
.filter(Boolean));
|
||||
const profanityDataset = new DataSet()
|
||||
.addAll(englishDataset)
|
||||
.removePhrasesIf((phrase) => normalizedProfanityAllowlist.has(phrase.metadata?.originalWord))
|
||||
.build();
|
||||
const profanityMatcher = new RegExpMatcher({
|
||||
...profanityDataset,
|
||||
...englishRecommendedTransformers,
|
||||
whitelistedTerms: profanityDataset.whitelistedTerms,
|
||||
});
|
||||
const DUPLICATE_WINDOW_MS = 15000;
|
||||
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
||||
const typingBySocket = new Map(); // socketId -> boolean
|
||||
const TYPING_START_NOTE = 72;
|
||||
const TYPING_SEND_NOTE = 79;
|
||||
const TYPING_NOTE_DURATION = 8;
|
||||
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
|
||||
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
|
||||
let lastAccessNoticeAt = 0;
|
||||
|
||||
function withinRateLimit(socketId) {
|
||||
const now = Date.now();
|
||||
const entries = rateBuckets.get(socketId) || [];
|
||||
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
|
||||
next.push(now);
|
||||
rateBuckets.set(socketId, next);
|
||||
return next.length <= RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
function hasProfanity(text) {
|
||||
if (typeof text !== 'string' || !text) return false;
|
||||
return profanityMatcher.hasMatch(text);
|
||||
}
|
||||
|
||||
function isDuplicate(socketId, text) {
|
||||
const prev = lastMessageBySocket.get(socketId);
|
||||
const now = Date.now();
|
||||
if (!prev) {
|
||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
||||
return false;
|
||||
}
|
||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
||||
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
|
||||
}
|
||||
|
||||
function isKeymash(text) {
|
||||
if (!text) return false;
|
||||
if (/(.)\1{6,}/.test(text)) return true; // same char 7+
|
||||
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
|
||||
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveRoverId(socketId) {
|
||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||
if (primary) return primary;
|
||||
const assignment = describeAssignment(socketId);
|
||||
return assignment?.roverId || null;
|
||||
}
|
||||
|
||||
function resolveRoverColor(roverId) {
|
||||
if (!roverId) return null;
|
||||
const record = roverManager.rovers.get(String(roverId));
|
||||
return record?.meta?.color || null;
|
||||
}
|
||||
|
||||
function isPrivateClosedRoverId(roverId) {
|
||||
if (!roverId) return false;
|
||||
return roverManager.canReplayRoverId(roverId) !== true;
|
||||
}
|
||||
|
||||
function normalizeUserText(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function buildMessage(socket, text, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
socketId: socket?.id || null,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverColor,
|
||||
fromDiscord: Boolean(meta.fromDiscord),
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
roverCtx: meta.roverCtx || null,
|
||||
text,
|
||||
tts: meta.tts || null,
|
||||
system: Boolean(meta.system),
|
||||
};
|
||||
}
|
||||
|
||||
function isChargingFromSensors(sensors = {}) {
|
||||
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
||||
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') {
|
||||
return true;
|
||||
}
|
||||
const code = sensors?.chargingState?.code;
|
||||
return code === 2 || code === 3 || code === 4;
|
||||
}
|
||||
|
||||
function buildRoverCtxSnapshot(roverId) {
|
||||
if (!roverId) return null;
|
||||
const key = String(roverId);
|
||||
const record = roverManager.rovers.get(key);
|
||||
if (!record) return null;
|
||||
const sensors = record?.lastSensor?.decoded || {};
|
||||
const batteryState = record?.batteryState || null;
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null;
|
||||
const charging = isChargingFromSensors(sensors);
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const wheelsOffGround = Boolean(
|
||||
sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight,
|
||||
);
|
||||
const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10;
|
||||
const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0));
|
||||
const latestBumps =
|
||||
(sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) +
|
||||
(sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0);
|
||||
const light = sensors?.lightBumper || {};
|
||||
const contactState = docked
|
||||
? 'clear'
|
||||
: latestBumps >= 0.5
|
||||
? 'bumps_recent'
|
||||
: sensors?.wall ||
|
||||
light.left ||
|
||||
light.frontLeft ||
|
||||
light.centerLeft ||
|
||||
light.centerRight ||
|
||||
light.frontRight ||
|
||||
light.right
|
||||
? 'wall_brush'
|
||||
: 'clear';
|
||||
const hazardState = docked
|
||||
? 'normal'
|
||||
: sensors?.virtualWall
|
||||
? 'virtual_wall_seen'
|
||||
: sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight
|
||||
? 'cliff_alert'
|
||||
: 'normal';
|
||||
const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal';
|
||||
const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12)));
|
||||
const activityScore = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
baseScore +
|
||||
(contactState === 'wall_brush' ? 6 : 0) +
|
||||
(contactState === 'bumps_recent' ? 12 : 0) +
|
||||
(hazardState !== 'normal' ? 8 : 0) +
|
||||
(wheelsOffGround ? -20 : 0),
|
||||
),
|
||||
);
|
||||
const activityBand =
|
||||
activityScore >= 75
|
||||
? 'intense'
|
||||
: activityScore >= 50
|
||||
? 'high'
|
||||
: activityScore >= 25
|
||||
? 'medium'
|
||||
: activityScore >= 8
|
||||
? 'low'
|
||||
: 'idle';
|
||||
const moving = latestDistanceM > 0.05 || latestTurnDeg > 10;
|
||||
let statusTag = 'idle';
|
||||
if (charging) {
|
||||
statusTag = 'charging';
|
||||
} else if (docked) {
|
||||
statusTag = 'docked';
|
||||
} else if (driverSocketId && moving) {
|
||||
statusTag = 'driving';
|
||||
} else if (driverSocketId) {
|
||||
statusTag = 'active-idle';
|
||||
}
|
||||
return {
|
||||
id: key,
|
||||
status_tag: statusTag,
|
||||
battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive),
|
||||
docked,
|
||||
charging,
|
||||
wheels_off_ground: wheelsOffGround,
|
||||
contact_state: contactState,
|
||||
hazard_state: hazardState,
|
||||
mobility_state: mobilityState,
|
||||
activity_score: activityScore,
|
||||
activity_band: activityBand,
|
||||
activity_trend: 'steady',
|
||||
activity_30s: {
|
||||
distance_m: latestDistanceM,
|
||||
turn_deg: latestTurnDeg,
|
||||
bumps: latestBumps,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypingPayload(socket, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
const socketId = socket?.id || null;
|
||||
const fromDiscord = Boolean(meta.fromDiscord);
|
||||
let typingId = meta.typingId || null;
|
||||
if (!typingId) {
|
||||
if (fromDiscord) {
|
||||
if (meta.discordUserId) {
|
||||
typingId = `discord:${meta.discordUserId}`;
|
||||
} else if (meta.discordUserName) {
|
||||
typingId = `discord:${meta.discordUserName}`;
|
||||
} else if (meta.nickname) {
|
||||
typingId = `discord:${meta.nickname}`;
|
||||
} else {
|
||||
typingId = 'discord:unknown';
|
||||
}
|
||||
} else if (socketId) {
|
||||
typingId = `socket:${socketId}`;
|
||||
} else if (meta.nickname) {
|
||||
typingId = `socket:${meta.nickname}`;
|
||||
} else {
|
||||
typingId = 'socket:unknown';
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
typingId,
|
||||
isTyping: Boolean(meta.isTyping),
|
||||
socketId,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverColor,
|
||||
fromDiscord,
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
function pushHistory(message) {
|
||||
history.push(message);
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function getRecentMessages(limit = 20, options = {}) {
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
||||
const includeSystem = options?.includeSystem !== false;
|
||||
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
|
||||
return source.slice(-safeLimit);
|
||||
}
|
||||
|
||||
function broadcastMessage(message) {
|
||||
pushHistory(message);
|
||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||
}
|
||||
|
||||
function broadcastTyping(payload) {
|
||||
publishEvent({ source: 'chat', type: 'chat:typing', payload });
|
||||
}
|
||||
|
||||
function playTypingNote(roverId, note, socketId) {
|
||||
if (!roverId) return;
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'song',
|
||||
song: {
|
||||
notes: [{ note, duration: TYPING_NOTE_DURATION }],
|
||||
},
|
||||
});
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone sent', { roverId, note, socketId });
|
||||
} catch (err) {
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone failed', { roverId, note, socketId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTtsOptions(raw = {}) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const speak = raw.speak !== false;
|
||||
if (!speak) return null;
|
||||
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
|
||||
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
|
||||
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
|
||||
if (typeof pitch === 'number') {
|
||||
pitch = Math.max(0, Math.min(99, pitch));
|
||||
}
|
||||
return { speak, engine, voice, pitch };
|
||||
}
|
||||
|
||||
function buildAccessNoticeText(mode, reasonText) {
|
||||
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
|
||||
const reason = reasonText ? ` Reason: ${reasonText}` : '';
|
||||
return `Heads up: the server is in ${label} mode.${reason}`;
|
||||
}
|
||||
|
||||
function shouldSendAccessNotice(message) {
|
||||
if (!message?.text || message.system) return false;
|
||||
const mode = getMode();
|
||||
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
||||
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
||||
const now = Date.now();
|
||||
if (now - lastAccessNoticeAt < ACCESS_NOTICE_COOLDOWN_MS) return false;
|
||||
lastAccessNoticeAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendSystemMessage(text) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) return null;
|
||||
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
|
||||
const message = buildMessage(null, safe, {
|
||||
nickname: 'The Overseer',
|
||||
role: 'user',
|
||||
fromDiscord: false,
|
||||
system: true,
|
||||
});
|
||||
broadcastMessage(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function maybeSendAccessNotice(message) {
|
||||
if (!shouldSendAccessNotice(message)) return;
|
||||
const reason = getAdminReason()?.text || '';
|
||||
const mode = getMode();
|
||||
const notice = buildAccessNoticeText(mode, reason);
|
||||
sendSystemMessage(notice);
|
||||
}
|
||||
|
||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||
const role = getRole(socket);
|
||||
// if (role === 'spectator') {
|
||||
// cb({ error: 'Spectators cannot chat' });
|
||||
// return;
|
||||
// }
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) {
|
||||
cb({ error: 'Message required' });
|
||||
return;
|
||||
}
|
||||
if (!withinRateLimit(socket.id)) {
|
||||
cb({ error: 'Slow down' });
|
||||
return;
|
||||
}
|
||||
if (clean.length > 400) {
|
||||
cb({ error: 'Message too long' });
|
||||
return;
|
||||
}
|
||||
if (hasProfanity(clean)) {
|
||||
cb({ error: 'Message blocked' });
|
||||
return;
|
||||
}
|
||||
// if (isDuplicate(socket.id, clean)) {
|
||||
// cb({ error: 'Duplicate message' });
|
||||
// return;
|
||||
// }
|
||||
// if (isKeymash(clean)) {
|
||||
// cb({ error: 'Message looks like spam' });
|
||||
// return;
|
||||
// }
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
const ttsOptions = normalizeTtsOptions(tts);
|
||||
const message = buildMessage(socket, clean, {
|
||||
fromDiscord: false,
|
||||
roverId,
|
||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||
tts: ttsOptions,
|
||||
});
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||
const privateClosed = isPrivateClosedRoverId(message.roverId);
|
||||
if (privateClosed) {
|
||||
const forcedTts = ttsOptions || { speak: true, engine: 'flite' };
|
||||
maybeSpeak(socket, message, forcedTts);
|
||||
cb({ success: true, privateOnly: true });
|
||||
return;
|
||||
}
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message);
|
||||
maybeSpeak(socket, message, ttsOptions);
|
||||
cb({ success: true });
|
||||
}
|
||||
|
||||
function maybeSpeak(socket, message, ttsOptions) {
|
||||
if (!ttsOptions || !message?.roverId) return;
|
||||
const record = roverManager.rovers.get(message.roverId);
|
||||
const audio = record?.meta?.audio || {};
|
||||
const ttsEnabled = Boolean(audio.ttsEnabled);
|
||||
if (!ttsEnabled) return;
|
||||
const { isQueuedDriver } = require('./turnService');
|
||||
if (
|
||||
!roverManager.canDrive(message.roverId, socket) &&
|
||||
!isQueuedDriver(message.roverId, socket?.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
issueCommand(message.roverId, {
|
||||
type: 'tts',
|
||||
tts: {
|
||||
text: message.text,
|
||||
engine: ttsOptions.engine,
|
||||
voice: ttsOptions.voice,
|
||||
pitch: ttsOptions.pitch,
|
||||
speak: true,
|
||||
},
|
||||
});
|
||||
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
|
||||
} catch (err) {
|
||||
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function sendExternalMessage({
|
||||
text,
|
||||
nickname = 'Discord',
|
||||
role = 'admin',
|
||||
roverId = null,
|
||||
discordGuildId = null,
|
||||
discordGuildName = null,
|
||||
discordGuildIconUrl = null,
|
||||
discordChannelId = null,
|
||||
discordUserId = null,
|
||||
discordUserName = null,
|
||||
discordUserAvatarUrl = null,
|
||||
}) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean || clean.length > 400) {
|
||||
throw new Error('Message invalid');
|
||||
}
|
||||
if (hasProfanity(clean)) {
|
||||
throw new Error('Message blocked');
|
||||
}
|
||||
if (isKeymash(clean)) {
|
||||
throw new Error('Message looks like spam');
|
||||
}
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
throw new Error('Private rover chat is closed');
|
||||
}
|
||||
const message = buildMessage(null, clean, {
|
||||
nickname,
|
||||
role,
|
||||
roverId,
|
||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||
fromDiscord: true,
|
||||
discordGuildId,
|
||||
discordGuildName,
|
||||
discordGuildIconUrl,
|
||||
discordChannelId,
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
});
|
||||
logger.info('External chat message', { roverId, nickname });
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function sendExternalTyping({
|
||||
nickname = 'Discord',
|
||||
role = 'user',
|
||||
roverId = null,
|
||||
discordGuildId = null,
|
||||
discordGuildName = null,
|
||||
discordGuildIconUrl = null,
|
||||
discordChannelId = null,
|
||||
discordUserId = null,
|
||||
discordUserName = null,
|
||||
discordUserAvatarUrl = null,
|
||||
isTyping = true,
|
||||
}) {
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return null;
|
||||
}
|
||||
const payload = buildTypingPayload(null, {
|
||||
nickname,
|
||||
role,
|
||||
roverId,
|
||||
fromDiscord: true,
|
||||
discordGuildId,
|
||||
discordGuildName,
|
||||
discordGuildIconUrl,
|
||||
discordChannelId,
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
isTyping,
|
||||
});
|
||||
broadcastTyping(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.emit('chat:init', history);
|
||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||
socket.on('chat:typing', (payload = {}) => {
|
||||
const isTyping = Boolean(payload?.isTyping);
|
||||
const wasTyping = typingBySocket.get(socket.id);
|
||||
if (isTyping) {
|
||||
typingBySocket.set(socket.id, true);
|
||||
if (!wasTyping) {
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
||||
}
|
||||
} else {
|
||||
typingBySocket.delete(socket.id);
|
||||
}
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return;
|
||||
}
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
if (!typingBySocket.has(socket.id)) return;
|
||||
typingBySocket.delete(socket.id);
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return;
|
||||
}
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
});
|
||||
|
||||
subscribe('chat:message', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:message', payload);
|
||||
});
|
||||
|
||||
subscribe('chat:typing', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:typing', payload);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
handleIncoming,
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
buildTypingPayload,
|
||||
sendSystemMessage,
|
||||
getRecentMessages,
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../globals/io');
|
||||
const roverManager = require('./roverManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
const logger = require('../globals/logger').child('commandService');
|
||||
|
||||
const pendingCommands = new Map(); // id -> { roverId }
|
||||
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||
const driveCooldowns = new Map(); // roverId -> blockedUntil
|
||||
|
||||
function issueCommand(roverId, payload) {
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record || !record.ws) {
|
||||
throw new Error('Rover offline');
|
||||
}
|
||||
const id = uuidv4();
|
||||
const message = { ...payload, id };
|
||||
record.ws.send(JSON.stringify(message));
|
||||
pendingCommands.set(id, { roverId, ts: Date.now(), type: payload.type });
|
||||
logger.info('Issued command', roverId, payload.type, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function handleAck(msg) {
|
||||
const pending = pendingCommands.get(msg.id);
|
||||
if (!pending) return;
|
||||
pendingCommands.delete(msg.id);
|
||||
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
|
||||
io.emit('commandAck', {
|
||||
roverId: pending.roverId,
|
||||
id: msg.id,
|
||||
status: msg.status || 'ok',
|
||||
error: msg.error,
|
||||
});
|
||||
}
|
||||
|
||||
function getRecentDriveActivity(windowMs, options = {}) {
|
||||
const now = Date.now();
|
||||
const results = [];
|
||||
for (const [roverId, info] of lastDriveActivity.entries()) {
|
||||
if (!info || now - info.ts > windowMs) continue;
|
||||
if (options.excludeAdmins && info.isAdmin) continue;
|
||||
results.push({ roverId, ...info });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function setDriveCooldown(roverId, durationMs) {
|
||||
if (!roverId || !durationMs) return;
|
||||
driveCooldowns.set(roverId, Date.now() + durationMs);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
issueCommand,
|
||||
handleAck,
|
||||
getRecentDriveActivity,
|
||||
setDriveCooldown,
|
||||
};
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
function handleCommand({ roverId, type, data } = {}, cb) {
|
||||
const reply = typeof cb === 'function' ? cb : () => {};
|
||||
try {
|
||||
if (!roverId) {
|
||||
throw new Error('roverId required');
|
||||
}
|
||||
if (!type) {
|
||||
throw new Error('type required');
|
||||
}
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
const payload = data ? { ...data } : {};
|
||||
const isRebootCommand = type === 'reboot';
|
||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||
const isAdminSocket = isAdmin(socket);
|
||||
if (isRebootCommand && !isAdminSocket) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
if (!isSongCommand && !isRebootCommand && !roverManager.canDrive(roverId, socket)) {
|
||||
throw new Error('Not your turn or no control');
|
||||
}
|
||||
const driveDirect = payload?.driveDirect;
|
||||
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
||||
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
|
||||
if (safeDrive) {
|
||||
payload.driveDirect = safeDrive;
|
||||
}
|
||||
const left = Number(payload?.driveDirect?.left);
|
||||
const right = Number(payload?.driveDirect?.right);
|
||||
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||
const blockedUntil = driveCooldowns.get(roverId);
|
||||
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||
const reason = isLockdownAdmin(socket)
|
||||
? 'Drive blocked: cooldown'
|
||||
: 'Drive blocked: safety cooldown';
|
||||
throw new Error(reason);
|
||||
}
|
||||
if (speed > 0) {
|
||||
let direction = 'turn';
|
||||
if (left > 0 && right > 0) direction = 'forward';
|
||||
if (left < 0 && right < 0) direction = 'backward';
|
||||
lastDriveActivity.set(roverId, {
|
||||
ts: Date.now(),
|
||||
socketId: socket.id,
|
||||
direction,
|
||||
speed,
|
||||
isAdmin: isAdminSocket,
|
||||
});
|
||||
}
|
||||
}
|
||||
const id = issueCommand(roverId, { type, ...payload });
|
||||
logger.info('Queued command', socket.id, roverId, type);
|
||||
try {
|
||||
const { recordActivity } = require('./turnService');
|
||||
recordActivity(roverId, socket.id);
|
||||
} catch (err) {
|
||||
// best effort; ignore activity update errors
|
||||
}
|
||||
reply({ id });
|
||||
} catch (err) {
|
||||
logger.warn('Command rejected', socket.id, err.message);
|
||||
reply({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('command', handleCommand);
|
||||
socket.on('command:issue', handleCommand);
|
||||
});
|
||||
|
||||
function isSongRawPayload(payload) {
|
||||
if (!payload) return false;
|
||||
const raw = payload.raw;
|
||||
if (!raw) return false;
|
||||
let bytes = null;
|
||||
if (Buffer.isBuffer(raw)) {
|
||||
bytes = raw;
|
||||
} else if (Array.isArray(raw)) {
|
||||
bytes = Buffer.from(raw);
|
||||
} else if (typeof raw === 'string') {
|
||||
bytes = Buffer.from(raw, 'base64');
|
||||
}
|
||||
if (!bytes || bytes.length === 0) return false;
|
||||
const opcode = bytes[0];
|
||||
return opcode === 140 || opcode === 141;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user