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)
|
# Design reference assets (kept locally, not in repo)
|
||||||
inspiration assets/
|
inspiration assets/
|
||||||
|
venus-data.zip
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ from ramp_controller import RampController
|
|||||||
|
|
||||||
|
|
||||||
# Version
|
# Version
|
||||||
VERSION = '1.1.1'
|
VERSION = '1.2.0'
|
||||||
|
|
||||||
# D-Bus service name for our addon
|
# D-Bus service name for our addon
|
||||||
SERVICE_NAME = 'com.victronenergy.generatorramp'
|
SERVICE_NAME = 'com.victronenergy.generatorramp'
|
||||||
@@ -113,6 +113,11 @@ class GeneratorRampController:
|
|||||||
|
|
||||||
# Enabled flag
|
# Enabled flag
|
||||||
self.enabled = True
|
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
|
# D-Bus connection
|
||||||
self.bus = dbus.SystemBus()
|
self.bus = dbus.SystemBus()
|
||||||
@@ -236,6 +241,12 @@ class GeneratorRampController:
|
|||||||
self.dbus_service.add_path('/Ramp/TimeRemaining', 0,
|
self.dbus_service.add_path('/Ramp/TimeRemaining', 0,
|
||||||
gettextcallback=lambda p, v: f"{v//60}m {v%60}s" if v > 0 else "0s")
|
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
|
# Fast recovery status
|
||||||
self.dbus_service.add_path('/Recovery/InFastRamp', 0)
|
self.dbus_service.add_path('/Recovery/InFastRamp', 0)
|
||||||
self.dbus_service.add_path('/Recovery/FastRampTarget', 0.0,
|
self.dbus_service.add_path('/Recovery/FastRampTarget', 0.0,
|
||||||
@@ -735,9 +746,28 @@ class GeneratorRampController:
|
|||||||
self._read_output_power()
|
self._read_output_power()
|
||||||
self._read_current_limit()
|
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
|
# Check for generator stop
|
||||||
if self.generator_state == GENERATOR_STATE['STOPPED']:
|
if self.generator_state == GENERATOR_STATE['STOPPED']:
|
||||||
if self.state != self.STATE_IDLE:
|
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.logger.info("Generator stopped")
|
||||||
self._transition_to(self.STATE_IDLE)
|
self._transition_to(self.STATE_IDLE)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -82,3 +82,12 @@ echo ""
|
|||||||
echo "Deploy via USB:"
|
echo "Deploy via USB:"
|
||||||
echo " Copy venus-data.zip to a USB stick, insert into Cerbo, reboot."
|
echo " Copy venus-data.zip to a USB stick, insert into Cerbo, reboot."
|
||||||
echo ""
|
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 { Marine2 } from "./Marine2"
|
||||||
import Connecting from "./components/ui/Connecting"
|
import Connecting from "./components/ui/Connecting"
|
||||||
import { appErrorBoundaryProps } from "./components/ui/Error/appErrorBoundary"
|
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"
|
import "./css/global.css"
|
||||||
|
|
||||||
export type AppProps = {
|
export type AppProps = {
|
||||||
@@ -23,8 +25,10 @@ const App = (props: AppProps) => {
|
|||||||
const locale = getLocale()
|
const locale = getLocale()
|
||||||
const visibleWidgetsStore = useVisibleWidgetsStore()
|
const visibleWidgetsStore = useVisibleWidgetsStore()
|
||||||
const { themeStore } = useTheme()
|
const { themeStore } = useTheme()
|
||||||
|
const sunsetStore = useSunsetModeStore()
|
||||||
|
|
||||||
useVebus()
|
useVebus()
|
||||||
|
useSunriseSunsetMode(sunsetStore.enabled)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appStore.remote) {
|
if (!appStore.remote) {
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ import BackIcon from "../../../images/icons/back.svg"
|
|||||||
import DockViewIcon from "../../../images/icons/dock-view.svg"
|
import DockViewIcon from "../../../images/icons/dock-view.svg"
|
||||||
import WavesIcon from "../../../images/icons/waves.svg"
|
import WavesIcon from "../../../images/icons/waves.svg"
|
||||||
import { AppViews, useAppViewsStore } from "../../../modules/AppViews"
|
import { AppViews, useAppViewsStore } from "../../../modules/AppViews"
|
||||||
import SwitchingPane from "../../views/SwitchingPane"
|
|
||||||
|
|
||||||
const Footer = ({ pageSelectorProps }: Props) => {
|
const Footer = ({ pageSelectorProps }: Props) => {
|
||||||
const appViewsStore = useAppViewsStore()
|
const appViewsStore = useAppViewsStore()
|
||||||
const [isShowingBackButton, setIsShowingBackButton] = useState(appViewsStore.currentView !== AppViews.ROOT)
|
const [isShowingBackButton, setIsShowingBackButton] = useState(
|
||||||
|
appViewsStore.currentView !== AppViews.CUSTOM_MOORING_VIEW,
|
||||||
|
)
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
appViewsStore.setView(AppViews.ROOT)
|
appViewsStore.setView(AppViews.CUSTOM_MOORING_VIEW)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsShowingBackButton(appViewsStore.currentView !== AppViews.ROOT)
|
setIsShowingBackButton(appViewsStore.currentView !== AppViews.CUSTOM_MOORING_VIEW)
|
||||||
}, [appViewsStore.currentView])
|
}, [appViewsStore.currentView])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +49,6 @@ const Footer = ({ pageSelectorProps }: Props) => {
|
|||||||
>
|
>
|
||||||
<DockViewIcon className="text-content-victronBlue" alt="Mooring View" />
|
<DockViewIcon className="text-content-victronBlue" alt="Mooring View" />
|
||||||
</div>
|
</div>
|
||||||
<SwitchingPane />
|
|
||||||
<SettingsMenu />
|
<SettingsMenu />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import CloseIcon from "../../../images/icons/close.svg"
|
|||||||
import ToggleSwitch from "../ToggleSwitch"
|
import ToggleSwitch from "../ToggleSwitch"
|
||||||
import RadioButton from "../RadioButton"
|
import RadioButton from "../RadioButton"
|
||||||
import { AppViews, useAppViewsStore } from "../../../modules/AppViews"
|
import { AppViews, useAppViewsStore } from "../../../modules/AppViews"
|
||||||
|
import { useSunsetModeStore } from "../../../utils/hooks/use-sunset-mode-store"
|
||||||
import Button from "../Button"
|
import Button from "../Button"
|
||||||
import classNames from "classnames"
|
import classNames from "classnames"
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const SettingsMenu = () => {
|
|||||||
const { locked, toggleLocked } = useAppStore()
|
const { locked, toggleLocked } = useAppStore()
|
||||||
const { themeStore } = useTheme()
|
const { themeStore } = useTheme()
|
||||||
const appViewsStore = useAppViewsStore()
|
const appViewsStore = useAppViewsStore()
|
||||||
|
const sunsetStore = useSunsetModeStore()
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [isHorizontal, setIsHorizontal] = useState(false)
|
const [isHorizontal, setIsHorizontal] = useState(false)
|
||||||
|
|
||||||
@@ -119,10 +121,24 @@ const SettingsMenu = () => {
|
|||||||
</span>
|
</span>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
id="ToggleAutoMode"
|
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}
|
selected={themeStore.autoMode}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<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">
|
<span className="mr-1 text-sm sm-m:mr-2 sm-l:text-base text-content-primary">
|
||||||
{translate("common.night")}
|
{translate("common.night")}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { observer } from "mobx-react"
|
import { observer } from "mobx-react"
|
||||||
import { useMeteoblueForecast, useWindyStation, ForecastData, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
|
import { useMeteoblueForecast, useWindyStation, ForecastData, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
|
||||||
|
import { parseSunTimeMs } from "../../../utils/sun-utils"
|
||||||
import { cardTitle } from "./cardStyles"
|
import { cardTitle } from "./cardStyles"
|
||||||
|
|
||||||
const WIND_COLORS = [
|
const WIND_COLORS = [
|
||||||
@@ -62,30 +63,6 @@ interface TimeInterval {
|
|||||||
end: number
|
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[] {
|
function buildNightIntervals(sunmoon: SunMoonData | undefined): TimeInterval[] {
|
||||||
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []
|
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []
|
||||||
const events: { ts: number; type: "rise" | "set" }[] = []
|
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 genRunning = genStatus.state === 1 || genStatus.state === 2 || genStatus.state === 3
|
||||||
const acInputActive = (consumption.inputL1 ?? 0) > 0 && (consumption.inputL2 ?? 0) > 0
|
const acInputActive = (consumption.inputL1 ?? 0) > 0 && (consumption.inputL2 ?? 0) > 0
|
||||||
const onBattery = !acInputActive
|
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)
|
const sourcePower = onBattery ? Math.abs(bat.power ?? 0) : (consumption.inputTotal ?? 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,7 +110,7 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
|
|||||||
className={`font-bold ${(bat.power ?? 0) > 0 ? "text-content-victronGreen" : "text-content-primary"}`}
|
className={`font-bold ${(bat.power ?? 0) > 0 ? "text-content-victronGreen" : "text-content-primary"}`}
|
||||||
style={{ fontSize: "0.875rem" }}
|
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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -149,7 +149,7 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
|
|||||||
{/* Source power bar: L1 + L2 consumption + battery charging */}
|
{/* Source power bar: L1 + L2 consumption + battery charging */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const batteryCharging = Math.max(0, bat.power ?? 0)
|
const batteryCharging = Math.max(0, bat.power ?? 0)
|
||||||
const barMax = 15000
|
const barMax = 12000
|
||||||
const l1Pct = ((consumption.l1 ?? 0) / barMax) * 100
|
const l1Pct = ((consumption.l1 ?? 0) / barMax) * 100
|
||||||
const l2Pct = ((consumption.l2 ?? 0) / barMax) * 100
|
const l2Pct = ((consumption.l2 ?? 0) / barMax) * 100
|
||||||
const batPct = (batteryCharging / 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="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">
|
<div className="flex items-center gap-1.5 flex-nowrap whitespace-nowrap overflow-hidden mb-1">
|
||||||
<span className="text-content-tertiary text-2xs">
|
<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 && (
|
{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>
|
</span>
|
||||||
{isGenerator && genStatus.conditionCode != null && genStatus.conditionCode > 0 && (
|
{isGenerator && genStatus.conditionCode != null && genStatus.conditionCode > 0 && (
|
||||||
@@ -167,12 +169,47 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
|
|||||||
{genStatus.conditionName}
|
{genStatus.conditionName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isGenerator && genRunning && genStatus.runtimeFormatted && (
|
<span className="ml-auto flex items-center gap-1.5">
|
||||||
<span className="text-2xs text-content-secondary">Run: {genStatus.runtimeFormatted}</span>
|
{isGenerator && genRunning && genStatus.runtimeFormatted && (
|
||||||
)}
|
<span className="text-2xs">
|
||||||
{isGenerator && !genRunning && genDaily.todayFormatted && (
|
<span className="text-content-tertiary">Run: </span>
|
||||||
<span className="text-2xs text-content-tertiary">Today: {genDaily.todayFormatted}</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 && (
|
{isGenerator && genStatus.timerFormatted && (
|
||||||
<span className="text-2xs text-content-victronYellow">Timer: {genStatus.timerFormatted}</span>
|
<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`}
|
title={`Battery: ${Math.round(batteryCharging)}W`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center mt-0.5 text-2xs text-content-tertiary tabular-nums">
|
<div className="flex items-center mt-0.5 text-2xs text-content-tertiary tabular-nums gap-3">
|
||||||
<div className="flex gap-3">
|
<span>
|
||||||
<span>
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 mr-0.5 align-middle" />
|
||||||
<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>
|
||||||
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",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -264,11 +286,6 @@ const CompactPowerEnergyCard = ({ onClick }: Props) => {
|
|||||||
{(genRamp.overloadCount ?? 0) > 0 && (
|
{(genRamp.overloadCount ?? 0) > 0 && (
|
||||||
<span className="text-content-victronRed font-bold">OL:{genRamp.overloadCount}</span>
|
<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>
|
</div>
|
||||||
{(genRamp.state === 3 || genRamp.state === 4) && genRamp.rampTimeRemaining != null && (
|
{(genRamp.state === 3 || genRamp.state === 4) && genRamp.rampTimeRemaining != null && (
|
||||||
<span className="text-content-victronYellow font-medium tabular-nums">
|
<span className="text-content-victronYellow font-medium tabular-nums">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, useState, useEffect } from "react"
|
import React, { useMemo, useState, useEffect } from "react"
|
||||||
import { useTideService, useMeteoblueForecast, TidePoint } from "../../../utils/hooks/use-custom-mqtt"
|
import { useTideService, useMeteoblueForecast, TidePoint } from "../../../utils/hooks/use-custom-mqtt"
|
||||||
|
import { parseSunTimeTs } from "../../../utils/sun-utils"
|
||||||
import { cardBase, cardTitle, cardStatusBadge } from "./cardStyles"
|
import { cardBase, cardTitle, cardStatusBadge } from "./cardStyles"
|
||||||
import {
|
import {
|
||||||
M_TO_FT,
|
M_TO_FT,
|
||||||
@@ -28,28 +29,6 @@ interface CelestialEvent {
|
|||||||
|
|
||||||
type UnifiedEvent = { kind: "tide"; data: TideEvent } | { kind: "celestial"; data: 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 {
|
interface CompactTideCardProps {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}
|
}
|
||||||
@@ -209,15 +188,15 @@ const CompactTideCard: React.FC<CompactTideCardProps> = ({ onClick }) => {
|
|||||||
const events: CelestialEvent[] = []
|
const events: CelestialEvent[] = []
|
||||||
for (let i = 0; i < sunmoon.time.length; i++) {
|
for (let i = 0; i < sunmoon.time.length; i++) {
|
||||||
const dateVal = sunmoon.time[i]
|
const dateVal = sunmoon.time[i]
|
||||||
const sunrise = parseSunMoonLocal(dateVal, sunmoon.sunrise?.[i])
|
const sunrise = parseSunTimeTs(dateVal, sunmoon.sunrise?.[i])
|
||||||
if (sunrise != null) {
|
if (sunrise != null) {
|
||||||
events.push({ type: "sunrise", time: sunrise, isPast: sunrise <= nowTs })
|
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) {
|
if (sunset != null) {
|
||||||
events.push({ type: "sunset", time: sunset, isPast: sunset <= nowTs })
|
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) {
|
if (moonrise != null) {
|
||||||
events.push({
|
events.push({
|
||||||
type: "moonrise",
|
type: "moonrise",
|
||||||
@@ -227,7 +206,7 @@ const CompactTideCard: React.FC<CompactTideCardProps> = ({ onClick }) => {
|
|||||||
illumination: sunmoon.moonillumination?.[i],
|
illumination: sunmoon.moonillumination?.[i],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const moonset = parseSunMoonLocal(dateVal, sunmoon.moonset?.[i])
|
const moonset = parseSunTimeTs(dateVal, sunmoon.moonset?.[i])
|
||||||
if (moonset != null) {
|
if (moonset != null) {
|
||||||
events.push({
|
events.push({
|
||||||
type: "moonset",
|
type: "moonset",
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const CompactWeatherCard: React.FC<CompactWeatherCardProps> = ({ onClick }) => {
|
|||||||
<span className={cardStatusBadge(meteo.isConnected)}>{meteo.isConnected ? "Online" : "Offline"}</span>
|
<span className={cardStatusBadge(meteo.isConnected)}>{meteo.isConnected ? "Online" : "Offline"}</span>
|
||||||
</div>
|
</div>
|
||||||
{meteo.isConnected ? (
|
{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="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 }}>
|
<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">
|
<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 MainLayout from "../../ui/MainLayout"
|
||||||
import { observer } from "mobx-react"
|
import { observer } from "mobx-react"
|
||||||
import { useTideService, useMeteoblueForecast, TidePoint, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
|
import { useTideService, useMeteoblueForecast, TidePoint, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
|
||||||
|
import { parseSunTimeTs } from "../../../utils/sun-utils"
|
||||||
|
|
||||||
const M_TO_FT = 3.28084
|
const M_TO_FT = 3.28084
|
||||||
|
|
||||||
@@ -78,32 +79,6 @@ interface NightInterval {
|
|||||||
end: number
|
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[] {
|
function buildNightIntervals(sunmoon: SunMoonData | undefined): NightInterval[] {
|
||||||
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []
|
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useMemo } from "react"
|
|||||||
import MainLayout from "../../ui/MainLayout"
|
import MainLayout from "../../ui/MainLayout"
|
||||||
import { observer } from "mobx-react"
|
import { observer } from "mobx-react"
|
||||||
import { useTideService, useMeteoblueForecast, TidePoint, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
|
import { useTideService, useMeteoblueForecast, TidePoint, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
|
||||||
|
import { parseSunTimeTs } from "../../../utils/sun-utils"
|
||||||
import { AppViews, useAppViewsStore } from "../../../modules/AppViews"
|
import { AppViews, useAppViewsStore } from "../../../modules/AppViews"
|
||||||
import {
|
import {
|
||||||
M_TO_FT,
|
M_TO_FT,
|
||||||
@@ -30,32 +31,6 @@ interface NightInterval {
|
|||||||
end: number
|
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[] {
|
function buildNightIntervals(sunmoon: SunMoonData | undefined): NightInterval[] {
|
||||||
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []
|
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []
|
||||||
|
|
||||||
|
|||||||
@@ -449,6 +449,78 @@ const WindCompass: React.FC<WindCompassProps> = ({
|
|||||||
{/* Cardinal labels */}
|
{/* Cardinal labels */}
|
||||||
{cardinalLabels}
|
{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 */}
|
{/* Boat outline - rotates with heading */}
|
||||||
<g
|
<g
|
||||||
transform={`rotate(${boatHeading}, ${CX}, ${CY}) translate(${CX}, ${CY}) scale(1.5) translate(${-CX}, ${-CY})`}
|
transform={`rotate(${boatHeading}, ${CX}, ${CY}) translate(${CX}, ${CY}) scale(1.5) translate(${-CX}, ${-CY})`}
|
||||||
@@ -559,78 +631,6 @@ const WindCompass: React.FC<WindCompassProps> = ({
|
|||||||
</g>
|
</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 */}
|
{/* Center overlay to improve wind speed readability */}
|
||||||
<circle cx={CX} cy={CY} r={90} fill="url(#compassCenterFade)" />
|
<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} />
|
<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 {
|
export class AppViewsStore {
|
||||||
currentView: AppViews = AppViews.ROOT
|
currentView: AppViews = AppViews.CUSTOM_MOORING_VIEW
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this)
|
makeAutoObservable(this)
|
||||||
|
|||||||
@@ -562,6 +562,8 @@ const GENERATOR_PATHS = [
|
|||||||
"/Settings/RampDuration",
|
"/Settings/RampDuration",
|
||||||
"/Settings/CooldownDuration",
|
"/Settings/CooldownDuration",
|
||||||
"/Settings/Enabled",
|
"/Settings/Enabled",
|
||||||
|
"/Energy/CurrentRun",
|
||||||
|
"/Energy/LastRun",
|
||||||
]
|
]
|
||||||
|
|
||||||
export function useGeneratorRamp() {
|
export function useGeneratorRamp() {
|
||||||
@@ -589,6 +591,8 @@ export function useGeneratorRamp() {
|
|||||||
cooldownDuration: values["/Settings/CooldownDuration"] as number | null,
|
cooldownDuration: values["/Settings/CooldownDuration"] as number | null,
|
||||||
enabled: values["/Settings/Enabled"] === 1,
|
enabled: values["/Settings/Enabled"] === 1,
|
||||||
setEnabled: (v: boolean) => setValue("/Settings/Enabled", v ? 1 : 0),
|
setEnabled: (v: boolean) => setValue("/Settings/Enabled", v ? 1 : 0),
|
||||||
|
currentRunEnergy: values["/Energy/CurrentRun"] as number | null,
|
||||||
|
lastRunEnergy: values["/Energy/LastRun"] as number | null,
|
||||||
isConnected,
|
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