New Features and fixes

This commit is contained in:
2026-03-20 13:27:02 +00:00
parent a91be98200
commit 68e8e3fc85
18 changed files with 388 additions and 236 deletions

1
.gitignore vendored
View File

@@ -52,3 +52,4 @@ dbus-windy-station/station_config.json
# Design reference assets (kept locally, not in repo)
inspiration assets/
venus-data.zip

View File

@@ -55,7 +55,7 @@ from ramp_controller import RampController
# Version
VERSION = '1.1.1'
VERSION = '1.2.0'
# D-Bus service name for our addon
SERVICE_NAME = 'com.victronenergy.generatorramp'
@@ -113,6 +113,11 @@ class GeneratorRampController:
# Enabled flag
self.enabled = True
# Energy accumulation (Wh -> kWh)
self._run_energy_wh = 0.0
self._last_run_energy_wh = 0.0
self._energy_last_time = None
# D-Bus connection
self.bus = dbus.SystemBus()
@@ -236,6 +241,12 @@ class GeneratorRampController:
self.dbus_service.add_path('/Ramp/TimeRemaining', 0,
gettextcallback=lambda p, v: f"{v//60}m {v%60}s" if v > 0 else "0s")
# Energy accumulation (kWh)
self.dbus_service.add_path('/Energy/CurrentRun', 0.0,
gettextcallback=lambda p, v: f"{v:.1f} kWh")
self.dbus_service.add_path('/Energy/LastRun', 0.0,
gettextcallback=lambda p, v: f"{v:.1f} kWh")
# Fast recovery status
self.dbus_service.add_path('/Recovery/InFastRamp', 0)
self.dbus_service.add_path('/Recovery/FastRampTarget', 0.0,
@@ -735,9 +746,28 @@ class GeneratorRampController:
self._read_output_power()
self._read_current_limit()
# Accumulate energy while generator is providing AC power
if self.ac_connected and self.generator_state in [
GENERATOR_STATE['RUNNING'], GENERATOR_STATE['WARMUP'], GENERATOR_STATE['COOLDOWN']
]:
total_power = self.current_l1_power + self.current_l2_power
if self._energy_last_time is not None and total_power > 0:
dt_hours = (now - self._energy_last_time) / 3600.0
self._run_energy_wh += total_power * dt_hours
self.dbus_service['/Energy/CurrentRun'] = round(self._run_energy_wh / 1000.0, 2)
self._energy_last_time = now
else:
self._energy_last_time = None
# Check for generator stop
if self.generator_state == GENERATOR_STATE['STOPPED']:
if self.state != self.STATE_IDLE:
if self._run_energy_wh > 0:
self._last_run_energy_wh = self._run_energy_wh
self.dbus_service['/Energy/LastRun'] = round(self._last_run_energy_wh / 1000.0, 2)
self.logger.info(f"Run energy: {self._last_run_energy_wh / 1000.0:.2f} kWh")
self._run_energy_wh = 0.0
self.dbus_service['/Energy/CurrentRun'] = 0.0
self.logger.info("Generator stopped")
self._transition_to(self.STATE_IDLE)
return True

View File

@@ -82,3 +82,12 @@ echo ""
echo "Deploy via USB:"
echo " Copy venus-data.zip to a USB stick, insert into Cerbo, reboot."
echo ""
read -p "Copy tarball to remote cerbo host? [y/N] " answer
if [[ "$answer" =~ ^[Yy]$ ]]; then
echo "Copying to cerbo..."
scp "$OUTPUT_DIR/${DIST_NAME}.tar.gz" cerbo:/data/
echo "Done."
else
echo "Skipping remote copy."
fi

View File

@@ -7,6 +7,8 @@ import { useVisibleWidgetsStore } from "./modules"
import { Marine2 } from "./Marine2"
import Connecting from "./components/ui/Connecting"
import { appErrorBoundaryProps } from "./components/ui/Error/appErrorBoundary"
import { useSunsetModeStore } from "./utils/hooks/use-sunset-mode-store"
import { useSunriseSunsetMode } from "./utils/hooks/use-sunrise-sunset-mode"
import "./css/global.css"
export type AppProps = {
@@ -23,8 +25,10 @@ const App = (props: AppProps) => {
const locale = getLocale()
const visibleWidgetsStore = useVisibleWidgetsStore()
const { themeStore } = useTheme()
const sunsetStore = useSunsetModeStore()
useVebus()
useSunriseSunsetMode(sunsetStore.enabled)
useEffect(() => {
if (!appStore.remote) {

View File

@@ -6,18 +6,18 @@ import BackIcon from "../../../images/icons/back.svg"
import DockViewIcon from "../../../images/icons/dock-view.svg"
import WavesIcon from "../../../images/icons/waves.svg"
import { AppViews, useAppViewsStore } from "../../../modules/AppViews"
import SwitchingPane from "../../views/SwitchingPane"
const Footer = ({ pageSelectorProps }: Props) => {
const appViewsStore = useAppViewsStore()
const [isShowingBackButton, setIsShowingBackButton] = useState(appViewsStore.currentView !== AppViews.ROOT)
const [isShowingBackButton, setIsShowingBackButton] = useState(
appViewsStore.currentView !== AppViews.CUSTOM_MOORING_VIEW,
)
const handleBackClick = () => {
appViewsStore.setView(AppViews.ROOT)
appViewsStore.setView(AppViews.CUSTOM_MOORING_VIEW)
}
useEffect(() => {
setIsShowingBackButton(appViewsStore.currentView !== AppViews.ROOT)
setIsShowingBackButton(appViewsStore.currentView !== AppViews.CUSTOM_MOORING_VIEW)
}, [appViewsStore.currentView])
return (
@@ -49,7 +49,6 @@ const Footer = ({ pageSelectorProps }: Props) => {
>
<DockViewIcon className="text-content-victronBlue" alt="Mooring View" />
</div>
<SwitchingPane />
<SettingsMenu />
</div>
)

View File

@@ -8,6 +8,7 @@ import CloseIcon from "../../../images/icons/close.svg"
import ToggleSwitch from "../ToggleSwitch"
import RadioButton from "../RadioButton"
import { AppViews, useAppViewsStore } from "../../../modules/AppViews"
import { useSunsetModeStore } from "../../../utils/hooks/use-sunset-mode-store"
import Button from "../Button"
import classNames from "classnames"
@@ -15,6 +16,7 @@ const SettingsMenu = () => {
const { locked, toggleLocked } = useAppStore()
const { themeStore } = useTheme()
const appViewsStore = useAppViewsStore()
const sunsetStore = useSunsetModeStore()
const [isModalOpen, setIsModalOpen] = useState(false)
const [isHorizontal, setIsHorizontal] = useState(false)
@@ -119,10 +121,24 @@ const SettingsMenu = () => {
</span>
<ToggleSwitch
id="ToggleAutoMode"
onChange={(e) => themeStore.setAutoMode(e.target.checked)}
onChange={(e) => {
themeStore.setAutoMode(e.target.checked)
if (e.target.checked) sunsetStore.setEnabled(false)
}}
selected={themeStore.autoMode}
/>
</label>
<label className="flex justify-between items-center pb-4 sm-m:pb-6 sm-l:pb-8">
<span className="mr-1 text-sm sm-m:mr-2 sm-l:text-base text-content-primary">Sunrise / Sunset</span>
<ToggleSwitch
id="ToggleSunsetMode"
onChange={(e) => {
sunsetStore.setEnabled(e.target.checked)
if (e.target.checked) themeStore.setAutoMode(false)
}}
selected={sunsetStore.enabled}
/>
</label>
<label className="flex justify-between items-center pb-4 sm-m:pb-6 sm-l:pb-8">
<span className="mr-1 text-sm sm-m:mr-2 sm-l:text-base text-content-primary">
{translate("common.night")}

View File

@@ -1,6 +1,7 @@
import React from "react"
import { observer } from "mobx-react"
import { useMeteoblueForecast, useWindyStation, ForecastData, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
import { parseSunTimeMs } from "../../../utils/sun-utils"
import { cardTitle } from "./cardStyles"
const WIND_COLORS = [
@@ -62,30 +63,6 @@ interface TimeInterval {
end: number
}
export function parseSunTimeMs(dateVal: unknown, timeStr: unknown): number | null {
if (typeof timeStr !== "string" || !timeStr || timeStr === "---") return null
let year: number, month: number, day: number
if (typeof dateVal === "string") {
const parts = dateVal.split("-")
if (parts.length < 3) return null
;[year, month, day] = parts.map(Number)
} else if (typeof dateVal === "number") {
const d = new Date(dateVal > 1e10 ? dateVal : dateVal * 1000)
year = d.getUTCFullYear()
month = d.getUTCMonth() + 1
day = d.getUTCDate()
} else {
return null
}
if (timeStr === "24:00") return Date.UTC(year, month - 1, day + 1, 0, 0)
const tp = timeStr.split(":")
if (tp.length < 2) return null
const [h, m] = tp.map(Number)
return Date.UTC(year, month - 1, day, h, m)
}
function buildNightIntervals(sunmoon: SunMoonData | undefined): TimeInterval[] {
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []
const events: { ts: number; type: "rise" | "set" }[] = []

View File

@@ -77,7 +77,7 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
const genRunning = genStatus.state === 1 || genStatus.state === 2 || genStatus.state === 3
const acInputActive = (consumption.inputL1 ?? 0) > 0 && (consumption.inputL2 ?? 0) > 0
const onBattery = !acInputActive
const sourceLabel = onBattery ? "Batt" : sourceType === 2 ? "Gen" : "Shore"
const sourceLabel = onBattery ? "Battery" : sourceType === 2 ? "Generator" : "Shore"
const sourcePower = onBattery ? Math.abs(bat.power ?? 0) : (consumption.inputTotal ?? 0)
return (
@@ -110,7 +110,7 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
className={`font-bold ${(bat.power ?? 0) > 0 ? "text-content-victronGreen" : "text-content-primary"}`}
style={{ fontSize: "0.875rem" }}
>
{bat.power != null ? `${bat.power > 0 ? "+" : ""}${Math.round(bat.power)}W` : "--"}
{bat.power != null ? `${Math.round(bat.power)}W` : "--"}
</div>
</div>
<div>
@@ -149,7 +149,7 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
{/* Source power bar: L1 + L2 consumption + battery charging */}
{(() => {
const batteryCharging = Math.max(0, bat.power ?? 0)
const barMax = 15000
const barMax = 12000
const l1Pct = ((consumption.l1 ?? 0) / barMax) * 100
const l2Pct = ((consumption.l2 ?? 0) / barMax) * 100
const batPct = (batteryCharging / barMax) * 100
@@ -157,9 +157,11 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
<div className="border-t border-outline-primary pt-2 mb-1">
<div className="flex items-center gap-1.5 flex-nowrap whitespace-nowrap overflow-hidden mb-1">
<span className="text-content-tertiary text-2xs">
Source: <span className="text-content-primary font-medium">{sourceLabel}</span>
<span className="text-content-primary font-medium">{sourceLabel}</span>
{sourcePower > 0 && (
<span className="text-content-primary font-medium ml-1">{Math.round(sourcePower / 1000)}kW</span>
<span className="text-content-primary font-medium ml-1">
{(sourcePower / 1000).toFixed(1)} kW
</span>
)}
</span>
{isGenerator && genStatus.conditionCode != null && genStatus.conditionCode > 0 && (
@@ -167,12 +169,47 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
{genStatus.conditionName}
</span>
)}
{isGenerator && genRunning && genStatus.runtimeFormatted && (
<span className="text-2xs text-content-secondary">Run: {genStatus.runtimeFormatted}</span>
)}
{isGenerator && !genRunning && genDaily.todayFormatted && (
<span className="text-2xs text-content-tertiary">Today: {genDaily.todayFormatted}</span>
)}
<span className="ml-auto flex items-center gap-1.5">
{isGenerator && genRunning && genStatus.runtimeFormatted && (
<span className="text-2xs">
<span className="text-content-tertiary">Run: </span>
<span className="text-content-primary font-medium">{genStatus.runtimeFormatted}</span>
</span>
)}
{isGenerator && genRunning && genRamp.currentRunEnergy != null && genRamp.currentRunEnergy > 0 && (
<span className="text-2xs">
<span className="text-content-primary font-medium">
{genRamp.currentRunEnergy.toFixed(1)} kWh
</span>
</span>
)}
{(isGenerator || onBattery) && !genRunning && genStatus.runtimeFormatted && (
<span className="text-2xs">
<span className="text-content-tertiary">
{onBattery && !isGenerator ? "Gen Last: " : "Last Run: "}
</span>
<span className="text-content-primary font-medium">{genStatus.runtimeFormatted}</span>
</span>
)}
{(isGenerator || onBattery) &&
!genRunning &&
genRamp.lastRunEnergy != null &&
genRamp.lastRunEnergy > 0 && (
<span className="text-2xs">
<span className="text-content-primary font-medium">
{genRamp.lastRunEnergy.toFixed(1)} kWh
</span>
</span>
)}
{(isGenerator || onBattery) && !genRunning && genDaily.todayFormatted && (
<span className="text-2xs">
<span className="text-content-tertiary">
{onBattery && !isGenerator ? "Gen Today: " : "Today: "}
</span>
<span className="text-content-primary font-medium">{genDaily.todayFormatted}</span>
</span>
)}
</span>
{isGenerator && genStatus.timerFormatted && (
<span className="text-2xs text-content-victronYellow">Timer: {genStatus.timerFormatted}</span>
)}
@@ -200,43 +237,28 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
title={`Battery: ${Math.round(batteryCharging)}W`}
/>
</div>
<div className="flex justify-between items-center mt-0.5 text-2xs text-content-tertiary tabular-nums">
<div className="flex gap-3">
<span>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 mr-0.5 align-middle" />
L1 <span className="text-content-primary font-medium">{Math.round(consumption.l1 ?? 0)}W</span>
</span>
<span>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 mr-0.5 align-middle" />
L2 <span className="text-content-primary font-medium">{Math.round(consumption.l2 ?? 0)}W</span>
</span>
{batteryCharging > 0 && (
<span>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-300 mr-0.5 align-middle" />
Bat <span className="text-content-primary font-medium">{Math.round(batteryCharging)}W</span>
</span>
)}
<span>
Total:{" "}
<span className="text-content-primary font-medium">
{(consumption.total ?? 0) > 0 ? `${Math.round((consumption.total ?? 0) / 1000)}kW` : "--"}
</span>
</span>
</div>
<span className="flex items-center">
<span
className="w-4 h-px flex-shrink-0"
style={{ background: "linear-gradient(to right, transparent, currentColor)" }}
/>
<span className="mx-0.5">15kW</span>
<span
className="w-1.5 h-2 flex-shrink-0 -translate-y-1/2"
style={{
borderBottom: "1px solid currentColor",
borderRight: "1px solid currentColor",
}}
/>
<div className="flex items-center mt-0.5 text-2xs text-content-tertiary tabular-nums gap-3">
<span>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 mr-0.5 align-middle" />
L1 <span className="text-content-primary font-medium">{Math.round(consumption.l1 ?? 0)}W</span>
</span>
<span>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 mr-0.5 align-middle" />
L2 <span className="text-content-primary font-medium">{Math.round(consumption.l2 ?? 0)}W</span>
</span>
<span>
Total:{" "}
<span className="text-content-primary font-medium">
{(consumption.total ?? 0) > 0 ? `${((consumption.total ?? 0) / 1000).toFixed(1)}kW` : "--"}
</span>
</span>
{batteryCharging > 0 && (
<span className="ml-auto">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-300 mr-0.5 align-middle" />
{"Battery: "}
<span className="text-content-primary font-medium">{(batteryCharging / 1000).toFixed(1)}kW</span>
</span>
)}
</div>
</div>
)
@@ -264,11 +286,6 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
{(genRamp.overloadCount ?? 0) > 0 && (
<span className="text-content-victronRed font-bold">OL:{genRamp.overloadCount}</span>
)}
{(consumption.inputTotal ?? 0) > 0 && (
<span className="text-content-primary font-medium tabular-nums">
{Math.round((consumption.inputTotal ?? 0) / 1000)}kW
</span>
)}
</div>
{(genRamp.state === 3 || genRamp.state === 4) && genRamp.rampTimeRemaining != null && (
<span className="text-content-victronYellow font-medium tabular-nums">

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useState, useEffect } from "react"
import { useTideService, useMeteoblueForecast, TidePoint } from "../../../utils/hooks/use-custom-mqtt"
import { parseSunTimeTs } from "../../../utils/sun-utils"
import { cardBase, cardTitle, cardStatusBadge } from "./cardStyles"
import {
M_TO_FT,
@@ -28,28 +29,6 @@ interface CelestialEvent {
type UnifiedEvent = { kind: "tide"; data: TideEvent } | { kind: "celestial"; data: CelestialEvent }
function parseSunMoonLocal(dateVal: unknown, timeStr: unknown): number | null {
if (typeof timeStr !== "string" || !timeStr || timeStr === "---") return null
let year: number, month: number, day: number
if (typeof dateVal === "string") {
const parts = dateVal.split("-")
if (parts.length < 3) return null
;[year, month, day] = parts.map(Number)
} else if (typeof dateVal === "number") {
const d = new Date(dateVal > 1e10 ? dateVal : dateVal * 1000)
year = d.getUTCFullYear()
month = d.getUTCMonth() + 1
day = d.getUTCDate()
} else {
return null
}
if (timeStr === "24:00") return Date.UTC(year, month - 1, day + 1, 0, 0) / 1000
const tp = timeStr.split(":")
if (tp.length < 2) return null
const [h, m] = tp.map(Number)
return Date.UTC(year, month - 1, day, h, m) / 1000
}
interface CompactTideCardProps {
onClick: () => void
}
@@ -209,15 +188,15 @@ const CompactTideCard: React.FC<CompactTideCardProps> = ({ onClick }) => {
const events: CelestialEvent[] = []
for (let i = 0; i < sunmoon.time.length; i++) {
const dateVal = sunmoon.time[i]
const sunrise = parseSunMoonLocal(dateVal, sunmoon.sunrise?.[i])
const sunrise = parseSunTimeTs(dateVal, sunmoon.sunrise?.[i])
if (sunrise != null) {
events.push({ type: "sunrise", time: sunrise, isPast: sunrise <= nowTs })
}
const sunset = parseSunMoonLocal(dateVal, sunmoon.sunset?.[i])
const sunset = parseSunTimeTs(dateVal, sunmoon.sunset?.[i])
if (sunset != null) {
events.push({ type: "sunset", time: sunset, isPast: sunset <= nowTs })
}
const moonrise = parseSunMoonLocal(dateVal, sunmoon.moonrise?.[i])
const moonrise = parseSunTimeTs(dateVal, sunmoon.moonrise?.[i])
if (moonrise != null) {
events.push({
type: "moonrise",
@@ -227,7 +206,7 @@ const CompactTideCard: React.FC<CompactTideCardProps> = ({ onClick }) => {
illumination: sunmoon.moonillumination?.[i],
})
}
const moonset = parseSunMoonLocal(dateVal, sunmoon.moonset?.[i])
const moonset = parseSunTimeTs(dateVal, sunmoon.moonset?.[i])
if (moonset != null) {
events.push({
type: "moonset",

View File

@@ -176,7 +176,7 @@ const CompactWeatherCard: React.FC<CompactWeatherCardProps> = ({ onClick }) => {
<span className={cardStatusBadge(meteo.isConnected)}>{meteo.isConnected ? "Online" : "Offline"}</span>
</div>
{meteo.isConnected ? (
<div className="flex-1 flex items-start justify-center relative">
<div className="flex-1 flex items-center justify-center relative">
<div className="absolute left-0 right-0 flex justify-between items-center z-10" style={{ top: 0 }}>
<div className="flex items-center gap-2 tabular-nums" style={{ width: 160 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2">

View File

@@ -2,6 +2,7 @@ import React, { useMemo } from "react"
import MainLayout from "../../ui/MainLayout"
import { observer } from "mobx-react"
import { useTideService, useMeteoblueForecast, TidePoint, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
import { parseSunTimeTs } from "../../../utils/sun-utils"
const M_TO_FT = 3.28084
@@ -78,32 +79,6 @@ interface NightInterval {
end: number
}
function parseSunTimeTs(dateVal: unknown, timeStr: unknown): number | null {
if (typeof timeStr !== "string" || !timeStr || timeStr === "---") return null
let year: number, month: number, day: number
if (typeof dateVal === "string") {
const parts = dateVal.split("-")
if (parts.length < 3) return null
;[year, month, day] = parts.map(Number)
} else if (typeof dateVal === "number") {
const d = new Date(dateVal > 1e10 ? dateVal : dateVal * 1000)
year = d.getUTCFullYear()
month = d.getUTCMonth() + 1
day = d.getUTCDate()
} else {
return null
}
if (timeStr === "24:00") {
return Date.UTC(year, month - 1, day + 1, 0, 0) / 1000
}
const tp = timeStr.split(":")
if (tp.length < 2) return null
const [h, m] = tp.map(Number)
return Date.UTC(year, month - 1, day, h, m) / 1000
}
function buildNightIntervals(sunmoon: SunMoonData | undefined): NightInterval[] {
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []

View File

@@ -2,6 +2,7 @@ import React, { useMemo } from "react"
import MainLayout from "../../ui/MainLayout"
import { observer } from "mobx-react"
import { useTideService, useMeteoblueForecast, TidePoint, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
import { parseSunTimeTs } from "../../../utils/sun-utils"
import { AppViews, useAppViewsStore } from "../../../modules/AppViews"
import {
M_TO_FT,
@@ -30,32 +31,6 @@ interface NightInterval {
end: number
}
function parseSunTimeTs(dateVal: unknown, timeStr: unknown): number | null {
if (typeof timeStr !== "string" || !timeStr || timeStr === "---") return null
let year: number, month: number, day: number
if (typeof dateVal === "string") {
const parts = dateVal.split("-")
if (parts.length < 3) return null
;[year, month, day] = parts.map(Number)
} else if (typeof dateVal === "number") {
const d = new Date(dateVal > 1e10 ? dateVal : dateVal * 1000)
year = d.getUTCFullYear()
month = d.getUTCMonth() + 1
day = d.getUTCDate()
} else {
return null
}
if (timeStr === "24:00") {
return Date.UTC(year, month - 1, day + 1, 0, 0) / 1000
}
const tp = timeStr.split(":")
if (tp.length < 2) return null
const [h, m] = tp.map(Number)
return Date.UTC(year, month - 1, day, h, m) / 1000
}
function buildNightIntervals(sunmoon: SunMoonData | undefined): NightInterval[] {
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []

View File

@@ -449,6 +449,78 @@ const WindCompass: React.FC<WindCompassProps> = ({
{/* Cardinal labels */}
{cardinalLabels}
{/* Forecast arc pill labels — above cardinals, below boat/wind arrow */}
{forecastArc12h &&
(() => {
let midDeg = (forecastArc12h.startDir + forecastArc12h.endDir) / 2
let sweep = forecastArc12h.endDir - forecastArc12h.startDir
if (sweep < 0) sweep += 360
if (sweep > 180) midDeg = (midDeg + 180) % 360
const mid = polarToXY(CX, CY, FORECAST_ARC_R, midDeg)
return (
<g>
<rect
x={mid.x - 15}
y={mid.y - 9}
width={30}
height={18}
rx={9}
fill="var(--c-surface-secondary, #F5F5F5)"
stroke="#aaa"
strokeWidth={0.8}
opacity={0.95}
/>
<text
x={mid.x}
y={mid.y}
textAnchor="middle"
dominantBaseline="central"
fill="#999"
fontSize={12}
fontWeight={700}
fontFamily="sans-serif"
>
12h
</text>
</g>
)
})()}
{forecastArc24h &&
(() => {
let midDeg = (forecastArc24h.startDir + forecastArc24h.endDir) / 2
let sweep = forecastArc24h.endDir - forecastArc24h.startDir
if (sweep < 0) sweep += 360
if (sweep > 180) midDeg = (midDeg + 180) % 360
const mid = polarToXY(CX, CY, ARC_24H_R, midDeg)
return (
<g>
<rect
x={mid.x - 15}
y={mid.y - 9}
width={30}
height={18}
rx={9}
fill="var(--c-surface-secondary, #F5F5F5)"
stroke="#aaa"
strokeWidth={0.8}
opacity={0.95}
/>
<text
x={mid.x}
y={mid.y}
textAnchor="middle"
dominantBaseline="central"
fill="#999"
fontSize={12}
fontWeight={700}
fontFamily="sans-serif"
>
24h
</text>
</g>
)
})()}
{/* Boat outline - rotates with heading */}
<g
transform={`rotate(${boatHeading}, ${CX}, ${CY}) translate(${CX}, ${CY}) scale(1.5) translate(${-CX}, ${-CY})`}
@@ -559,78 +631,6 @@ const WindCompass: React.FC<WindCompassProps> = ({
</g>
)}
{/* Forecast arc pill labels — rendered above compass/boat/wind layers */}
{forecastArc12h &&
(() => {
let midDeg = (forecastArc12h.startDir + forecastArc12h.endDir) / 2
let sweep = forecastArc12h.endDir - forecastArc12h.startDir
if (sweep < 0) sweep += 360
if (sweep > 180) midDeg = (midDeg + 180) % 360
const mid = polarToXY(CX, CY, FORECAST_ARC_R, midDeg)
return (
<g>
<rect
x={mid.x - 12}
y={mid.y - 9}
width={24}
height={18}
rx={9}
fill="var(--c-surface-secondary, #F5F5F5)"
stroke="#aaa"
strokeWidth={0.8}
opacity={0.95}
/>
<text
x={mid.x}
y={mid.y}
textAnchor="middle"
dominantBaseline="central"
fill="#999"
fontSize={12}
fontWeight={700}
fontFamily="sans-serif"
>
12
</text>
</g>
)
})()}
{forecastArc24h &&
(() => {
let midDeg = (forecastArc24h.startDir + forecastArc24h.endDir) / 2
let sweep = forecastArc24h.endDir - forecastArc24h.startDir
if (sweep < 0) sweep += 360
if (sweep > 180) midDeg = (midDeg + 180) % 360
const mid = polarToXY(CX, CY, ARC_24H_R, midDeg)
return (
<g>
<rect
x={mid.x - 12}
y={mid.y - 9}
width={24}
height={18}
rx={9}
fill="var(--c-surface-secondary, #F5F5F5)"
stroke="#aaa"
strokeWidth={0.8}
opacity={0.95}
/>
<text
x={mid.x}
y={mid.y}
textAnchor="middle"
dominantBaseline="central"
fill="#999"
fontSize={12}
fontWeight={700}
fontFamily="sans-serif"
>
24
</text>
</g>
)
})()}
{/* Center overlay to improve wind speed readability */}
<circle cx={CX} cy={CY} r={90} fill="url(#compassCenterFade)" />
<circle cx={CX} cy={CY} r={78} fill="none" stroke="#888" strokeWidth={0.5} opacity={0.25} />

View File

@@ -41,7 +41,7 @@ export const AppViewTitleKeys = new Map<AppViews, string>([
])
export class AppViewsStore {
currentView: AppViews = AppViews.ROOT
currentView: AppViews = AppViews.CUSTOM_MOORING_VIEW
constructor() {
makeAutoObservable(this)

View File

@@ -562,6 +562,8 @@ const GENERATOR_PATHS = [
"/Settings/RampDuration",
"/Settings/CooldownDuration",
"/Settings/Enabled",
"/Energy/CurrentRun",
"/Energy/LastRun",
]
export function useGeneratorRamp() {
@@ -589,6 +591,8 @@ export function useGeneratorRamp() {
cooldownDuration: values["/Settings/CooldownDuration"] as number | null,
enabled: values["/Settings/Enabled"] === 1,
setEnabled: (v: boolean) => setValue("/Settings/Enabled", v ? 1 : 0),
currentRunEnergy: values["/Energy/CurrentRun"] as number | null,
lastRunEnergy: values["/Energy/LastRun"] as number | null,
isConnected,
}
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useRef, useCallback } from "react"
import { useTheme } from "@victronenergy/mfd-modules"
import { useMeteoblueForecast, SunMoonData } from "./use-custom-mqtt"
import { parseSunTimeMs } from "../sun-utils"
interface SunEvent {
ts: number
type: "rise" | "set"
}
function getSunEvents(sunmoon: SunMoonData | undefined): SunEvent[] {
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []
const events: SunEvent[] = []
for (let i = 0; i < sunmoon.time.length; i++) {
const date = sunmoon.time[i]
const rise = parseSunTimeMs(date, sunmoon.sunrise[i])
if (rise != null) events.push({ ts: rise, type: "rise" })
const set = parseSunTimeMs(date, sunmoon.sunset[i])
if (set != null) events.push({ ts: set, type: "set" })
}
events.sort((a, b) => a.ts - b.ts)
return events
}
/**
* When enabled, drives themeStore.setDarkMode() based on Meteoblue sunrise/sunset data.
*
* On first enable, sets dark/light based on current sun position.
* Then schedules a timer for the next sun event only — manual Light/Dark taps
* between events are respected as temporary overrides.
*/
export function useSunriseSunsetMode(enabled: boolean) {
const { themeStore } = useTheme()
const { forecast } = useMeteoblueForecast()
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const initializedRef = useRef(false)
const clearTimer = useCallback(() => {
if (timerRef.current != null) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const scheduleNext = useCallback(
(events: SunEvent[]) => {
clearTimer()
const now = Date.now()
const nextEvent = events.find((e) => e.ts > now)
if (!nextEvent) return
const delay = Math.max(nextEvent.ts - now, 1000)
timerRef.current = setTimeout(() => {
themeStore.setDarkMode(nextEvent.type === "set")
scheduleNext(events)
}, delay)
},
[clearTimer, themeStore],
)
useEffect(() => {
if (!enabled) {
clearTimer()
initializedRef.current = false
return
}
const events = getSunEvents(forecast?.sunmoon)
if (events.length === 0) {
clearTimer()
return
}
if (!initializedRef.current) {
const now = Date.now()
const pastEvents = events.filter((e) => e.ts <= now)
const isDark = pastEvents.length === 0 || pastEvents[pastEvents.length - 1].type === "set"
themeStore.setDarkMode(isDark)
initializedRef.current = true
}
scheduleNext(events)
return clearTimer
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, forecast?.sunmoon, clearTimer, scheduleNext])
useEffect(() => {
if (enabled) {
themeStore.setAutoMode(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled])
}

View File

@@ -0,0 +1,39 @@
import { makeAutoObservable } from "mobx"
import { useMemo } from "react"
const STORAGE_KEY = "sunriseSunsetMode"
export class SunsetModeStore {
enabled: boolean = false
constructor() {
makeAutoObservable(this)
try {
this.enabled = localStorage.getItem(STORAGE_KEY) === "true"
} catch {
// localStorage unavailable
}
}
setEnabled(value: boolean) {
this.enabled = value
try {
localStorage.setItem(STORAGE_KEY, String(value))
} catch {
// localStorage unavailable
}
}
}
let store: SunsetModeStore
function initializeStore() {
const s = store ?? new SunsetModeStore()
if (typeof window === "undefined") return s
if (!store) store = s
return s
}
export function useSunsetModeStore() {
return useMemo(() => initializeStore(), [])
}

View File

@@ -0,0 +1,33 @@
/**
* Parse a Meteoblue sunmoon time string (e.g. "06:23") combined with a date
* value into a UTC timestamp in milliseconds.
*/
export function parseSunTimeMs(dateVal: unknown, timeStr: unknown): number | null {
if (typeof timeStr !== "string" || !timeStr || timeStr === "---") return null
let year: number, month: number, day: number
if (typeof dateVal === "string") {
const parts = dateVal.split("-")
if (parts.length < 3) return null
;[year, month, day] = parts.map(Number)
} else if (typeof dateVal === "number") {
const d = new Date(dateVal > 1e10 ? dateVal : dateVal * 1000)
year = d.getUTCFullYear()
month = d.getUTCMonth() + 1
day = d.getUTCDate()
} else {
return null
}
if (timeStr === "24:00") return Date.UTC(year, month - 1, day + 1, 0, 0)
const tp = timeStr.split(":")
if (tp.length < 2) return null
const [h, m] = tp.map(Number)
return Date.UTC(year, month - 1, day, h, m)
}
/** Same as parseSunTimeMs but returns seconds (for tide chart components). */
export function parseSunTimeTs(dateVal: unknown, timeStr: unknown): number | null {
const ms = parseSunTimeMs(dateVal, timeStr)
return ms != null ? ms / 1000 : null
}