New Features and fixes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,3 +52,4 @@ dbus-windy-station/station_config.json
|
||||
|
||||
# Design reference assets (kept locally, not in repo)
|
||||
inspiration assets/
|
||||
venus-data.zip
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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" }[] = []
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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(), [])
|
||||
}
|
||||
33
venus-html5-app/src/app/Marine2/utils/sun-utils.ts
Normal file
33
venus-html5-app/src/app/Marine2/utils/sun-utils.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user