Initial implementation: Bordanlage boat onboard system
Complete multiroom audio + navigation dashboard: - Docker stack: SignalK, Snapcast (4 zones), librespot, shairport-sync, Mopidy, Jellyfin, Portainer - React 18 + Vite dashboard with nautical dark theme - Full mock system (SignalK NMEA simulation, Snapcast zones, Mopidy player) - Real API clients for all services with reconnect logic - SVG instruments: Compass, WindRose, Gauge, DepthSounder, SpeedLog - Pages: Overview, Navigation, Audio (zones/radio/library), Systems - Dev mode runs fully without hardware (make dev) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
25
bordanlage/.env
Normal file
25
bordanlage/.env
Normal file
@@ -0,0 +1,25 @@
|
||||
# General
|
||||
COMPOSE_PROJECT_NAME=bordanlage
|
||||
DEV=true
|
||||
|
||||
# Spotify Connect
|
||||
SPOTIFY_NAME=Bordanlage
|
||||
SPOTIFY_BITRATE=320
|
||||
SPOTIFY_CACHE_SIZE=1024
|
||||
|
||||
# Boat Info
|
||||
BOAT_NAME=My Yacht
|
||||
BOAT_MMSI=123456789
|
||||
|
||||
# Paths
|
||||
MUSIC_PATH=./music
|
||||
|
||||
# Jellyfin API Key (set after first run)
|
||||
JELLYFIN_API_KEY=
|
||||
|
||||
# Service URLs (used by dashboard)
|
||||
VITE_SNAPCAST_HOST=localhost
|
||||
VITE_SIGNALK_HOST=localhost
|
||||
VITE_MOPIDY_HOST=localhost
|
||||
VITE_JELLYFIN_HOST=localhost
|
||||
VITE_PORTAINER_HOST=localhost
|
||||
25
bordanlage/.env.example
Normal file
25
bordanlage/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# General
|
||||
COMPOSE_PROJECT_NAME=bordanlage
|
||||
DEV=true
|
||||
|
||||
# Spotify Connect
|
||||
SPOTIFY_NAME=Bordanlage
|
||||
SPOTIFY_BITRATE=320
|
||||
SPOTIFY_CACHE_SIZE=1024
|
||||
|
||||
# Boat Info
|
||||
BOAT_NAME=My Yacht
|
||||
BOAT_MMSI=123456789
|
||||
|
||||
# Paths
|
||||
MUSIC_PATH=./music
|
||||
|
||||
# Jellyfin API Key (set after first run)
|
||||
JELLYFIN_API_KEY=
|
||||
|
||||
# Service URLs (used by dashboard)
|
||||
VITE_SNAPCAST_HOST=localhost
|
||||
VITE_SIGNALK_HOST=localhost
|
||||
VITE_MOPIDY_HOST=localhost
|
||||
VITE_JELLYFIN_HOST=localhost
|
||||
VITE_PORTAINER_HOST=localhost
|
||||
22
bordanlage/Makefile
Normal file
22
bordanlage/Makefile
Normal file
@@ -0,0 +1,22 @@
|
||||
.PHONY: dev boot stop logs rebuild status pipes
|
||||
|
||||
dev: pipes
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
boot: pipes
|
||||
docker compose up -d
|
||||
|
||||
stop:
|
||||
docker compose down
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
rebuild:
|
||||
docker compose build --no-cache
|
||||
|
||||
status:
|
||||
docker compose ps
|
||||
|
||||
pipes:
|
||||
@bash scripts/init-pipes.sh
|
||||
125
bordanlage/README.md
Normal file
125
bordanlage/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Bordanlage – Boat Onboard System
|
||||
|
||||
A complete multiroom audio + navigation dashboard system for boats.
|
||||
Runs on any Docker-capable computer – fully simulated in dev mode (no hardware needed).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (or Docker Engine + Compose)
|
||||
- `make`
|
||||
- For Spotify Connect: a Spotify Premium account
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd bordanlage
|
||||
make dev
|
||||
```
|
||||
|
||||
Dashboard: **http://localhost:8080**
|
||||
|
||||
---
|
||||
|
||||
## Service URLs
|
||||
|
||||
| Service | URL | Description |
|
||||
|---------------|------------------------------|------------------------------|
|
||||
| Dashboard | http://localhost:8080 | Main touch UI |
|
||||
| SignalK | http://localhost:3000 | Navigation data + chart viewer |
|
||||
| Mopidy/Iris | http://localhost:6680/iris/ | Music player UI |
|
||||
| Snapcast Web | http://localhost:1780 | Multiroom audio control |
|
||||
| Jellyfin | http://localhost:8096 | Media library |
|
||||
| Portainer | http://localhost:9000 | Docker management |
|
||||
|
||||
---
|
||||
|
||||
## Spotify Connect
|
||||
|
||||
1. Run `make dev` (or `make boot` on the boat)
|
||||
2. Open Spotify on your phone/computer
|
||||
3. Tap the device icon (bottom right) → look for **"Bordanlage"**
|
||||
4. If it doesn't appear automatically: Go to **Connect to a Device** → enter the IP of the host machine manually
|
||||
|
||||
> On Linux (boat): set `network_mode: host` for the `librespot` service in `docker-compose.yml` for reliable mDNS discovery.
|
||||
|
||||
---
|
||||
|
||||
## AirPlay
|
||||
|
||||
1. Ensure the `shairport` container is running
|
||||
2. On your iPhone/Mac: open Control Center → tap AirPlay → select **"Bordanlage AirPlay"**
|
||||
|
||||
> On Mac: AirPlay works natively via Bonjour.
|
||||
> On Windows WSL2: the `avahi` container in `docker-compose.dev.yml` handles mDNS.
|
||||
|
||||
---
|
||||
|
||||
## Adding Music
|
||||
|
||||
Drop audio files into `./music/`. Mopidy and Jellyfin both mount this directory.
|
||||
|
||||
Trigger a Mopidy library scan:
|
||||
```bash
|
||||
curl -s http://localhost:6680/mopidy/rpc -d '{"jsonrpc":"2.0","id":1,"method":"local.scan"}' \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
For Jellyfin: open http://localhost:8096 → Settings → Libraries → Scan.
|
||||
|
||||
---
|
||||
|
||||
## Connecting Real NMEA Hardware
|
||||
|
||||
Edit `docker-compose.yml` and uncomment the `signalk` device section:
|
||||
|
||||
```yaml
|
||||
signalk:
|
||||
devices:
|
||||
- /dev/ttyUSB0:/dev/ttyUSB0 # NMEA 0183 via USB-Serial
|
||||
```
|
||||
|
||||
Then configure the NMEA connection in SignalK at http://localhost:3000 → Server → Connections.
|
||||
|
||||
For NMEA 2000: use a Yacht Devices YDNU-02 or similar USB gateway.
|
||||
|
||||
---
|
||||
|
||||
## Migration to Real Boat
|
||||
|
||||
Changes needed in `docker-compose.yml`:
|
||||
|
||||
1. **Audio output per zone**: add `--soundcard hw:N,0` to each `zone-*` command and uncomment `/dev/snd`
|
||||
2. **Spotify/AirPlay discovery**: set `network_mode: host` for `librespot` and `shairport`
|
||||
3. **Hardware video decoding** (optional): uncomment `/dev/dri` in `jellyfin`
|
||||
4. **NMEA hardware**: uncomment `/dev/ttyUSB0` in `signalk`
|
||||
5. Set `DEV=false` in `.env`
|
||||
|
||||
Run `make boot` instead of `make dev`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Spotify device not showing up on Mac:**
|
||||
- Ensure port 57621 (UDP+TCP) is accessible. Docker Desktop on Mac sometimes blocks UDP.
|
||||
- Try connecting manually: Spotify → "Connect to a Device" → "Connect to [IP]"
|
||||
|
||||
**AirPlay not visible on Windows:**
|
||||
- The `avahi` container requires D-Bus. Run Docker Desktop with host networking or use WSL2.
|
||||
|
||||
**Snapcast zones show as offline:**
|
||||
- Audio pipes must exist before snapserver starts. Run `make pipes` or `bash scripts/init-pipes.sh`
|
||||
|
||||
**Mopidy won't start:**
|
||||
- Check `docker compose logs mopidy`. The custom Dockerfile installs plugins on first build; rebuild with `make rebuild`
|
||||
|
||||
**Dashboard shows "No signal":**
|
||||
- In dev mode this is normal until mock data initializes (1–2 seconds)
|
||||
- In production: check that SignalK is running and the WebSocket URL is correct in `.env`
|
||||
|
||||
**Port conflicts:**
|
||||
- Edit the port mappings in `docker-compose.yml` or `docker-compose.dev.yml`
|
||||
32
bordanlage/config/mopidy.conf
Normal file
32
bordanlage/config/mopidy.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
[core]
|
||||
data_dir = /var/lib/mopidy
|
||||
|
||||
[logging]
|
||||
verbosity = 1
|
||||
|
||||
[audio]
|
||||
output = audioresample ! audioconvert ! audio/x-raw,rate=44100,channels=2,format=S16LE ! filesink location=/tmp/audio/mopidy.pcm
|
||||
|
||||
[http]
|
||||
enabled = true
|
||||
hostname = 0.0.0.0
|
||||
port = 6680
|
||||
allowed_origins =
|
||||
|
||||
[mpd]
|
||||
enabled = false
|
||||
|
||||
[local]
|
||||
enabled = true
|
||||
media_dir = /music
|
||||
scan_timeout = 1000
|
||||
|
||||
[stream]
|
||||
enabled = true
|
||||
protocols = http, https, mms, rtmp, rtsp
|
||||
|
||||
[tunein]
|
||||
enabled = true
|
||||
|
||||
[iris]
|
||||
enabled = true
|
||||
48
bordanlage/config/nginx/default.conf
Normal file
48
bordanlage/config/nginx/default.conf
Normal file
@@ -0,0 +1,48 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve React app – HTML5 history mode
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy SignalK
|
||||
location /signalk/ {
|
||||
proxy_pass http://signalk:3000/signalk/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Proxy Snapcast WebSocket API
|
||||
location /snapcast/ {
|
||||
proxy_pass http://snapserver:1780/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Proxy Mopidy
|
||||
location /mopidy/ {
|
||||
proxy_pass http://mopidy:6680/mopidy/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Proxy Jellyfin
|
||||
location /jellyfin/ {
|
||||
proxy_pass http://jellyfin:8096/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript;
|
||||
}
|
||||
15
bordanlage/config/shairport.conf
Normal file
15
bordanlage/config/shairport.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
general = {
|
||||
name = "Bordanlage AirPlay";
|
||||
port = 5000;
|
||||
interpolation = "auto";
|
||||
output_backend = "pipe";
|
||||
};
|
||||
|
||||
pipe = {
|
||||
name = "/tmp/audio/airplay.pcm";
|
||||
};
|
||||
|
||||
metadata = {
|
||||
enabled = yes;
|
||||
include_cover_art = yes;
|
||||
};
|
||||
16
bordanlage/config/snapserver.conf
Normal file
16
bordanlage/config/snapserver.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
[stream]
|
||||
source = pipe:///tmp/audio/spotify.pcm?name=Spotify&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
|
||||
source = pipe:///tmp/audio/airplay.pcm?name=AirPlay&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
|
||||
source = pipe:///tmp/audio/mopidy.pcm?name=Mopidy&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
|
||||
|
||||
[server]
|
||||
threads = -1
|
||||
|
||||
[http]
|
||||
enabled = true
|
||||
bind_to_address = 0.0.0.0
|
||||
port = 1780
|
||||
|
||||
[logging]
|
||||
sink = system
|
||||
filter = *:info
|
||||
14
bordanlage/dashboard/Dockerfile
Normal file
14
bordanlage/dashboard/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# Stage 1: Build React app
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
15
bordanlage/dashboard/index.html
Normal file
15
bordanlage/dashboard/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Bordanlage</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
bordanlage/dashboard/nginx.conf
Normal file
13
bordanlage/dashboard/nginx.conf
Normal file
@@ -0,0 +1,13 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
19
bordanlage/dashboard/package.json
Normal file
19
bordanlage/dashboard/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "bordanlage-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
46
bordanlage/dashboard/src/App.jsx
Normal file
46
bordanlage/dashboard/src/App.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react'
|
||||
import TopBar from './components/layout/TopBar.jsx'
|
||||
import TabNav from './components/layout/TabNav.jsx'
|
||||
import Overview from './pages/Overview.jsx'
|
||||
import Navigation from './pages/Navigation.jsx'
|
||||
import Audio from './pages/Audio.jsx'
|
||||
import Systems from './pages/Systems.jsx'
|
||||
|
||||
const PAGES = {
|
||||
overview: Overview,
|
||||
navigation: Navigation,
|
||||
audio: Audio,
|
||||
systems: Systems,
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState('overview')
|
||||
const Page = PAGES[tab] || Overview
|
||||
|
||||
return (
|
||||
<div style={styles.app}>
|
||||
<TopBar />
|
||||
<TabNav activeTab={tab} onTabChange={setTab} />
|
||||
<main style={styles.main}>
|
||||
<Page />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
app: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg)',
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
},
|
||||
}
|
||||
36
bordanlage/dashboard/src/api/jellyfin.js
Normal file
36
bordanlage/dashboard/src/api/jellyfin.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// Jellyfin REST API client.
|
||||
|
||||
export function createJellyfinClient(baseUrl, apiKey) {
|
||||
const headers = {
|
||||
'Authorization': `MediaBrowser Token="${apiKey}", Client="Bordanlage", Device="Dashboard", DeviceId="bordanlage-1", Version="1.0"`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
async function get(path) {
|
||||
const res = await fetch(`${baseUrl}${path}`, { headers })
|
||||
if (!res.ok) throw new Error(`Jellyfin ${res.status}: ${path}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
return {
|
||||
async getArtists() {
|
||||
return get('/Artists?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Audio&Recursive=true')
|
||||
},
|
||||
async getAlbums(artistId) {
|
||||
const q = artistId ? `&ArtistIds=${artistId}` : ''
|
||||
return get(`/Items?SortBy=SortName&IncludeItemTypes=MusicAlbum&Recursive=true${q}`)
|
||||
},
|
||||
async getTracks(albumId) {
|
||||
return get(`/Items?ParentId=${albumId}&IncludeItemTypes=Audio&SortBy=IndexNumber`)
|
||||
},
|
||||
async search(query) {
|
||||
return get(`/Items?SearchTerm=${encodeURIComponent(query)}&IncludeItemTypes=Audio,MusicAlbum,MusicArtist&Recursive=true&Limit=20`)
|
||||
},
|
||||
getStreamUrl(itemId) {
|
||||
return `${baseUrl}/Audio/${itemId}/stream?static=true&api_key=${apiKey}`
|
||||
},
|
||||
getImageUrl(itemId, type = 'Primary') {
|
||||
return `${baseUrl}/Items/${itemId}/Images/${type}?api_key=${apiKey}`
|
||||
}
|
||||
}
|
||||
}
|
||||
84
bordanlage/dashboard/src/api/mopidy.js
Normal file
84
bordanlage/dashboard/src/api/mopidy.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// Real Mopidy JSON-RPC WebSocket client.
|
||||
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
export function createMopidyClient(baseUrl) {
|
||||
const wsUrl = `${baseUrl}/mopidy/ws`
|
||||
let ws = null
|
||||
let reconnectAttempt = 0
|
||||
let destroyed = false
|
||||
let msgId = 1
|
||||
const pending = new Map()
|
||||
const listeners = {}
|
||||
|
||||
function emit(event, data) {
|
||||
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (destroyed) return
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
emit('connected', null)
|
||||
}
|
||||
|
||||
ws.onmessage = ({ data }) => {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
|
||||
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(msg.error.message))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
|
||||
// Mopidy events (no id)
|
||||
if (msg.event) emit(`event:${msg.event}`, msg)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
emit('disconnected', null)
|
||||
for (const [, { reject }] of pending) reject(new Error('Connection closed'))
|
||||
pending.clear()
|
||||
if (!destroyed) scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => {}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
|
||||
reconnectAttempt++
|
||||
setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return {
|
||||
call(method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return reject(new Error('Mopidy not connected'))
|
||||
}
|
||||
const id = msgId++
|
||||
pending.set(id, { resolve, reject })
|
||||
ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
|
||||
})
|
||||
},
|
||||
on(event, fn) {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(fn)
|
||||
},
|
||||
off(event, fn) {
|
||||
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||
},
|
||||
disconnect() {
|
||||
destroyed = true
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
82
bordanlage/dashboard/src/api/signalk.js
Normal file
82
bordanlage/dashboard/src/api/signalk.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Real SignalK WebSocket client with reconnect.
|
||||
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
export function createSignalKClient(baseUrl) {
|
||||
const wsUrl = `${baseUrl}/signalk/v1/stream?subscribe=self`
|
||||
const listeners = {}
|
||||
let ws = null
|
||||
let reconnectAttempt = 0
|
||||
let destroyed = false
|
||||
|
||||
function emit(event, data) {
|
||||
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (destroyed) return
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
emit('connected', null)
|
||||
|
||||
// Subscribe to relevant paths
|
||||
ws.send(JSON.stringify({
|
||||
context: 'vessels.self',
|
||||
subscribe: [
|
||||
{ path: 'navigation.speedOverGround' },
|
||||
{ path: 'navigation.courseOverGroundTrue' },
|
||||
{ path: 'navigation.headingTrue' },
|
||||
{ path: 'navigation.position' },
|
||||
{ path: 'environment.depth.belowKeel' },
|
||||
{ path: 'environment.wind.speedApparent' },
|
||||
{ path: 'environment.wind.angleApparent' },
|
||||
{ path: 'environment.water.temperature' },
|
||||
{ path: 'environment.outside.temperature' },
|
||||
{ path: 'propulsion.main.revolutions' },
|
||||
{ path: 'electrical.batteries.starter.voltage' },
|
||||
{ path: 'electrical.batteries.house.voltage' },
|
||||
{ path: 'steering.rudderAngle' },
|
||||
{ path: 'tanks.fuel.0.currentLevel' },
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
ws.onmessage = ({ data }) => {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
if (msg.updates) emit('delta', msg)
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
emit('disconnected', null)
|
||||
if (!destroyed) scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => { /* onclose will handle reconnect */ }
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
|
||||
reconnectAttempt++
|
||||
setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return {
|
||||
on(event, fn) {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(fn)
|
||||
},
|
||||
off(event, fn) {
|
||||
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||
},
|
||||
disconnect() {
|
||||
destroyed = true
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
85
bordanlage/dashboard/src/api/snapcast.js
Normal file
85
bordanlage/dashboard/src/api/snapcast.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// Real Snapcast JSON-RPC WebSocket client with reconnect + request/response matching.
|
||||
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
export function createSnapcastClient(wsUrl) {
|
||||
let ws = null
|
||||
let reconnectAttempt = 0
|
||||
let destroyed = false
|
||||
let msgId = 1
|
||||
const pending = new Map()
|
||||
const listeners = {}
|
||||
|
||||
function emit(event, data) {
|
||||
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (destroyed) return
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
emit('connected', null)
|
||||
}
|
||||
|
||||
ws.onmessage = ({ data }) => {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
|
||||
// JSON-RPC response
|
||||
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(msg.error.message))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
|
||||
// Server-sent notification
|
||||
if (msg.method) emit('update', msg)
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
emit('disconnected', null)
|
||||
// Reject all pending requests
|
||||
for (const [, { reject }] of pending) reject(new Error('Connection closed'))
|
||||
pending.clear()
|
||||
if (!destroyed) scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => {}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
|
||||
reconnectAttempt++
|
||||
setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return {
|
||||
call(method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return reject(new Error('Snapcast not connected'))
|
||||
}
|
||||
const id = msgId++
|
||||
pending.set(id, { resolve, reject })
|
||||
ws.send(JSON.stringify({ id, jsonrpc: '2.0', method, params }))
|
||||
})
|
||||
},
|
||||
on(event, fn) {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(fn)
|
||||
},
|
||||
off(event, fn) {
|
||||
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||
},
|
||||
disconnect() {
|
||||
destroyed = true
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
53
bordanlage/dashboard/src/components/audio/LibraryBrowser.jsx
Normal file
53
bordanlage/dashboard/src/components/audio/LibraryBrowser.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getApi } from '../../mock/index.js'
|
||||
|
||||
export default function LibraryBrowser() {
|
||||
const [tracks, setTracks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { mopidy } = getApi()
|
||||
|
||||
useEffect(() => {
|
||||
mopidy.call('tracklist.get_tracks')
|
||||
.then(t => { setTracks(t || []); setLoading(false) })
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function playTrack(uri) {
|
||||
await mopidy.call('tracklist.clear')
|
||||
await mopidy.call('tracklist.add', { uris: [uri] })
|
||||
await mopidy.call('playback.play')
|
||||
}
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--muted)', padding: 16 }}>Loading library…</div>
|
||||
if (!tracks.length) return <div style={{ color: 'var(--muted)', padding: 16 }}>No tracks found. Add music to ./music</div>
|
||||
|
||||
return (
|
||||
<div style={styles.list}>
|
||||
{tracks.map((track, i) => (
|
||||
<button key={track.uri || i} style={styles.row} onClick={() => playTrack(track.uri)}>
|
||||
<span style={styles.num}>{i + 1}</span>
|
||||
<div style={styles.meta}>
|
||||
<span style={styles.name}>{track.name}</span>
|
||||
<span style={styles.artist}>{track.artists?.map(a => a.name).join(', ')}</span>
|
||||
</div>
|
||||
<span style={styles.album}>{track.album?.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
list: { display: 'flex', flexDirection: 'column', gap: 2, overflow: 'auto', maxHeight: 400 },
|
||||
row: {
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '8px 12px', borderRadius: 'var(--radius)',
|
||||
background: 'none', border: '1px solid transparent',
|
||||
color: 'var(--text)', textAlign: 'left', minHeight: 48,
|
||||
},
|
||||
num: { fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)', minWidth: 20 },
|
||||
meta: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 },
|
||||
name: { fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||
artist: { fontSize: 11, color: 'var(--muted)' },
|
||||
album: { fontSize: 11, color: 'var(--muted)', minWidth: 100, textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||
}
|
||||
90
bordanlage/dashboard/src/components/audio/NowPlaying.jsx
Normal file
90
bordanlage/dashboard/src/components/audio/NowPlaying.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { usePlayer } from '../../hooks/usePlayer.js'
|
||||
|
||||
function formatTime(ms) {
|
||||
if (!ms) return '0:00'
|
||||
const s = Math.floor(ms / 1000)
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function NowPlaying({ compact = false }) {
|
||||
const { currentTrack, state, position, play, pause, next, previous, connected } = usePlayer()
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<span style={{ color: 'var(--muted)', fontSize: 13 }}>Audio not connected</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const progress = currentTrack?.duration
|
||||
? Math.min(100, (position / currentTrack.duration) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div style={{ ...styles.container, ...(compact ? styles.compact : {}) }}>
|
||||
{/* Cover placeholder */}
|
||||
<div style={styles.cover}>
|
||||
{currentTrack?.coverUrl
|
||||
? <img src={currentTrack.coverUrl} alt="cover" style={styles.coverImg} />
|
||||
: <span style={styles.coverIcon}>♫</span>}
|
||||
</div>
|
||||
|
||||
<div style={styles.info}>
|
||||
<div style={styles.title}>{currentTrack?.title || 'Nothing playing'}</div>
|
||||
<div style={styles.artist}>{currentTrack?.artist || ''}</div>
|
||||
{!compact && <div style={styles.album}>{currentTrack?.album || ''}</div>}
|
||||
|
||||
{/* Progress bar */}
|
||||
{currentTrack && (
|
||||
<div style={styles.progressRow}>
|
||||
<span style={styles.timeText}>{formatTime(position)}</span>
|
||||
<div style={styles.progressBg}>
|
||||
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
|
||||
</div>
|
||||
<span style={styles.timeText}>{formatTime(currentTrack.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div style={styles.controls}>
|
||||
<button style={styles.btn} onClick={previous}>⏮</button>
|
||||
<button style={{ ...styles.btn, ...styles.playBtn }}
|
||||
onClick={state === 'playing' ? pause : play}>
|
||||
{state === 'playing' ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button style={styles.btn} onClick={next}>⏭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex', gap: 16, padding: 16,
|
||||
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
alignItems: 'center',
|
||||
},
|
||||
compact: { padding: '10px 14px' },
|
||||
cover: {
|
||||
width: 64, height: 64, flexShrink: 0,
|
||||
background: 'var(--surface2)', borderRadius: 6,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
coverImg: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||
coverIcon: { fontSize: 28, color: 'var(--muted)' },
|
||||
info: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 },
|
||||
title: { fontWeight: 600, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||
artist: { fontSize: 12, color: 'var(--muted)' },
|
||||
album: { fontSize: 11, color: 'var(--muted)', opacity: 0.7 },
|
||||
progressRow: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 },
|
||||
progressBg: { flex: 1, height: 3, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' },
|
||||
progressFill: { height: '100%', background: 'var(--accent)', borderRadius: 2, transition: 'width 1s linear' },
|
||||
timeText: { fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', minWidth: 30 },
|
||||
controls: { display: 'flex', gap: 4, marginTop: 4 },
|
||||
btn: { width: 36, height: 36, fontSize: 14, background: 'var(--surface2)', color: 'var(--text)', minWidth: 36 },
|
||||
playBtn: { background: 'var(--accent)', color: '#000', fontWeight: 700 },
|
||||
}
|
||||
61
bordanlage/dashboard/src/components/audio/RadioBrowser.jsx
Normal file
61
bordanlage/dashboard/src/components/audio/RadioBrowser.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react'
|
||||
import { getApi } from '../../mock/index.js'
|
||||
|
||||
const BUILT_IN_STATIONS = [
|
||||
{ name: 'SWR3', uri: 'http://stream.swr3.de/swr3/mp3-128/stream.mp3' },
|
||||
{ name: 'NDR 1 Welle Nord', uri: 'http://ndr.de/ndr1welle-nord-128.mp3' },
|
||||
{ name: 'Deutschlandfunk', uri: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3' },
|
||||
{ name: 'KISS FM', uri: 'http://topstream.kissfm.de/kissfm' },
|
||||
]
|
||||
|
||||
export default function RadioBrowser() {
|
||||
const [playing, setPlaying] = useState(null)
|
||||
const { mopidy } = getApi()
|
||||
|
||||
async function playStation(uri, name) {
|
||||
try {
|
||||
await mopidy.call('tracklist.clear')
|
||||
await mopidy.call('tracklist.add', { uris: [uri] })
|
||||
await mopidy.call('playback.play')
|
||||
setPlaying(uri)
|
||||
} catch (e) {
|
||||
console.error('Radio play error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.title}>Radio Stations</div>
|
||||
{BUILT_IN_STATIONS.map(s => (
|
||||
<button
|
||||
key={s.uri}
|
||||
style={{
|
||||
...styles.station,
|
||||
...(playing === s.uri ? styles.active : {}),
|
||||
}}
|
||||
onClick={() => playStation(s.uri, s.name)}
|
||||
>
|
||||
<span style={styles.dot}>◉</span>
|
||||
<span style={styles.stationName}>{s.name}</span>
|
||||
{playing === s.uri && <span style={styles.live}>LIVE</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { display: 'flex', flexDirection: 'column', gap: 6 },
|
||||
title: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 },
|
||||
station: {
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 16px', background: 'var(--surface)',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
|
||||
color: 'var(--text)', textAlign: 'left', minHeight: 48,
|
||||
transition: 'border-color 0.15s',
|
||||
},
|
||||
active: { borderColor: 'var(--accent)', background: 'var(--surface2)' },
|
||||
dot: { color: 'var(--muted)', fontSize: 10 },
|
||||
stationName: { flex: 1, fontSize: 14 },
|
||||
live: { fontSize: 9, padding: '2px 5px', background: '#ef444422', color: 'var(--danger)', borderRadius: 3, fontWeight: 700 },
|
||||
}
|
||||
37
bordanlage/dashboard/src/components/audio/SourcePicker.jsx
Normal file
37
bordanlage/dashboard/src/components/audio/SourcePicker.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
const SOURCES = [
|
||||
{ id: 'Spotify', label: 'Spotify', color: 'var(--spotify)', icon: '🎵' },
|
||||
{ id: 'AirPlay', label: 'AirPlay', color: 'var(--airplay)', icon: '📡' },
|
||||
{ id: 'Mopidy', label: 'Mopidy', color: 'var(--accent)', icon: '📻' },
|
||||
]
|
||||
|
||||
export default function SourcePicker({ activeSource, onSelect }) {
|
||||
return (
|
||||
<div style={styles.row}>
|
||||
{SOURCES.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
style={{
|
||||
...styles.btn,
|
||||
...(activeSource === s.id ? { background: s.color + '22', borderColor: s.color, color: s.color } : {}),
|
||||
}}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<span>{s.icon}</span>
|
||||
<span style={{ fontSize: 12 }}>{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
row: { display: 'flex', gap: 8 },
|
||||
btn: {
|
||||
flex: 1, height: 52, flexDirection: 'column',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)', color: 'var(--muted)',
|
||||
transition: 'all 0.15s',
|
||||
minHeight: 52,
|
||||
},
|
||||
}
|
||||
53
bordanlage/dashboard/src/components/audio/ZoneCard.jsx
Normal file
53
bordanlage/dashboard/src/components/audio/ZoneCard.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
export default function ZoneCard({ zone, onVolume, onMute, onSource }) {
|
||||
const { id, name, active, volume, muted, source } = zone
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
...styles.card,
|
||||
opacity: active ? 1 : 0.45,
|
||||
borderColor: active && !muted ? 'var(--accent)' : 'var(--border)',
|
||||
}}>
|
||||
<div style={styles.header}>
|
||||
<span style={styles.name}>{name}</span>
|
||||
<div style={styles.badges}>
|
||||
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
|
||||
{active ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.source}>{source}</div>
|
||||
|
||||
<div style={styles.volumeRow}>
|
||||
<button style={styles.muteBtn} onClick={() => onMute(id, !muted)}>
|
||||
{muted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<input
|
||||
type="range" min={0} max={100} value={muted ? 0 : volume}
|
||||
onChange={e => onVolume(id, Number(e.target.value))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={styles.volVal}>{muted ? '–' : volume}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
card: {
|
||||
padding: 14,
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
transition: 'border-color 0.2s, opacity 0.2s',
|
||||
},
|
||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
|
||||
name: { fontWeight: 600, fontSize: 14 },
|
||||
badges: { display: 'flex', gap: 4 },
|
||||
badge: { fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700 },
|
||||
source: { fontSize: 11, color: 'var(--muted)' },
|
||||
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
|
||||
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44 },
|
||||
volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
|
||||
}
|
||||
32
bordanlage/dashboard/src/components/audio/ZoneGrid.jsx
Normal file
32
bordanlage/dashboard/src/components/audio/ZoneGrid.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useZones } from '../../hooks/useZones.js'
|
||||
import ZoneCard from './ZoneCard.jsx'
|
||||
|
||||
export default function ZoneGrid() {
|
||||
const { zones, setVolume, setMuted, setSource } = useZones()
|
||||
|
||||
if (!zones.length) {
|
||||
return <div style={{ color: 'var(--muted)', padding: 24, textAlign: 'center' }}>Loading zones…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.grid}>
|
||||
{zones.map(zone => (
|
||||
<ZoneCard
|
||||
key={zone.id}
|
||||
zone={zone}
|
||||
onVolume={setVolume}
|
||||
onMute={setMuted}
|
||||
onSource={setSource}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: 12,
|
||||
}
|
||||
}
|
||||
94
bordanlage/dashboard/src/components/instruments/Compass.jsx
Normal file
94
bordanlage/dashboard/src/components/instruments/Compass.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// Animated SVG compass rose.
|
||||
|
||||
const CX = 96, CY = 96, R = 80
|
||||
|
||||
const CARDINALS = [
|
||||
{ label: 'N', angle: 0 },
|
||||
{ label: 'E', angle: 90 },
|
||||
{ label: 'S', angle: 180 },
|
||||
{ label: 'W', angle: 270 },
|
||||
]
|
||||
|
||||
function polarXY(cx, cy, r, angleDeg) {
|
||||
const rad = (angleDeg - 90) * Math.PI / 180
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||
}
|
||||
|
||||
export default function Compass({ heading = 0, cog }) {
|
||||
const hdg = heading ?? 0
|
||||
const hasCog = cog != null
|
||||
|
||||
return (
|
||||
<svg width={192} height={192} viewBox="0 0 192 192">
|
||||
{/* Outer ring */}
|
||||
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={2} />
|
||||
<circle cx={CX} cy={CY} r={R - 12} fill="none" stroke="var(--border)" strokeWidth={1} />
|
||||
|
||||
{/* Rotating rose */}
|
||||
<g style={{
|
||||
transformOrigin: `${CX}px ${CY}px`,
|
||||
transform: `rotate(${-hdg}deg)`,
|
||||
transition: 'transform 0.8s ease',
|
||||
}}>
|
||||
{/* 36 tick marks */}
|
||||
{Array.from({ length: 36 }, (_, i) => {
|
||||
const angle = i * 10
|
||||
const outer = polarXY(CX, CY, R, angle)
|
||||
const inner = polarXY(CX, CY, R - (i % 3 === 0 ? 10 : 5), angle)
|
||||
return (
|
||||
<line key={i}
|
||||
x1={outer.x} y1={outer.y}
|
||||
x2={inner.x} y2={inner.y}
|
||||
stroke={i % 9 === 0 ? 'var(--accent)' : 'var(--muted)'}
|
||||
strokeWidth={i % 9 === 0 ? 2 : 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Cardinal labels */}
|
||||
{CARDINALS.map(c => {
|
||||
const p = polarXY(CX, CY, R - 22, c.angle)
|
||||
return (
|
||||
<text key={c.label}
|
||||
x={p.x} y={p.y + 4}
|
||||
textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={12} fontWeight={700}
|
||||
fill={c.label === 'N' ? 'var(--danger)' : 'var(--text)'}
|
||||
>
|
||||
{c.label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Fixed lubber line (ship's bow = top) */}
|
||||
<line x1={CX} y1={CY - R + 2} x2={CX} y2={CY - R + 16}
|
||||
stroke="var(--accent)" strokeWidth={3} strokeLinecap="round" />
|
||||
|
||||
{/* COG indicator */}
|
||||
{hasCog && (() => {
|
||||
const cogAngle = cog - hdg
|
||||
const tip = polarXY(CX, CY, R - 6, cogAngle)
|
||||
return (
|
||||
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
|
||||
stroke="var(--warning)" strokeWidth={2} strokeDasharray="4,3"
|
||||
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.8s ease' }}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Center */}
|
||||
<circle cx={CX} cy={CY} r={4} fill="var(--accent)" />
|
||||
|
||||
{/* Heading value */}
|
||||
<text x={CX} y={CY + 26} textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
|
||||
{Math.round(hdg).toString().padStart(3, '0')}°
|
||||
</text>
|
||||
<text x={CX} y={186} textAnchor="middle"
|
||||
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
|
||||
HEADING
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Depth sounder with alarm threshold.
|
||||
import Gauge from './Gauge.jsx'
|
||||
|
||||
export default function DepthSounder({ depth }) {
|
||||
return (
|
||||
<Gauge
|
||||
value={depth}
|
||||
min={0}
|
||||
max={30}
|
||||
label="Depth"
|
||||
unit="m"
|
||||
warning={3}
|
||||
danger={1.5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
118
bordanlage/dashboard/src/components/instruments/Gauge.jsx
Normal file
118
bordanlage/dashboard/src/components/instruments/Gauge.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
// Analog round gauge with animated needle.
|
||||
|
||||
const R = 80
|
||||
const CX = 96
|
||||
const CY = 96
|
||||
const START_ANGLE = -225
|
||||
const SWEEP = 270
|
||||
|
||||
function polarToXY(cx, cy, r, angleDeg) {
|
||||
const rad = (angleDeg - 90) * Math.PI / 180
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||
}
|
||||
|
||||
function arcPath(cx, cy, r, startDeg, endDeg) {
|
||||
const start = polarToXY(cx, cy, r, startDeg)
|
||||
const end = polarToXY(cx, cy, r, endDeg)
|
||||
const large = endDeg - startDeg > 180 ? 1 : 0
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`
|
||||
}
|
||||
|
||||
function buildTicks(startDeg, sweep, count) {
|
||||
return Array.from({ length: count + 1 }, (_, i) => {
|
||||
const angle = startDeg + (sweep / count) * i
|
||||
const outer = polarToXY(CX, CY, R, angle)
|
||||
const inner = polarToXY(CX, CY, R - (i % 2 === 0 ? 10 : 6), angle)
|
||||
return { outer, inner, major: i % 2 === 0 }
|
||||
})
|
||||
}
|
||||
|
||||
export default function Gauge({ value, min = 0, max = 10, label = '', unit = '', danger, warning }) {
|
||||
const clampedVal = Math.min(max, Math.max(min, value ?? min))
|
||||
const ratio = (clampedVal - min) / (max - min)
|
||||
const needleAngle = START_ANGLE + ratio * SWEEP
|
||||
|
||||
const warnRatio = warning != null ? (warning - min) / (max - min) : null
|
||||
const dangRatio = danger != null ? (danger - min) / (max - min) : null
|
||||
|
||||
const ticks = buildTicks(START_ANGLE, SWEEP, 10)
|
||||
|
||||
const needleTip = polarToXY(CX, CY, R - 12, needleAngle)
|
||||
const needleBase1 = polarToXY(CX, CY, 6, needleAngle + 90)
|
||||
const needleBase2 = polarToXY(CX, CY, 6, needleAngle - 90)
|
||||
|
||||
const isWarning = warning != null && clampedVal >= warning
|
||||
const isDanger = danger != null && clampedVal >= danger
|
||||
const needleColor = isDanger ? 'var(--danger)' : isWarning ? 'var(--warning)' : 'var(--accent)'
|
||||
|
||||
return (
|
||||
<svg width={192} height={192} viewBox="0 0 192 192" style={{ overflow: 'visible' }}>
|
||||
{/* Background arc */}
|
||||
<path
|
||||
d={arcPath(CX, CY, R, START_ANGLE, START_ANGLE + SWEEP)}
|
||||
fill="none" stroke="var(--border)" strokeWidth={8} strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Warning zone */}
|
||||
{warnRatio != null && (
|
||||
<path
|
||||
d={arcPath(CX, CY, R,
|
||||
START_ANGLE + warnRatio * SWEEP,
|
||||
START_ANGLE + (dangRatio ?? 1) * SWEEP)}
|
||||
fill="none" stroke="#f59e0b33" strokeWidth={8}
|
||||
/>
|
||||
)}
|
||||
{/* Danger zone */}
|
||||
{dangRatio != null && (
|
||||
<path
|
||||
d={arcPath(CX, CY, R, START_ANGLE + dangRatio * SWEEP, START_ANGLE + SWEEP)}
|
||||
fill="none" stroke="#ef444433" strokeWidth={8}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress arc */}
|
||||
<path
|
||||
d={arcPath(CX, CY, R, START_ANGLE, needleAngle)}
|
||||
fill="none" stroke={needleColor} strokeWidth={4} strokeLinecap="round"
|
||||
style={{ transition: 'all 0.6s ease' }}
|
||||
/>
|
||||
|
||||
{/* Ticks */}
|
||||
{ticks.map((t, i) => (
|
||||
<line key={i}
|
||||
x1={t.outer.x} y1={t.outer.y}
|
||||
x2={t.inner.x} y2={t.inner.y}
|
||||
stroke={t.major ? 'var(--muted)' : 'var(--border)'}
|
||||
strokeWidth={t.major ? 1.5 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Needle */}
|
||||
<polygon
|
||||
points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
|
||||
fill={needleColor}
|
||||
style={{ transition: 'all 0.6s ease', transformOrigin: `${CX}px ${CY}px` }}
|
||||
/>
|
||||
|
||||
{/* Center cap */}
|
||||
<circle cx={CX} cy={CY} r={6} fill="var(--surface2)" stroke={needleColor} strokeWidth={2} />
|
||||
|
||||
{/* Value text */}
|
||||
<text x={CX} y={CY + 28} textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)"
|
||||
style={{ transition: 'all 0.3s' }}>
|
||||
{value != null ? (Number.isInteger(max - min) && max - min <= 20
|
||||
? Math.round(clampedVal)
|
||||
: clampedVal.toFixed(1)) : '--'}
|
||||
</text>
|
||||
<text x={CX} y={CY + 42} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
|
||||
{unit}
|
||||
</text>
|
||||
|
||||
{/* Label */}
|
||||
<text x={CX} y={186} textAnchor="middle" fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
|
||||
{label.toUpperCase()}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
14
bordanlage/dashboard/src/components/instruments/SpeedLog.jsx
Normal file
14
bordanlage/dashboard/src/components/instruments/SpeedLog.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
// Speed through water / SOG gauge.
|
||||
import Gauge from './Gauge.jsx'
|
||||
|
||||
export default function SpeedLog({ sog }) {
|
||||
return (
|
||||
<Gauge
|
||||
value={sog}
|
||||
min={0}
|
||||
max={12}
|
||||
label="Speed"
|
||||
unit="kn"
|
||||
/>
|
||||
)
|
||||
}
|
||||
76
bordanlage/dashboard/src/components/instruments/WindRose.jsx
Normal file
76
bordanlage/dashboard/src/components/instruments/WindRose.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// Wind rose showing apparent wind angle and speed.
|
||||
|
||||
const CX = 96, CY = 96, R = 70
|
||||
|
||||
function polarXY(cx, cy, r, angleDeg) {
|
||||
const rad = (angleDeg - 90) * Math.PI / 180
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||
}
|
||||
|
||||
export default function WindRose({ windAngle = 0, windSpeed = 0 }) {
|
||||
const angle = windAngle ?? 0
|
||||
const speed = windSpeed ?? 0
|
||||
const tipLen = Math.min(R - 10, 20 + speed * 2.5)
|
||||
|
||||
const tip = polarXY(CX, CY, tipLen, angle)
|
||||
const left = polarXY(CX, CY, 10, angle + 120)
|
||||
const right = polarXY(CX, CY, 10, angle - 120)
|
||||
|
||||
const color = speed > 18 ? 'var(--danger)' : speed > 12 ? 'var(--warning)' : 'var(--accent)'
|
||||
|
||||
return (
|
||||
<svg width={192} height={192} viewBox="0 0 192 192">
|
||||
{/* Background rings */}
|
||||
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={1} />
|
||||
<circle cx={CX} cy={CY} r={R * 0.6} fill="none" stroke="var(--border)" strokeWidth={1} strokeDasharray="3,4" />
|
||||
|
||||
{/* Dividers every 45° */}
|
||||
{Array.from({ length: 8 }, (_, i) => {
|
||||
const p = polarXY(CX, CY, R, i * 45)
|
||||
return <line key={i} x1={CX} y1={CY} x2={p.x} y2={p.y}
|
||||
stroke="var(--border)" strokeWidth={1} />
|
||||
})}
|
||||
|
||||
{/* Wind arrow */}
|
||||
<g style={{ transition: 'all 0.6s ease' }}>
|
||||
<polygon
|
||||
points={`${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`}
|
||||
fill={color} opacity={0.85}
|
||||
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
|
||||
/>
|
||||
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
|
||||
stroke={color} strokeWidth={2}
|
||||
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
|
||||
/>
|
||||
</g>
|
||||
|
||||
<circle cx={CX} cy={CY} r={5} fill="var(--surface2)" stroke={color} strokeWidth={2} />
|
||||
|
||||
{/* Speed value */}
|
||||
<text x={CX} y={CY + 26} textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
|
||||
{speed.toFixed(1)}
|
||||
</text>
|
||||
<text x={CX} y={CY + 40} textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
|
||||
kn
|
||||
</text>
|
||||
|
||||
{/* Labels */}
|
||||
{[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([l, a]) => {
|
||||
const p = polarXY(CX, CY, R + 12, a)
|
||||
return (
|
||||
<text key={l} x={p.x} y={p.y + 4} textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
|
||||
{l}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
<text x={CX} y={186} textAnchor="middle"
|
||||
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
|
||||
WIND
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
56
bordanlage/dashboard/src/components/layout/TabNav.jsx
Normal file
56
bordanlage/dashboard/src/components/layout/TabNav.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
const TABS = [
|
||||
{ id: 'overview', label: 'Overview', icon: '◈' },
|
||||
{ id: 'navigation', label: 'Navigation', icon: '⊕' },
|
||||
{ id: 'audio', label: 'Audio', icon: '♫' },
|
||||
{ id: 'systems', label: 'Systems', icon: '⚙' },
|
||||
]
|
||||
|
||||
export default function TabNav({ activeTab, onTabChange }) {
|
||||
return (
|
||||
<nav style={styles.nav}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
style={{
|
||||
...styles.tab,
|
||||
...(activeTab === tab.id ? styles.active : {}),
|
||||
}}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
<span style={styles.icon}>{tab.icon}</span>
|
||||
<span style={styles.label}>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
nav: {
|
||||
display: 'flex',
|
||||
background: 'var(--surface)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
height: 48,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
fontSize: 11,
|
||||
color: 'var(--muted)',
|
||||
borderRadius: 0,
|
||||
borderBottom: '2px solid transparent',
|
||||
transition: 'color 0.15s, border-color 0.15s',
|
||||
minHeight: 48,
|
||||
},
|
||||
active: {
|
||||
color: 'var(--accent)',
|
||||
borderBottom: '2px solid var(--accent)',
|
||||
},
|
||||
icon: { fontSize: 16 },
|
||||
label: { fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' },
|
||||
}
|
||||
77
bordanlage/dashboard/src/components/layout/TopBar.jsx
Normal file
77
bordanlage/dashboard/src/components/layout/TopBar.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react'
|
||||
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
function formatTime() {
|
||||
return new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
const { sog, heading, connected } = useNMEA()
|
||||
const [time, setTime] = useState(formatTime())
|
||||
|
||||
// Clock tick
|
||||
useState(() => {
|
||||
const t = setInterval(() => setTime(formatTime()), 5000)
|
||||
return () => clearInterval(t)
|
||||
})
|
||||
|
||||
return (
|
||||
<header style={styles.bar}>
|
||||
<div style={styles.left}>
|
||||
<span style={styles.logo}>⚓ Bordanlage</span>
|
||||
{isDev && <span style={styles.devBadge}>DEV · MOCK DATA</span>}
|
||||
</div>
|
||||
|
||||
<div style={styles.center}>
|
||||
{connected && sog != null && (
|
||||
<span style={styles.stat}>
|
||||
<span style={styles.val}>{sog.toFixed(1)}</span>
|
||||
<span style={styles.unit}>kn</span>
|
||||
</span>
|
||||
)}
|
||||
{connected && heading != null && (
|
||||
<span style={styles.stat}>
|
||||
<span style={styles.val}>{Math.round(heading)}°</span>
|
||||
<span style={styles.unit}>HDG</span>
|
||||
</span>
|
||||
)}
|
||||
{!connected && (
|
||||
<span style={{ color: 'var(--muted)', fontSize: 13 }}>No signal</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.right}>
|
||||
<span style={styles.time}>{time}</span>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
bar: {
|
||||
height: 52,
|
||||
background: 'var(--surface)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 16px',
|
||||
gap: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
left: { display: 'flex', alignItems: 'center', gap: 10, flex: 1 },
|
||||
center: { display: 'flex', gap: 20, alignItems: 'center' },
|
||||
right: { flex: 1, display: 'flex', justifyContent: 'flex-end' },
|
||||
logo: { fontWeight: 700, fontSize: 15, color: 'var(--accent)', letterSpacing: '0.04em' },
|
||||
devBadge: {
|
||||
fontSize: 10, fontWeight: 600, padding: '2px 7px',
|
||||
background: '#f59e0b22', color: 'var(--warning)',
|
||||
border: '1px solid var(--warning)', borderRadius: 4,
|
||||
letterSpacing: '0.06em',
|
||||
},
|
||||
stat: { display: 'flex', alignItems: 'baseline', gap: 3 },
|
||||
val: { fontFamily: 'var(--font-mono)', fontSize: 16, color: 'var(--text)' },
|
||||
unit: { fontSize: 10, color: 'var(--muted)' },
|
||||
time: { fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--muted)' },
|
||||
}
|
||||
37
bordanlage/dashboard/src/components/nav/ChartPlaceholder.jsx
Normal file
37
bordanlage/dashboard/src/components/nav/ChartPlaceholder.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||
|
||||
export default function ChartPlaceholder() {
|
||||
const { lat, lon } = useNMEA()
|
||||
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
|
||||
|
||||
// SignalK has a built-in chart viewer
|
||||
const chartUrl = `http://${signalkHost}:3000/@signalk/freeboard-sk/`
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<iframe
|
||||
src={chartUrl}
|
||||
style={styles.iframe}
|
||||
title="Chart"
|
||||
onError={() => {}}
|
||||
/>
|
||||
{lat != null && (
|
||||
<div style={styles.coords}>
|
||||
<span style={styles.coord}>{lat.toFixed(5)}°N</span>
|
||||
<span style={styles.coord}>{lon.toFixed(5)}°E</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { position: 'relative', flex: 1, borderRadius: 'var(--radius)', overflow: 'hidden', border: '1px solid var(--border)' },
|
||||
iframe: { width: '100%', height: '100%', border: 'none', background: 'var(--surface2)' },
|
||||
coords: {
|
||||
position: 'absolute', bottom: 12, left: 12,
|
||||
background: '#07111fcc', borderRadius: 6, padding: '6px 10px',
|
||||
display: 'flex', gap: 12,
|
||||
},
|
||||
coord: { fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--accent)' },
|
||||
}
|
||||
67
bordanlage/dashboard/src/components/nav/InstrumentPanel.jsx
Normal file
67
bordanlage/dashboard/src/components/nav/InstrumentPanel.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||
import Gauge from '../instruments/Gauge.jsx'
|
||||
import Compass from '../instruments/Compass.jsx'
|
||||
import WindRose from '../instruments/WindRose.jsx'
|
||||
|
||||
function DataRow({ label, value, unit }) {
|
||||
return (
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>{label}</span>
|
||||
<span style={styles.value}>
|
||||
{value != null ? `${typeof value === 'number' ? value.toFixed(1) : value}` : '—'}
|
||||
{unit && <span style={styles.unit}> {unit}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function InstrumentPanel() {
|
||||
const nmea = useNMEA()
|
||||
|
||||
return (
|
||||
<div style={styles.panel}>
|
||||
{/* Gauges row */}
|
||||
<div style={styles.gauges}>
|
||||
<Compass heading={nmea.heading} cog={nmea.cog} />
|
||||
<WindRose windAngle={nmea.windAngle} windSpeed={nmea.windSpeed} />
|
||||
<Gauge value={nmea.depth} min={0} max={30} label="Depth" unit="m" warning={3} danger={1.5} />
|
||||
<Gauge value={nmea.sog} min={0} max={12} label="SOG" unit="kn" />
|
||||
</div>
|
||||
|
||||
{/* Data table */}
|
||||
<div style={styles.table}>
|
||||
<DataRow label="COG" value={nmea.cog != null ? Math.round(nmea.cog) : null} unit="°" />
|
||||
<DataRow label="Heading" value={nmea.heading != null ? Math.round(nmea.heading) : null} unit="°" />
|
||||
<DataRow label="SOG" value={nmea.sog} unit="kn" />
|
||||
<DataRow label="Depth" value={nmea.depth} unit="m" />
|
||||
<DataRow label="Wind Speed" value={nmea.windSpeed} unit="kn" />
|
||||
<DataRow label="Wind Angle" value={nmea.windAngle} unit="°" />
|
||||
<DataRow label="Water Temp" value={nmea.waterTemp} unit="°C" />
|
||||
<DataRow label="Air Temp" value={nmea.airTemp} unit="°C" />
|
||||
<DataRow label="RPM" value={nmea.rpm} unit="" />
|
||||
<DataRow label="Rudder" value={nmea.rudder} unit="°" />
|
||||
<DataRow label="Fuel" value={nmea.fuel} unit="%" />
|
||||
<DataRow label="Lat" value={nmea.lat?.toFixed(5)} unit="°N" />
|
||||
<DataRow label="Lon" value={nmea.lon?.toFixed(5)} unit="°E" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
panel: { display: 'flex', flexDirection: 'column', gap: 16 },
|
||||
gauges: { display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' },
|
||||
table: {
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||
gap: '0 24px', background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)', padding: 16,
|
||||
border: '1px solid var(--border)',
|
||||
},
|
||||
row: {
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '7px 0', borderBottom: '1px solid var(--border)',
|
||||
},
|
||||
label: { fontSize: 12, color: 'var(--muted)' },
|
||||
value: { fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)' },
|
||||
unit: { color: 'var(--muted)', fontSize: 11 },
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
function BatteryBar({ label, voltage, nominal, low, critical }) {
|
||||
const percent = Math.min(100, Math.max(0, ((voltage - critical) / (nominal - critical)) * 100))
|
||||
const color = voltage < critical ? 'var(--danger)' : voltage < low ? 'var(--warning)' : 'var(--success)'
|
||||
|
||||
return (
|
||||
<div style={styles.battery}>
|
||||
<div style={styles.header}>
|
||||
<span style={styles.label}>{label}</span>
|
||||
<span style={{ ...styles.voltage, color }}>{voltage != null ? `${voltage.toFixed(2)} V` : '—'}</span>
|
||||
</div>
|
||||
<div style={styles.barBg}>
|
||||
<div style={{ ...styles.barFill, width: `${percent}%`, background: color, transition: 'width 0.6s, background 0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BatteryStatus({ battery1, battery2 }) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<BatteryBar label="Starter (12V)" voltage={battery1} nominal={12.8} low={12.2} critical={11.8} />
|
||||
<BatteryBar label="House (24V)" voltage={battery2} nominal={25.6} low={24.8} critical={23.5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { display: 'flex', flexDirection: 'column', gap: 14, padding: 16, background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' },
|
||||
battery: { display: 'flex', flexDirection: 'column', gap: 6 },
|
||||
header: { display: 'flex', justifyContent: 'space-between' },
|
||||
label: { fontSize: 12, color: 'var(--muted)' },
|
||||
voltage: { fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600 },
|
||||
barBg: { height: 8, background: 'var(--border)', borderRadius: 4, overflow: 'hidden' },
|
||||
barFill: { height: '100%', borderRadius: 4 },
|
||||
}
|
||||
22
bordanlage/dashboard/src/components/systems/EngineData.jsx
Normal file
22
bordanlage/dashboard/src/components/systems/EngineData.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Gauge from '../instruments/Gauge.jsx'
|
||||
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||
|
||||
export default function EngineData() {
|
||||
const { rpm, waterTemp, fuel } = useNMEA()
|
||||
|
||||
return (
|
||||
<div style={styles.grid}>
|
||||
<Gauge value={rpm} min={0} max={3000} label="RPM" unit="rpm" warning={2500} danger={2800} />
|
||||
<Gauge value={waterTemp} min={0} max={120} label="Coolant" unit="°C" warning={85} danger={100} />
|
||||
<Gauge value={fuel} min={0} max={100} label="Fuel" unit="%" warning={20} danger={10} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
grid: {
|
||||
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
|
||||
padding: 16, background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useDocker } from '../../hooks/useDocker.js'
|
||||
|
||||
const STATUS_COLOR = {
|
||||
online: 'var(--success)',
|
||||
offline: 'var(--danger)',
|
||||
unknown: 'var(--muted)',
|
||||
}
|
||||
|
||||
export default function ServiceHealth() {
|
||||
const { services, refresh } = useDocker()
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<span style={styles.title}>Services</span>
|
||||
<button style={styles.refreshBtn} onClick={refresh}>↻ Refresh</button>
|
||||
</div>
|
||||
{services.map(s => (
|
||||
<div key={s.id} style={styles.row}>
|
||||
<span style={{ ...styles.dot, color: STATUS_COLOR[s.status] }}>●</span>
|
||||
<span style={styles.name}>{s.name}</span>
|
||||
<a href={s.url} target="_blank" rel="noreferrer" style={styles.link}>{s.url.replace(/^https?:\/\//, '')}</a>
|
||||
<span style={{ ...styles.status, color: STATUS_COLOR[s.status] }}>{s.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { padding: 16, background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 10 },
|
||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 },
|
||||
title: { fontWeight: 600, fontSize: 13, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)' },
|
||||
refreshBtn: { fontSize: 12, color: 'var(--accent)', minWidth: 0, minHeight: 0, padding: '4px 8px', height: 'auto' },
|
||||
row: { display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', borderBottom: '1px solid var(--border)' },
|
||||
dot: { fontSize: 10 },
|
||||
name: { minWidth: 90, fontWeight: 500, fontSize: 13 },
|
||||
link: { flex: 1, fontSize: 11, color: 'var(--muted)', textDecoration: 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||
status: { fontSize: 11, fontWeight: 600, textTransform: 'uppercase' },
|
||||
}
|
||||
40
bordanlage/dashboard/src/hooks/useDocker.js
Normal file
40
bordanlage/dashboard/src/hooks/useDocker.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const SERVICES = [
|
||||
{ id: 'signalk', name: 'SignalK', url: 'http://localhost:3000/signalk' },
|
||||
{ id: 'snapserver', name: 'Snapcast', url: 'http://localhost:1780' },
|
||||
{ id: 'mopidy', name: 'Mopidy', url: 'http://localhost:6680' },
|
||||
{ id: 'jellyfin', name: 'Jellyfin', url: 'http://localhost:8096' },
|
||||
{ id: 'portainer', name: 'Portainer', url: 'http://localhost:9000' },
|
||||
]
|
||||
|
||||
async function ping(url) {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
|
||||
return res.ok || res.status < 500
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function useDocker() {
|
||||
const [services, setServices] = useState(
|
||||
SERVICES.map(s => ({ ...s, status: 'unknown' }))
|
||||
)
|
||||
|
||||
async function checkAll() {
|
||||
const results = await Promise.all(SERVICES.map(async s => ({
|
||||
...s,
|
||||
status: await ping(s.url) ? 'online' : 'offline',
|
||||
})))
|
||||
setServices(results)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkAll()
|
||||
const timer = setInterval(checkAll, 30000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
return { services, refresh: checkAll }
|
||||
}
|
||||
93
bordanlage/dashboard/src/hooks/useNMEA.js
Normal file
93
bordanlage/dashboard/src/hooks/useNMEA.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getApi } from '../mock/index.js'
|
||||
|
||||
const radToDeg = r => r * 180 / Math.PI
|
||||
const kelvinToC = k => k - 273.15
|
||||
|
||||
function parseDelta(updates) {
|
||||
const values = {}
|
||||
for (const update of updates) {
|
||||
for (const { path, value } of (update.values || [])) {
|
||||
values[path] = value
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
sog: null, cog: null, heading: null, depth: null,
|
||||
windSpeed: null, windAngle: null, windDirection: null,
|
||||
lat: null, lon: null, rpm: null,
|
||||
battery1: null, battery2: null,
|
||||
waterTemp: null, airTemp: null,
|
||||
rudder: null, fuel: null,
|
||||
connected: false,
|
||||
}
|
||||
|
||||
export function useNMEA() {
|
||||
const [data, setData] = useState(DEFAULT_STATE)
|
||||
const clientRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const { signalk } = getApi()
|
||||
clientRef.current = signalk
|
||||
|
||||
const onDelta = ({ updates }) => {
|
||||
const v = parseDelta(updates)
|
||||
|
||||
setData(prev => {
|
||||
const next = { ...prev, connected: true }
|
||||
|
||||
if (v['navigation.speedOverGround'] != null)
|
||||
next.sog = v['navigation.speedOverGround'] * 1.944 // m/s → knots
|
||||
if (v['navigation.courseOverGroundTrue'] != null)
|
||||
next.cog = radToDeg(v['navigation.courseOverGroundTrue'])
|
||||
if (v['navigation.headingTrue'] != null)
|
||||
next.heading = radToDeg(v['navigation.headingTrue'])
|
||||
if (v['navigation.position'] != null) {
|
||||
next.lat = v['navigation.position'].latitude
|
||||
next.lon = v['navigation.position'].longitude
|
||||
}
|
||||
if (v['environment.depth.belowKeel'] != null)
|
||||
next.depth = v['environment.depth.belowKeel']
|
||||
if (v['environment.wind.speedApparent'] != null)
|
||||
next.windSpeed = v['environment.wind.speedApparent'] * 1.944
|
||||
if (v['environment.wind.angleApparent'] != null) {
|
||||
next.windAngle = radToDeg(v['environment.wind.angleApparent'])
|
||||
next.windDirection = ((next.heading || 0) + next.windAngle + 360) % 360
|
||||
}
|
||||
if (v['propulsion.main.revolutions'] != null)
|
||||
next.rpm = Math.round(v['propulsion.main.revolutions'] * 60)
|
||||
if (v['electrical.batteries.starter.voltage'] != null)
|
||||
next.battery1 = v['electrical.batteries.starter.voltage']
|
||||
if (v['electrical.batteries.house.voltage'] != null)
|
||||
next.battery2 = v['electrical.batteries.house.voltage']
|
||||
if (v['environment.water.temperature'] != null)
|
||||
next.waterTemp = kelvinToC(v['environment.water.temperature'])
|
||||
if (v['environment.outside.temperature'] != null)
|
||||
next.airTemp = kelvinToC(v['environment.outside.temperature'])
|
||||
if (v['steering.rudderAngle'] != null)
|
||||
next.rudder = radToDeg(v['steering.rudderAngle'])
|
||||
if (v['tanks.fuel.0.currentLevel'] != null)
|
||||
next.fuel = v['tanks.fuel.0.currentLevel'] * 100
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const onDisconnected = () => setData(prev => ({ ...prev, connected: false }))
|
||||
const onConnected = () => setData(prev => ({ ...prev, connected: true }))
|
||||
|
||||
signalk.on('delta', onDelta)
|
||||
signalk.on('connected', onConnected)
|
||||
signalk.on('disconnected', onDisconnected)
|
||||
|
||||
return () => {
|
||||
signalk.off('delta', onDelta)
|
||||
signalk.off('connected', onConnected)
|
||||
signalk.off('disconnected', onDisconnected)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return data
|
||||
}
|
||||
89
bordanlage/dashboard/src/hooks/usePlayer.js
Normal file
89
bordanlage/dashboard/src/hooks/usePlayer.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { getApi } from '../mock/index.js'
|
||||
|
||||
const DEFAULT = {
|
||||
currentTrack: null,
|
||||
state: 'stopped',
|
||||
position: 0,
|
||||
activeSource: 'mopidy',
|
||||
connected: false,
|
||||
}
|
||||
|
||||
export function usePlayer() {
|
||||
const [player, setPlayer] = useState(DEFAULT)
|
||||
const { mopidy } = getApi()
|
||||
const posTimer = useRef(null)
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [track, state, position] = await Promise.all([
|
||||
mopidy.call('playback.get_current_track'),
|
||||
mopidy.call('playback.get_state'),
|
||||
mopidy.call('playback.get_time_position'),
|
||||
])
|
||||
|
||||
setPlayer(prev => ({
|
||||
...prev,
|
||||
connected: true,
|
||||
state: state || 'stopped',
|
||||
position: position || 0,
|
||||
currentTrack: track ? {
|
||||
title: track.name,
|
||||
artist: track.artists?.map(a => a.name).join(', ') || 'Unknown',
|
||||
album: track.album?.name || '',
|
||||
duration: track.length || 0,
|
||||
coverUrl: null,
|
||||
} : null,
|
||||
}))
|
||||
} catch {
|
||||
setPlayer(prev => ({ ...prev, connected: false }))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
|
||||
const onStateChange = ({ new_state }) =>
|
||||
setPlayer(prev => ({ ...prev, state: new_state }))
|
||||
|
||||
const onTrackChange = ({ tl_track }) => {
|
||||
const track = tl_track?.track
|
||||
if (track) setPlayer(prev => ({
|
||||
...prev,
|
||||
position: 0,
|
||||
currentTrack: {
|
||||
title: track.name,
|
||||
artist: track.artists?.map(a => a.name).join(', ') || 'Unknown',
|
||||
album: track.album?.name || '',
|
||||
duration: track.length || 0,
|
||||
coverUrl: null,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
mopidy.on('event:playbackStateChanged', onStateChange)
|
||||
mopidy.on('event:trackPlaybackStarted', onTrackChange)
|
||||
|
||||
// Advance position locally
|
||||
posTimer.current = setInterval(() => {
|
||||
setPlayer(prev => {
|
||||
if (prev.state !== 'playing') return prev
|
||||
return { ...prev, position: prev.position + 1000 }
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
mopidy.off('event:playbackStateChanged', onStateChange)
|
||||
mopidy.off('event:trackPlaybackStarted', onTrackChange)
|
||||
clearInterval(posTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const play = useCallback(() => mopidy.call('playback.play'), [])
|
||||
const pause = useCallback(() => mopidy.call('playback.pause'), [])
|
||||
const next = useCallback(() => mopidy.call('playback.next'), [])
|
||||
const previous = useCallback(() => mopidy.call('playback.previous'), [])
|
||||
const seek = useCallback(pos => mopidy.call('playback.seek', { time_position: pos }), [])
|
||||
|
||||
return { ...player, play, pause, next, previous, seek }
|
||||
}
|
||||
69
bordanlage/dashboard/src/hooks/useZones.js
Normal file
69
bordanlage/dashboard/src/hooks/useZones.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { getApi } from '../mock/index.js'
|
||||
|
||||
function parseStatus(status) {
|
||||
return (status?.server?.groups || []).map(group => {
|
||||
const client = group.clients?.[0] || {}
|
||||
return {
|
||||
id: group.id,
|
||||
name: client.config?.name || group.id,
|
||||
active: client.connected ?? false,
|
||||
volume: client.config?.volume?.percent ?? 50,
|
||||
muted: client.config?.volume?.muted ?? false,
|
||||
source: group.stream_id || 'Mopidy',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useZones() {
|
||||
const [zones, setZones] = useState([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const { snapcast } = getApi()
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const status = await snapcast.call('Server.GetStatus')
|
||||
if (alive) {
|
||||
setZones(parseStatus(status))
|
||||
setConnected(true)
|
||||
}
|
||||
} catch {
|
||||
if (alive) setConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdate = (msg) => {
|
||||
if (msg?.result?.server) setZones(parseStatus(msg.result))
|
||||
}
|
||||
|
||||
snapcast.on('update', onUpdate)
|
||||
loadStatus()
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
snapcast.off('update', onUpdate)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setVolume = useCallback(async (zoneId, volume) => {
|
||||
await snapcast.call('Client.SetVolume', { id: zoneId, volume: { percent: volume, muted: false } })
|
||||
}, [])
|
||||
|
||||
const setMuted = useCallback(async (zoneId, muted) => {
|
||||
await snapcast.call('Client.SetMuted', { id: zoneId, muted })
|
||||
}, [])
|
||||
|
||||
const setSource = useCallback(async (zoneId, streamId) => {
|
||||
await snapcast.call('Group.SetStream', { id: zoneId, stream_id: streamId })
|
||||
}, [])
|
||||
|
||||
const toggleZone = useCallback(async (zoneId) => {
|
||||
const zone = zones.find(z => z.id === zoneId)
|
||||
if (zone) await setMuted(zoneId, !zone.muted)
|
||||
}, [zones, setMuted])
|
||||
|
||||
return { zones, connected, setVolume, setMuted, setSource, toggleZone }
|
||||
}
|
||||
79
bordanlage/dashboard/src/index.css
Normal file
79
bordanlage/dashboard/src/index.css
Normal file
@@ -0,0 +1,79 @@
|
||||
/* ─── CSS Custom Properties ───────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #07111f;
|
||||
--surface: #0a1928;
|
||||
--surface2: #0d2035;
|
||||
--border: #1e2a3a;
|
||||
--text: #e2eaf2;
|
||||
--muted: #4a6080;
|
||||
--accent: #38bdf8;
|
||||
--success: #34d399;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--spotify: #1DB954;
|
||||
--airplay: #60a5fa;
|
||||
|
||||
--font-ui: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'DM Mono', 'Courier New', monospace;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 16px;
|
||||
}
|
||||
|
||||
/* ─── Reset ───────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 16px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ───────────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: var(--surface); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
/* ─── Utilities ───────────────────────────────────────────────────────────── */
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.muted { color: var(--muted); }
|
||||
.accent { color: var(--accent); }
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
button:active { opacity: 0.7; }
|
||||
|
||||
input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
10
bordanlage/dashboard/src/main.jsx
Normal file
10
bordanlage/dashboard/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
38
bordanlage/dashboard/src/mock/index.js
Normal file
38
bordanlage/dashboard/src/mock/index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Mock router: returns real API clients in production, mock implementations in dev.
|
||||
import { createSignalKMock } from './signalk.mock.js'
|
||||
import { createSnapcastMock } from './snapcast.mock.js'
|
||||
import { createMopidyMock } from './mopidy.mock.js'
|
||||
import { createSignalKClient } from '../api/signalk.js'
|
||||
import { createSnapcastClient } from '../api/snapcast.js'
|
||||
import { createMopidyClient } from '../api/mopidy.js'
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
export function createApi() {
|
||||
if (isDev) {
|
||||
return {
|
||||
signalk: createSignalKMock(),
|
||||
snapcast: createSnapcastMock(),
|
||||
mopidy: createMopidyMock(),
|
||||
isMock: true,
|
||||
}
|
||||
}
|
||||
|
||||
const snapcastHost = import.meta.env.VITE_SNAPCAST_HOST || 'localhost'
|
||||
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
|
||||
const mopidyHost = import.meta.env.VITE_MOPIDY_HOST || 'localhost'
|
||||
|
||||
return {
|
||||
signalk: createSignalKClient(`ws://${signalkHost}:3000`),
|
||||
snapcast: createSnapcastClient(`ws://${snapcastHost}:1705`),
|
||||
mopidy: createMopidyClient(`ws://${mopidyHost}:6680`),
|
||||
isMock: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton – one API instance for the whole app
|
||||
let _api = null
|
||||
export function getApi() {
|
||||
if (!_api) _api = createApi()
|
||||
return _api
|
||||
}
|
||||
149
bordanlage/dashboard/src/mock/mopidy.mock.js
Normal file
149
bordanlage/dashboard/src/mock/mopidy.mock.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// Simulates the Mopidy JSON-RPC WebSocket API.
|
||||
|
||||
const TRACKS = [
|
||||
{
|
||||
uri: 'mock:track:1',
|
||||
name: 'Ocean Drive',
|
||||
artists: [{ name: 'Duke Dumont' }],
|
||||
album: { name: 'Ocean Drive', images: [] },
|
||||
length: 232000,
|
||||
},
|
||||
{
|
||||
uri: 'mock:track:2',
|
||||
name: 'Feel It Still',
|
||||
artists: [{ name: 'Portugal. The Man' }],
|
||||
album: { name: 'Woodstock', images: [] },
|
||||
length: 178000,
|
||||
},
|
||||
{
|
||||
uri: 'mock:track:3',
|
||||
name: 'Sailing',
|
||||
artists: [{ name: 'Christopher Cross' }],
|
||||
album: { name: 'Christopher Cross', images: [] },
|
||||
length: 261000,
|
||||
},
|
||||
{
|
||||
uri: 'mock:track:4',
|
||||
name: 'Beyond the Sea',
|
||||
artists: [{ name: 'Bobby Darin' }],
|
||||
album: { name: 'That\'s All', images: [] },
|
||||
length: 185000,
|
||||
},
|
||||
{
|
||||
uri: 'mock:track:5',
|
||||
name: 'Into the Mystic',
|
||||
artists: [{ name: 'Van Morrison' }],
|
||||
album: { name: 'Moondance', images: [] },
|
||||
length: 215000,
|
||||
},
|
||||
]
|
||||
|
||||
const RADIO_STATIONS = [
|
||||
{ uri: 'http://stream.swr3.de/swr3/mp3-128/stream.mp3', name: 'SWR3' },
|
||||
{ uri: 'http://ndr.de/ndr1welle-nord-128.mp3', name: 'NDR 1 Welle Nord' },
|
||||
{ uri: 'http://live-bauhaus.radiobt.de/bauhaus/mp3-128', name: 'Radio Bauhaus' },
|
||||
]
|
||||
|
||||
export function createMopidyMock() {
|
||||
let state = 'playing'
|
||||
let currentIndex = 0
|
||||
let position = 0
|
||||
let positionTimer = null
|
||||
|
||||
const listeners = {}
|
||||
|
||||
function emit(event, data) {
|
||||
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (positionTimer) return
|
||||
positionTimer = setInterval(() => {
|
||||
if (state === 'playing') {
|
||||
position += 1000
|
||||
const track = TRACKS[currentIndex]
|
||||
if (position >= track.length) {
|
||||
currentIndex = (currentIndex + 1) % TRACKS.length
|
||||
position = 0
|
||||
emit('event:trackPlaybackStarted', { tl_track: { track: TRACKS[currentIndex] } })
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
startTimer()
|
||||
|
||||
async function call(method, params = {}) {
|
||||
await new Promise(r => setTimeout(r, 15))
|
||||
|
||||
switch (method) {
|
||||
case 'playback.get_current_track':
|
||||
return TRACKS[currentIndex]
|
||||
|
||||
case 'playback.get_state':
|
||||
return state
|
||||
|
||||
case 'playback.get_time_position':
|
||||
return position
|
||||
|
||||
case 'playback.play':
|
||||
state = 'playing'
|
||||
emit('event:playbackStateChanged', { new_state: 'playing' })
|
||||
return null
|
||||
|
||||
case 'playback.pause':
|
||||
state = 'paused'
|
||||
emit('event:playbackStateChanged', { new_state: 'paused' })
|
||||
return null
|
||||
|
||||
case 'playback.stop':
|
||||
state = 'stopped'
|
||||
position = 0
|
||||
emit('event:playbackStateChanged', { new_state: 'stopped' })
|
||||
return null
|
||||
|
||||
case 'playback.next':
|
||||
currentIndex = (currentIndex + 1) % TRACKS.length
|
||||
position = 0
|
||||
state = 'playing'
|
||||
emit('event:trackPlaybackStarted', { tl_track: { track: TRACKS[currentIndex] } })
|
||||
return null
|
||||
|
||||
case 'playback.previous':
|
||||
currentIndex = (currentIndex - 1 + TRACKS.length) % TRACKS.length
|
||||
position = 0
|
||||
state = 'playing'
|
||||
emit('event:trackPlaybackStarted', { tl_track: { track: TRACKS[currentIndex] } })
|
||||
return null
|
||||
|
||||
case 'playback.seek':
|
||||
position = params.time_position || 0
|
||||
return null
|
||||
|
||||
case 'tracklist.get_tracks':
|
||||
return TRACKS
|
||||
|
||||
case 'library.browse':
|
||||
return TRACKS.map(t => ({ uri: t.uri, name: t.name, type: 'track' }))
|
||||
|
||||
case 'library.search':
|
||||
return [{ tracks: TRACKS }]
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
on(event, fn) {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(fn)
|
||||
},
|
||||
off(event, fn) {
|
||||
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||
},
|
||||
getTracks: () => TRACKS,
|
||||
getRadioStations: () => RADIO_STATIONS,
|
||||
}
|
||||
}
|
||||
118
bordanlage/dashboard/src/mock/signalk.mock.js
Normal file
118
bordanlage/dashboard/src/mock/signalk.mock.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// Simulates a SignalK WebSocket delta stream with realistic Baltic Sea boat data.
|
||||
|
||||
const INTERVAL_MS = 1000
|
||||
|
||||
// Starting position: Kiel Fjord, Baltic Sea
|
||||
const BASE_LAT = 54.3233
|
||||
const BASE_LON = 10.1394
|
||||
|
||||
function randomWalk(value, min, max, step) {
|
||||
const delta = (Math.random() - 0.5) * step * 2
|
||||
return Math.min(max, Math.max(min, value + delta))
|
||||
}
|
||||
|
||||
function degToRad(d) { return d * Math.PI / 180 }
|
||||
|
||||
export function createSignalKMock() {
|
||||
const listeners = {}
|
||||
let timer = null
|
||||
let running = false
|
||||
|
||||
// Initial state
|
||||
const state = {
|
||||
sog: 5.2,
|
||||
cog: 215,
|
||||
heading: 217,
|
||||
depth: 12.4,
|
||||
windSpeed: 13.5,
|
||||
windAngle: 42,
|
||||
rpm: 1800,
|
||||
battery1: 12.6, // starter
|
||||
battery2: 25.1, // house (24V)
|
||||
waterTemp: 17.8,
|
||||
lat: BASE_LAT,
|
||||
lon: BASE_LON,
|
||||
routeIndex: 0,
|
||||
rudder: 2.5,
|
||||
airTemp: 14.2,
|
||||
fuel: 68,
|
||||
}
|
||||
|
||||
// Simulate boat moving along a rough course
|
||||
function advancePosition() {
|
||||
const speedMs = state.sog * 0.514 // knots to m/s
|
||||
const headRad = degToRad(state.heading)
|
||||
const dLat = (speedMs * Math.cos(headRad) * INTERVAL_MS / 1000) / 111320
|
||||
const dLon = (speedMs * Math.sin(headRad) * INTERVAL_MS / 1000) / (111320 * Math.cos(degToRad(state.lat)))
|
||||
state.lat += dLat
|
||||
state.lon += dLon
|
||||
}
|
||||
|
||||
function buildDelta() {
|
||||
state.sog = randomWalk(state.sog, 3.5, 8, 0.15)
|
||||
state.cog = randomWalk(state.cog, 200, 235, 1.5)
|
||||
state.heading = randomWalk(state.heading, 198, 237, 1.2)
|
||||
state.depth = randomWalk(state.depth, 6, 25, 0.3)
|
||||
state.windSpeed = randomWalk(state.windSpeed, 8, 22, 0.8)
|
||||
state.windAngle = randomWalk(state.windAngle, 25, 70, 2)
|
||||
state.rpm = Math.round(randomWalk(state.rpm, 1500, 2100, 40))
|
||||
state.battery1 = randomWalk(state.battery1, 12.2, 12.9, 0.02)
|
||||
state.battery2 = randomWalk(state.battery2, 24.5, 25.6, 0.04)
|
||||
state.waterTemp = randomWalk(state.waterTemp, 16, 20, 0.05)
|
||||
state.rudder = randomWalk(state.rudder, -15, 15, 1.5)
|
||||
advancePosition()
|
||||
|
||||
return {
|
||||
updates: [{
|
||||
source: { label: 'mock', type: 'NMEA2000' },
|
||||
timestamp: new Date().toISOString(),
|
||||
values: [
|
||||
{ path: 'navigation.speedOverGround', value: state.sog * 0.514 },
|
||||
{ path: 'navigation.courseOverGroundTrue', value: degToRad(state.cog) },
|
||||
{ path: 'navigation.headingTrue', value: degToRad(state.heading) },
|
||||
{ path: 'navigation.position', value: { latitude: state.lat, longitude: state.lon } },
|
||||
{ path: 'environment.depth.belowKeel', value: state.depth },
|
||||
{ path: 'environment.wind.speedApparent', value: state.windSpeed * 0.514 },
|
||||
{ path: 'environment.wind.angleApparent', value: degToRad(state.windAngle) },
|
||||
{ path: 'environment.water.temperature', value: state.waterTemp + 273.15 },
|
||||
{ path: 'environment.outside.temperature', value: state.airTemp + 273.15 },
|
||||
{ path: 'propulsion.main.revolutions', value: state.rpm / 60 },
|
||||
{ path: 'electrical.batteries.starter.voltage', value: state.battery1 },
|
||||
{ path: 'electrical.batteries.house.voltage', value: state.battery2 },
|
||||
{ path: 'steering.rudderAngle', value: degToRad(state.rudder) },
|
||||
{ path: 'tanks.fuel.0.currentLevel', value: state.fuel / 100 },
|
||||
]
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
function emit(event, data) {
|
||||
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running) return
|
||||
running = true
|
||||
// Send initial delta immediately
|
||||
emit('delta', buildDelta())
|
||||
timer = setInterval(() => emit('delta', buildDelta()), INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) clearInterval(timer)
|
||||
running = false
|
||||
}
|
||||
|
||||
return {
|
||||
on(event, fn) {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(fn)
|
||||
if (event === 'delta' && !running) start()
|
||||
},
|
||||
off(event, fn) {
|
||||
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||
},
|
||||
getSnapshot: () => ({ ...state }),
|
||||
disconnect: stop,
|
||||
}
|
||||
}
|
||||
92
bordanlage/dashboard/src/mock/snapcast.mock.js
Normal file
92
bordanlage/dashboard/src/mock/snapcast.mock.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// Simulates the Snapcast JSON-RPC API (WebSocket-based).
|
||||
|
||||
const STREAMS = [
|
||||
{ id: 'Spotify', status: 'idle', uri: { name: 'Spotify' } },
|
||||
{ id: 'AirPlay', status: 'idle', uri: { name: 'AirPlay' } },
|
||||
{ id: 'Mopidy', status: 'playing', uri: { name: 'Mopidy' } },
|
||||
]
|
||||
|
||||
const initialZones = [
|
||||
{ id: 'zone-salon', name: 'Salon', volume: 72, muted: false, connected: true, stream: 'Mopidy' },
|
||||
{ id: 'zone-cockpit', name: 'Cockpit', volume: 58, muted: false, connected: true, stream: 'Mopidy' },
|
||||
{ id: 'zone-bug', name: 'Bug', volume: 45, muted: true, connected: true, stream: 'Spotify' },
|
||||
{ id: 'zone-heck', name: 'Heck', volume: 60, muted: false, connected: false, stream: 'Mopidy' },
|
||||
]
|
||||
|
||||
export function createSnapcastMock() {
|
||||
const zones = initialZones.map(z => ({ ...z }))
|
||||
const listeners = {}
|
||||
|
||||
function emit(event, data) {
|
||||
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||
}
|
||||
|
||||
function buildStatus() {
|
||||
return {
|
||||
server: {
|
||||
groups: zones.map(z => ({
|
||||
id: z.id,
|
||||
name: z.name,
|
||||
stream_id: z.stream,
|
||||
clients: [{
|
||||
id: z.id,
|
||||
connected: z.connected,
|
||||
config: {
|
||||
name: z.name,
|
||||
volume: { percent: z.volume, muted: z.muted }
|
||||
}
|
||||
}]
|
||||
})),
|
||||
streams: STREAMS,
|
||||
version: '0.27.0',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function call(method, params = {}) {
|
||||
await new Promise(r => setTimeout(r, 10)) // simulate network
|
||||
|
||||
switch (method) {
|
||||
case 'Server.GetStatus':
|
||||
return buildStatus()
|
||||
|
||||
case 'Server.GetRPCVersion':
|
||||
return { major: 2, minor: 0, patch: 0 }
|
||||
|
||||
case 'Client.SetVolume': {
|
||||
const z = zones.find(z => z.id === params.id)
|
||||
if (z) { z.volume = params.volume.percent; z.muted = params.volume.muted }
|
||||
emit('update', buildStatus())
|
||||
return {}
|
||||
}
|
||||
|
||||
case 'Client.SetMuted': {
|
||||
const z = zones.find(z => z.id === params.id)
|
||||
if (z) z.muted = params.muted
|
||||
emit('update', buildStatus())
|
||||
return {}
|
||||
}
|
||||
|
||||
case 'Group.SetStream': {
|
||||
const z = zones.find(z => z.id === params.id)
|
||||
if (z) z.stream = params.stream_id
|
||||
emit('update', buildStatus())
|
||||
return {}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Snapcast mock: unknown method "${method}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
on(event, fn) {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(fn)
|
||||
},
|
||||
off(event, fn) {
|
||||
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||
},
|
||||
}
|
||||
}
|
||||
49
bordanlage/dashboard/src/pages/Audio.jsx
Normal file
49
bordanlage/dashboard/src/pages/Audio.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react'
|
||||
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
||||
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
||||
import SourcePicker from '../components/audio/SourcePicker.jsx'
|
||||
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
|
||||
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
|
||||
|
||||
const SUB_TABS = ['Zones', 'Radio', 'Library']
|
||||
|
||||
export default function Audio() {
|
||||
const [subTab, setSubTab] = useState('Zones')
|
||||
const [activeSource, setActiveSource] = useState('Mopidy')
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<NowPlaying />
|
||||
|
||||
<SourcePicker activeSource={activeSource} onSelect={setActiveSource} />
|
||||
|
||||
{/* Sub-tabs */}
|
||||
<div style={styles.subTabs}>
|
||||
{SUB_TABS.map(t => (
|
||||
<button key={t} style={{ ...styles.subTab, ...(subTab === t ? styles.subTabActive : {}) }}
|
||||
onClick={() => setSubTab(t)}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={styles.content}>
|
||||
{subTab === 'Zones' && <ZoneGrid />}
|
||||
{subTab === 'Radio' && <RadioBrowser />}
|
||||
{subTab === 'Library' && <LibraryBrowser />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flex: 1 },
|
||||
subTabs: { display: 'flex', gap: 2, background: 'var(--surface)', borderRadius: 'var(--radius)', padding: 3, border: '1px solid var(--border)' },
|
||||
subTab: {
|
||||
flex: 1, height: 36, fontSize: 13, fontWeight: 500,
|
||||
color: 'var(--muted)', borderRadius: 6,
|
||||
background: 'none', minHeight: 36,
|
||||
},
|
||||
subTabActive: { background: 'var(--surface2)', color: 'var(--text)' },
|
||||
content: { flex: 1 },
|
||||
}
|
||||
28
bordanlage/dashboard/src/pages/Navigation.jsx
Normal file
28
bordanlage/dashboard/src/pages/Navigation.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import ChartPlaceholder from '../components/nav/ChartPlaceholder.jsx'
|
||||
import InstrumentPanel from '../components/nav/InstrumentPanel.jsx'
|
||||
|
||||
export default function Navigation() {
|
||||
return (
|
||||
<div style={styles.layout}>
|
||||
<div style={styles.chart}>
|
||||
<ChartPlaceholder />
|
||||
</div>
|
||||
<div style={styles.panel}>
|
||||
<InstrumentPanel />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
layout: {
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
},
|
||||
chart: { flex: 2, display: 'flex', minHeight: 0 },
|
||||
panel: { flex: 1, overflow: 'auto', minHeight: 0 },
|
||||
}
|
||||
42
bordanlage/dashboard/src/pages/Overview.jsx
Normal file
42
bordanlage/dashboard/src/pages/Overview.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useNMEA } from '../hooks/useNMEA.js'
|
||||
import Compass from '../components/instruments/Compass.jsx'
|
||||
import SpeedLog from '../components/instruments/SpeedLog.jsx'
|
||||
import DepthSounder from '../components/instruments/DepthSounder.jsx'
|
||||
import WindRose from '../components/instruments/WindRose.jsx'
|
||||
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
||||
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
||||
|
||||
export default function Overview() {
|
||||
const nmea = useNMEA()
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
{/* Instruments row */}
|
||||
<section style={styles.instruments}>
|
||||
<Compass heading={nmea.heading} cog={nmea.cog} />
|
||||
<SpeedLog sog={nmea.sog} />
|
||||
<DepthSounder depth={nmea.depth} />
|
||||
<WindRose windAngle={nmea.windAngle} windSpeed={nmea.windSpeed} />
|
||||
</section>
|
||||
|
||||
{/* Now Playing */}
|
||||
<NowPlaying compact />
|
||||
|
||||
{/* Zone quick overview */}
|
||||
<section>
|
||||
<div style={styles.sectionTitle}>Audio Zones</div>
|
||||
<ZoneGrid />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 16, overflow: 'auto', flex: 1 },
|
||||
instruments: {
|
||||
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
|
||||
padding: 16, background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
|
||||
},
|
||||
sectionTitle: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 },
|
||||
}
|
||||
32
bordanlage/dashboard/src/pages/Systems.jsx
Normal file
32
bordanlage/dashboard/src/pages/Systems.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useNMEA } from '../hooks/useNMEA.js'
|
||||
import BatteryStatus from '../components/systems/BatteryStatus.jsx'
|
||||
import EngineData from '../components/systems/EngineData.jsx'
|
||||
import ServiceHealth from '../components/systems/ServiceHealth.jsx'
|
||||
|
||||
export default function Systems() {
|
||||
const { battery1, battery2 } = useNMEA()
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<section>
|
||||
<div style={styles.sectionTitle}>Batteries</div>
|
||||
<BatteryStatus battery1={battery1} battery2={battery2} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={styles.sectionTitle}>Engine</div>
|
||||
<EngineData />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={styles.sectionTitle}>Services</div>
|
||||
<ServiceHealth />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 20, overflow: 'auto', flex: 1 },
|
||||
sectionTitle: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 },
|
||||
}
|
||||
15
bordanlage/dashboard/vite.config.js
Normal file
15
bordanlage/dashboard/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 8080,
|
||||
proxy: {
|
||||
'/signalk': { target: 'http://localhost:3000', ws: true },
|
||||
'/snapcast-ws': { target: 'http://localhost:1780', ws: true },
|
||||
'/mopidy': { target: 'http://localhost:6680', ws: true },
|
||||
'/jellyfin': { target: 'http://localhost:8096', changeOrigin: true },
|
||||
}
|
||||
}
|
||||
})
|
||||
58
bordanlage/docker-compose.dev.yml
Normal file
58
bordanlage/docker-compose.dev.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
version: "3.9"
|
||||
|
||||
# Development override – run without any hardware.
|
||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
services:
|
||||
|
||||
signalk:
|
||||
environment:
|
||||
- SIGNALK_DEMO=true # Built-in demo NMEA data generator
|
||||
|
||||
# Spotify Connect still works over TCP on dev (no host network needed)
|
||||
librespot:
|
||||
environment:
|
||||
- LIBRESPOT_DISABLE_DISCOVERY=false
|
||||
ports:
|
||||
- "57621:57621/udp"
|
||||
- "57621:57621/tcp"
|
||||
|
||||
# avahi container for AirPlay mDNS discovery on Mac/Windows
|
||||
avahi:
|
||||
image: flungo/avahi
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /var/run/dbus:/var/run/dbus
|
||||
|
||||
# Null-player zones – no audio hardware needed
|
||||
zone-salon:
|
||||
command: snapclient --host snapserver --hostID zone-salon --player null
|
||||
|
||||
zone-cockpit:
|
||||
command: snapclient --host snapserver --hostID zone-cockpit --player null
|
||||
|
||||
zone-bug:
|
||||
command: snapclient --host snapserver --hostID zone-bug --player null
|
||||
|
||||
zone-heck:
|
||||
command: snapclient --host snapserver --hostID zone-heck --player null
|
||||
|
||||
# Vite dev server with HMR instead of built nginx image
|
||||
dashboard:
|
||||
build: .
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./dashboard:/app
|
||||
- /app/node_modules
|
||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 8080"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- VITE_SNAPCAST_HOST=localhost
|
||||
- VITE_SIGNALK_HOST=localhost
|
||||
- VITE_MOPIDY_HOST=localhost
|
||||
- VITE_JELLYFIN_HOST=localhost
|
||||
# Override the build-based image
|
||||
image: node:20-alpine
|
||||
204
bordanlage/docker-compose.yml
Normal file
204
bordanlage/docker-compose.yml
Normal file
@@ -0,0 +1,204 @@
|
||||
version: "3.9"
|
||||
|
||||
# Production docker-compose for boat deployment.
|
||||
# For development use: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
|
||||
volumes:
|
||||
signalk-data:
|
||||
mopidy-data:
|
||||
jellyfin-config:
|
||||
jellyfin-cache:
|
||||
portainer-data:
|
||||
pipes:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: tmpfs
|
||||
device: tmpfs
|
||||
|
||||
networks:
|
||||
bordanlage:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
|
||||
# ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
signalk:
|
||||
image: signalk/signalk-server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- signalk-data:/home/node/.signalk
|
||||
environment:
|
||||
- SIGNALK_ADMIN_USERNAME=admin
|
||||
- SIGNALK_ADMIN_PASSWORD=bordanlage
|
||||
# Uncomment for real NMEA hardware on boat:
|
||||
# devices:
|
||||
# - /dev/ttyUSB0:/dev/ttyUSB0
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
# ─── Audio Sources ─────────────────────────────────────────────────────────
|
||||
|
||||
librespot:
|
||||
image: ghcr.io/librespot-org/librespot:latest
|
||||
restart: unless-stopped
|
||||
# On Linux (boat): use host network for mDNS/Spotify discovery
|
||||
# network_mode: host
|
||||
ports:
|
||||
- "57621:57621/udp" # Spotify zeroconf discovery
|
||||
- "57621:57621/tcp"
|
||||
environment:
|
||||
- SPOTIFY_NAME=${SPOTIFY_NAME:-Bordanlage}
|
||||
- SPOTIFY_BITRATE=${SPOTIFY_BITRATE:-320}
|
||||
command: >
|
||||
--name "${SPOTIFY_NAME:-Bordanlage}"
|
||||
--bitrate ${SPOTIFY_BITRATE:-320}
|
||||
--backend pipe
|
||||
--device /tmp/audio/spotify.pcm
|
||||
--zeroconf-port 57621
|
||||
--cache-size-limit ${SPOTIFY_CACHE_SIZE:-1024}
|
||||
volumes:
|
||||
- pipes:/tmp/audio
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
shairport:
|
||||
image: mikebrady/shairport-sync:latest
|
||||
restart: unless-stopped
|
||||
# On Linux (boat): use host network for mDNS/AirPlay discovery
|
||||
# network_mode: host
|
||||
ports:
|
||||
- "5000:5000/tcp"
|
||||
- "5000:5000/udp"
|
||||
- "6001-6011:6001-6011/udp"
|
||||
volumes:
|
||||
- ./config/shairport.conf:/etc/shairport-sync.conf:ro
|
||||
- pipes:/tmp/audio
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
mopidy:
|
||||
build: ./docker/mopidy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6680:6680"
|
||||
volumes:
|
||||
- ./config/mopidy.conf:/etc/mopidy/mopidy.conf:ro
|
||||
- mopidy-data:/var/lib/mopidy
|
||||
- ${MUSIC_PATH:-./music}:/music:ro
|
||||
- pipes:/tmp/audio
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
# ─── Media Library ─────────────────────────────────────────────────────────
|
||||
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8096:8096"
|
||||
volumes:
|
||||
- jellyfin-config:/config
|
||||
- jellyfin-cache:/cache
|
||||
- ${MUSIC_PATH:-./music}:/music:ro
|
||||
environment:
|
||||
- JELLYFIN_PublishedServerUrl=http://localhost:8096
|
||||
# Uncomment for hardware video decoding on boat:
|
||||
# devices:
|
||||
# - /dev/dri:/dev/dri
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
# ─── Multiroom Audio ───────────────────────────────────────────────────────
|
||||
|
||||
snapserver:
|
||||
image: ghcr.io/badaix/snapcast:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1704:1704" # Snapcast protocol
|
||||
- "1705:1705" # Control API (JSON-RPC)
|
||||
- "1780:1780" # Snapweb UI
|
||||
volumes:
|
||||
- ./config/snapserver.conf:/etc/snapserver.conf:ro
|
||||
- pipes:/tmp/audio
|
||||
depends_on:
|
||||
- librespot
|
||||
- mopidy
|
||||
command: snapserver -c /etc/snapserver.conf
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
zone-salon:
|
||||
image: ghcr.io/badaix/snapcast:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- snapserver
|
||||
# On boat: add --soundcard hw:0,0 and device /dev/snd
|
||||
# devices:
|
||||
# - /dev/snd:/dev/snd
|
||||
command: snapclient --host snapserver --hostID zone-salon --player null
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
zone-cockpit:
|
||||
image: ghcr.io/badaix/snapcast:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- snapserver
|
||||
command: snapclient --host snapserver --hostID zone-cockpit --player null
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
zone-bug:
|
||||
image: ghcr.io/badaix/snapcast:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- snapserver
|
||||
command: snapclient --host snapserver --hostID zone-bug --player null
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
zone-heck:
|
||||
image: ghcr.io/badaix/snapcast:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- snapserver
|
||||
command: snapclient --host snapserver --hostID zone-heck --player null
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
# ─── Management ────────────────────────────────────────────────────────────
|
||||
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9000:9000"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- portainer-data:/data
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
# ─── Dashboard ─────────────────────────────────────────────────────────────
|
||||
|
||||
dashboard:
|
||||
build:
|
||||
context: ./dashboard
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
- VITE_SNAPCAST_HOST=${VITE_SNAPCAST_HOST:-localhost}
|
||||
- VITE_SIGNALK_HOST=${VITE_SIGNALK_HOST:-localhost}
|
||||
- VITE_MOPIDY_HOST=${VITE_MOPIDY_HOST:-localhost}
|
||||
- VITE_JELLYFIN_HOST=${VITE_JELLYFIN_HOST:-localhost}
|
||||
depends_on:
|
||||
- snapserver
|
||||
- signalk
|
||||
- mopidy
|
||||
networks:
|
||||
- bordanlage
|
||||
20
bordanlage/docker/mopidy/Dockerfile
Normal file
20
bordanlage/docker/mopidy/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM ghcr.io/mopidy/mopidy:latest
|
||||
|
||||
USER root
|
||||
|
||||
RUN pip3 install \
|
||||
mopidy-iris \
|
||||
mopidy-local \
|
||||
mopidy-stream \
|
||||
mopidy-tunein \
|
||||
mopidy-podcast \
|
||||
--break-system-packages
|
||||
|
||||
# Ensure audio pipe directory exists at startup
|
||||
RUN echo '#!/bin/sh\nmkdir -p /tmp/audio\nexec "$@"' > /entrypoint-wrapper.sh \
|
||||
&& chmod +x /entrypoint-wrapper.sh
|
||||
|
||||
USER mopidy
|
||||
|
||||
ENTRYPOINT ["/entrypoint-wrapper.sh"]
|
||||
CMD ["mopidy", "--config", "/etc/mopidy/mopidy.conf"]
|
||||
17
bordanlage/scripts/init-pipes.sh
Executable file
17
bordanlage/scripts/init-pipes.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
PIPE_DIR="${PIPE_DIR:-/tmp/audio}"
|
||||
mkdir -p "$PIPE_DIR"
|
||||
|
||||
for pipe in spotify.pcm airplay.pcm mopidy.pcm; do
|
||||
path="$PIPE_DIR/$pipe"
|
||||
if [ ! -p "$path" ]; then
|
||||
mkfifo "$path"
|
||||
echo "Created pipe: $path"
|
||||
else
|
||||
echo "Pipe already exists: $path"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All audio pipes ready."
|
||||
25
bordanlage/scripts/setup-boot.sh
Executable file
25
bordanlage/scripts/setup-boot.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
echo "Setting up production (boat) environment..."
|
||||
|
||||
# Copy .env if not exists
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
echo "Created .env – please edit SPOTIFY_NAME, BOAT_NAME, BOAT_MMSI"
|
||||
fi
|
||||
|
||||
# Create music directory
|
||||
mkdir -p music
|
||||
|
||||
# Init pipes (persistent on boat)
|
||||
PIPE_DIR=/tmp/audio bash scripts/init-pipes.sh
|
||||
|
||||
echo ""
|
||||
echo "Edit .env then run 'make boot' to start."
|
||||
echo "Dashboard: http://<boat-ip>:8080"
|
||||
echo ""
|
||||
echo "IMPORTANT: Review docker-compose.yml and uncomment:"
|
||||
echo " - /dev/ttyUSB0 for NMEA hardware"
|
||||
echo " - /dev/snd for zone audio output"
|
||||
echo " - /dev/dri for hardware video decoding"
|
||||
21
bordanlage/scripts/setup-dev.sh
Executable file
21
bordanlage/scripts/setup-dev.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
echo "Setting up development environment..."
|
||||
|
||||
# Copy .env if not exists
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
echo "Created .env from .env.example"
|
||||
fi
|
||||
|
||||
# Create music directory
|
||||
mkdir -p music
|
||||
echo "Music directory: ./music (drop your files here)"
|
||||
|
||||
# Init pipes
|
||||
bash scripts/init-pipes.sh
|
||||
|
||||
echo ""
|
||||
echo "Done! Run 'make dev' to start all services."
|
||||
echo "Dashboard: http://localhost:8080"
|
||||
Reference in New Issue
Block a user