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:
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user