diff --git a/scripts/auto_firmware_version.py b/scripts/auto_firmware_version.py index b617df9aa..907b592f0 100644 --- a/scripts/auto_firmware_version.py +++ b/scripts/auto_firmware_version.py @@ -11,7 +11,7 @@ def get_firmware_specifier_build_flag(): return build_flag def get_time_specifier_build_flag(): - build_timestamp = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + build_timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") build_flag = "#define BUILD_TIMESTAMP \"" + build_timestamp + "\"" print ("Build date: " + build_timestamp) return build_flag diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index ba0f7a591..445fbafd8 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -3,10 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faHome } from '@fortawesome/free-solid-svg-icons/faHome'; import { faList } from '@fortawesome/free-solid-svg-icons/faList'; import { faTimeline } from '@fortawesome/free-solid-svg-icons/faTimeline'; -import { faTemperatureHalf } from '@fortawesome/free-solid-svg-icons/faTemperatureHalf'; -import { faBluetoothB } from '@fortawesome/free-brands-svg-icons/faBluetoothB'; import { faCog } from '@fortawesome/free-solid-svg-icons/faCog'; -import { faRotate } from '@fortawesome/free-solid-svg-icons/faRotate'; import { faMagnifyingGlassChart } from '@fortawesome/free-solid-svg-icons/faMagnifyingGlassChart'; import { faChartSimple } from '@fortawesome/free-solid-svg-icons/faChartSimple'; import { faCircleChevronLeft } from '@fortawesome/free-solid-svg-icons/faCircleChevronLeft'; @@ -60,16 +57,9 @@ const NAVIGATION_SECTIONS = [ id: 'devices', showDivider: true, items: [ - { label: 'PID Autotune', link: '/pidtune', icon: faTemperatureHalf }, - { label: 'Bluetooth Devices', link: '/scales', icon: faBluetoothB }, { label: 'Settings', link: '/settings', icon: faCog }, ], }, - { - id: 'updates', - showDivider: true, - items: [{ label: 'System & Updates', link: '/ota', icon: faRotate }], - }, ]; function MenuItem({ collapsed = false, icon, isNew = false, label, link }) { diff --git a/web/src/index.jsx b/web/src/index.jsx index caaf883e9..da67a323b 100644 --- a/web/src/index.jsx +++ b/web/src/index.jsx @@ -23,11 +23,8 @@ import { faBars } from '@fortawesome/free-solid-svg-icons/faBars'; const Home = lazy(() => import('./pages/Home/index.jsx').then(m => m.Home)); const NotFound = lazy(() => import('./pages/_404.jsx').then(m => m.NotFound)); const Settings = lazy(() => import('./pages/Settings/index.jsx').then(m => m.Settings)); -const OTA = lazy(() => import('./pages/OTA/index.jsx').then(m => m.OTA)); -const Scales = lazy(() => import('./pages/Scales/index.jsx').then(m => m.Scales)); const ProfileList = lazy(() => import('./pages/ProfileList/index.jsx').then(m => m.ProfileList)); const ProfileEdit = lazy(() => import('./pages/ProfileEdit/index.jsx').then(m => m.ProfileEdit)); -const Autotune = lazy(() => import('./pages/Autotune/index.jsx').then(m => m.Autotune)); const ShotHistory = lazy(() => import('./pages/ShotHistory/index.jsx').then(m => m.ShotHistory)); const ShotAnalyzer = lazy(() => import('./pages/ShotAnalyzer/index.jsx').then(m => m.ShotAnalyzer)); const StatisticsPage = lazy(() => @@ -88,9 +85,6 @@ export function App() { - - - diff --git a/web/src/pages/Autotune/index.jsx b/web/src/pages/Autotune/index.jsx deleted file mode 100644 index ac8ff43b4..000000000 --- a/web/src/pages/Autotune/index.jsx +++ /dev/null @@ -1,218 +0,0 @@ -import { useState, useEffect, useCallback, useContext } from 'preact/hooks'; -import { ApiServiceContext } from '../../services/ApiService.js'; -import { OverviewChart } from '../../components/OverviewChart.jsx'; -import { Spinner } from '../../components/Spinner.jsx'; -import Card from '../../components/Card.jsx'; - -export function Autotune() { - const apiService = useContext(ApiServiceContext); - const [active, setActive] = useState(false); - const [result, setResult] = useState(null); - const [failed, setFailed] = useState(false); - const [time, setTime] = useState(120); - const [samples, setSamples] = useState(6); - // Default 680 W matches Gaggia Classic Pro 2019 / E24 boiler element on - // 230 V. (120 V variant is 570 W; cafeparts.com EF0030-A datasheet.) Used - // controller-side as combinedKff = TUNER_OUTPUT_SPAN / wattage so the - // Thermal Feedforward Gain is auto-populated on completion. - const [wattage, setWattage] = useState(680); - - const onStart = useCallback(() => { - apiService.send({ - tp: 'req:autotune-start', - time, - samples, - wattage, - }); - setFailed(false); - setResult(null); - setActive(true); - }, [time, samples, wattage, apiService]); - - useEffect(() => { - const resultListener = apiService.on('evt:autotune-result', msg => { - setActive(false); - setFailed(false); - setResult(msg.pid); - }); - const failedListener = apiService.on('evt:autotune-failed', () => { - setActive(false); - setResult(null); - setFailed(true); - }); - return () => { - apiService.off('evt:autotune-result', resultListener); - apiService.off('evt:autotune-failed', failedListener); - }; - }, [apiService]); - - return ( - <> -
-

PID Autotune

-
- -
- - {active && ( -
-
- -
-
-
- - Autotune in Progress -
-
- - The boiler will heat at full power until its temperature inflection is detected, - then the SIMC tuning rule derives PID gains. Typically 1–3 minutes depending on - machine. - -
-
-
- )} - - {result && ( -
-
-
-

Autotune Complete!

-
Your new PID values have been saved successfully.
-
-
-
-
-                  {result}
-                
-
-
- )} - - {failed && ( -
-
-
-

Autotune Failed

-
- No valid gains were produced. Your existing PID settings have been preserved. - Try increasing Test Duration or confirm the boiler was cold at start. -
-
-
-
- )} - - {!active && !result && !failed && ( -
-
- - Please ensure the boiler temperature is below 50°C before starting the autotune - process. - -
- -
-
- - setTime(Number.parseInt(e.target.value, 10) || 0)} - placeholder='120' - /> -
- Upper bound on the identification test. Most espresso boilers resolve within - 60–120 s. Extend if Autotune fails before peak slope is detected. -
-
- -
- - setSamples(Number.parseInt(e.target.value, 10) || 4)} - placeholder='6' - /> -
- Moving-window length (samples) used for slope estimation. Larger values smooth - MAX31855 quantisation but lag the inflection. 6 is the sweet spot. -
-
- -
- - setWattage(Number.parseInt(e.target.value, 10) || 0)} - placeholder='680' - /> -
- Boiler heating element wattage. Defaults: Gaggia Classic Pro 2019 / E24 = 1370 - W; Rancilio Silvia ≈ 1100 W. Used to derive Thermal Feedforward Gain after - autotune completes. -
-
-
-
- )} -
-
- -
-
- {!active && !result && !failed && ( - - )} - - {result && ( - - )} - - {failed && ( - - )} -
-
- - ); -} diff --git a/web/src/pages/OTA/index.jsx b/web/src/pages/OTA/index.jsx deleted file mode 100644 index 924c9245c..000000000 --- a/web/src/pages/OTA/index.jsx +++ /dev/null @@ -1,370 +0,0 @@ -import { useCallback, useContext, useEffect, useRef, useState } from 'preact/hooks'; -import Card from '../../components/Card.jsx'; -import { Spinner } from '../../components/Spinner.jsx'; -import { ApiServiceContext } from '../../services/ApiService.js'; -import { downloadJson } from '../../utils/download.js'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCheck } from '@fortawesome/free-solid-svg-icons/faCheck'; -import { machine } from '../../services/ApiService.js'; -import { computed } from '@preact/signals'; - -const imageUrlToBase64 = async blob => { - return new Promise((onSuccess, onError) => { - try { - const reader = new FileReader(); - reader.onload = function () { - onSuccess(this.result); - }; - reader.readAsDataURL(blob); - } catch (e) { - onError(e); - } - }); -}; - -const connected = computed(() => machine.value.connected); - -export function OTA() { - const apiService = useContext(ApiServiceContext); - const [isLoading, setIsLoading] = useState(true); - const [submitting, setSubmitting] = useState(false); - const [formData, setFormData] = useState({}); - const [phase, setPhase] = useState(0); - const [progress, setProgress] = useState(0); - const rssi = machine.value.status.rssi; - const lat = machine.value.status.lat; - - const downloadSupportData = useCallback(async () => { - const settingsResponse = await fetch(`/api/settings`); - const data = await settingsResponse.json(); - delete data.wifiPassword; - delete data.haPassword; - const coredumpBlob = await fetch(`/api/core-dump`).then(r => r.blob()); - let coredump = await imageUrlToBase64(coredumpBlob); - coredump = coredump.substring(coredump.indexOf('base64,') + 7); - const supportFile = { - settings: data, - versions: formData, - coredump, - }; - const ts = Date.now(); - downloadJson(supportFile, `support-${ts}.dat`); - }, [formData]); - useEffect(() => { - const listenerId = apiService.on('res:ota-settings', msg => { - setFormData(msg); - setIsLoading(false); - setSubmitting(false); - }); - return () => { - apiService.off('res:ota-settings', listenerId); - }; - }, [apiService]); - useEffect(() => { - const listenerId = apiService.on('evt:ota-progress', msg => { - setProgress(msg.progress); - setPhase(msg.phase); - }); - return () => { - apiService.off('evt:ota-progress', listenerId); - }; - }, [apiService]); - - useEffect(() => { - const listenerId = apiService.on('evt:history-rebuild-progress', msg => { - setRebuildProgress({ - total: msg.total || 0, - current: msg.current || 0, - status: msg.status || '', - }); - - if (msg.status === 'completed' || msg.status === 'error') { - setRebuilding(false); - setRebuilt(msg.status === 'completed'); - } - }); - return () => { - apiService.off('evt:history-rebuild-progress', listenerId); - }; - }, [apiService]); - useEffect(() => { - if (connected.value) { - apiService.send({ tp: 'req:ota-settings' }); - } - }, [apiService, connected.value]); - - const formRef = useRef(); - - const onSubmit = useCallback( - async e => { - e.preventDefault(); - setSubmitting(true); - const form = formRef.current; - const formData = new FormData(form); - apiService.send({ tp: 'req:ota-settings', update: true, channel: formData.get('channel') }); - setSubmitting(true); - }, - [setFormData, formRef], - ); - - const onUpdate = useCallback( - component => { - apiService.send({ tp: 'req:ota-start', cp: component }); - }, - [apiService], - ); - - const [rebuilding, setRebuilding] = useState(false); - const [rebuilt, setRebuilt] = useState(false); - const [rebuildProgress, setRebuildProgress] = useState({ total: 0, current: 0, status: '' }); - const onHistoryRebuild = useCallback(async () => { - setRebuilt(false); - setRebuilding(true); - setRebuildProgress({ total: 0, current: 0, status: 'starting' }); - apiService.send({ tp: 'req:history:rebuild' }); - }, [apiService]); - - if (isLoading) { - return ( -
- -
- ); - } - - if (phase > 0) { - return ( -
- - - {phase === 1 - ? 'Updating Display firmware' - : phase === 2 - ? 'Updating Display filesystem' - : phase === 3 - ? 'Updating controller firmware' - : 'Finished'} - - {phase === 4 ? 100 : progress}% - {phase === 4 && ( - - Back - - )} -
- ); - } - - return ( - <> -
-

System & Updates

-
- -
-
- -
- - -
- -
- - {formData.hardware} -
- -
- -
- {formData.controllerVersion} - {formData.controllerUpdateAvailable && ( - - (Update available: {formData.latestVersion}) - - )} -
-
- -
- -
- {formData.displayVersion} - {formData.displayUpdateAvailable && ( - - (Update available: {formData.latestVersion}) - - )} -
-
- -
- - - {rssi}dB (Roundtrip: {lat} ms) - - -
- - {formData.spiffsTotal !== undefined && ( -
- -
-
-
-
-
- {((formData.spiffsUsed || 0) / 1024).toFixed(1)} KB /{' '} - {(formData.spiffsTotal / 1024).toFixed(1)} KB ({formData.spiffsUsedPct}%) -
-
-
- )} - - {formData.sdTotal !== undefined && ( -
- -
-
-
-
-
- {((formData.sdUsed || 0) / 1024 / 1024).toFixed(1)} MB /{' '} - {(formData.sdTotal / 1024 / 1024).toFixed(1)} MB ({formData.sdUsedPct}%) -
-
-
- )} - - {formData.heapTotal !== undefined && ( -
- -
-
-
-
-
- {((formData.heapTotal - formData.heapFree || 0) / 1024).toFixed(1)} kB /{' '} - {(formData.heapTotal / 1024).toFixed(1)} kB ( - {( - ((formData.heapTotal - formData.heapFree) / formData.heapTotal) * - 100 - ).toFixed(2)} - %) (Fragmentation:{' '} - {(100 - (formData.heapLargest * 100) / formData.heapFree).toFixed(2)}%) -
-
-
- )} - -
- - Make sure to backup your profiles from the profile screen before updating the - display. - -
- -
- -
-
- - - - - -
- - {rebuilding && ( -
-
- {rebuildProgress.status === 'starting' || - rebuildProgress.status === 'scanning' || - rebuildProgress.total === 0 - ? 'Scanning shot history files...' - : `Processing shot history files (${rebuildProgress.current}/${rebuildProgress.total})`} -
-
-
0 - ? `${(rebuildProgress.current / rebuildProgress.total) * 100}%` - : '30%', - }} - /> -
-
- )} -
- - - ); -} diff --git a/web/src/pages/Scales/index.jsx b/web/src/pages/Scales/index.jsx deleted file mode 100644 index 0790d4bdb..000000000 --- a/web/src/pages/Scales/index.jsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useState, useEffect, useCallback } from 'preact/hooks'; -import { useQuery } from 'preact-fetching'; -import Card from '../../components/Card.jsx'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faScaleBalanced } from '@fortawesome/free-solid-svg-icons/faScaleBalanced'; -import { machine } from '../../services/ApiService.js'; -import { Spinner } from '../../components/Spinner.jsx'; -import { faSignal } from '@fortawesome/free-solid-svg-icons/faSignal'; -import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; -import { faBatteryFull } from '@fortawesome/free-solid-svg-icons/faBatteryFull'; -import { faBatteryThreeQuarters } from '@fortawesome/free-solid-svg-icons/faBatteryThreeQuarters'; -import { faBatteryHalf } from '@fortawesome/free-solid-svg-icons/faBatteryHalf'; -import { faBatteryQuarter } from '@fortawesome/free-solid-svg-icons/faBatteryQuarter'; -import { faBatteryEmpty } from '@fortawesome/free-solid-svg-icons/faBatteryEmpty'; - -// Pick an icon that roughly matches the fill level for a richer at-a-glance -// read on the Scales page. The thresholds here are visual-only; the color -// below is what actually conveys "pay attention" (≤9% red, ≤29% yellow). -function batteryIcon(pct) { - if (pct >= 87) return faBatteryFull; - if (pct >= 62) return faBatteryThreeQuarters; - if (pct >= 37) return faBatteryHalf; - if (pct >= 12) return faBatteryQuarter; - return faBatteryEmpty; -} - -function batteryColorClass(pct) { - if (pct <= 9) return 'text-error'; // critical — user must charge soon - if (pct <= 29) return 'text-warning'; // heads-up - return 'text-base-content/60'; // everyday — same tone as RSSI -} - -export function Scales() { - const [key, setKey] = useState(0); - const [scaleData, setScaleData] = useState([]); - const [isScanning, setIsScanning] = useState(false); - const mode = machine.value.status.mode; - - useEffect(() => { - const intervalHandle = setInterval(() => { - setKey(Date.now().valueOf()); - }, 10000); - - return () => clearInterval(intervalHandle); - }, []); - - const { - isLoading, - isError, - data: fetchedScales = [], - } = useQuery(`scales-${key}`, async () => { - const response = await fetch(`/api/scales/list`); - const data = await response.json(); - return data; - }); - - const { - isInfoLoading, - isInfoError, - data: connectedScale = [], - } = useQuery(`scale-info-${key}`, async () => { - const response = await fetch(`/api/scales/info`); - const data = await response.json(); - return data; - }); - - useEffect(() => { - if (!connectedScale || fetchedScales.length === 0) { - return; - } - const scales = connectedScale.connected ? [connectedScale] : fetchedScales; - setScaleData(scales); - }, [connectedScale, fetchedScales]); - - const onScan = useCallback(async () => { - setIsScanning(true); - try { - await fetch('/api/scales/scan', { - method: 'post', - }); - // Refresh the data after scan - setKey(Date.now().valueOf()); - } catch (error) { - console.error('Scan failed:', error); - } finally { - setIsScanning(false); - } - }, [setIsScanning]); - - const onConnect = useCallback(async uuid => { - try { - const data = new FormData(); - data.append('uuid', uuid); - await fetch('/api/scales/connect', { - method: 'post', - body: data, - }); - // Refresh the data after connection - setKey(Date.now().valueOf()); - } catch (error) { - console.error('Connection failed:', error); - } - }, []); - - const loading = isLoading || isInfoLoading || isScanning; - - return ( - <> -
-

Bluetooth Devices

- -
- -
- - {mode === 0 && ( -
-
-
-

System in Standby

-

- Please put GaggiMate in Brew or Grind mode to use Bluetooth scales. -

-
-
-
- )} - {mode > 0 && ( - - )} -
-
- - Scales are automatically refreshed every 10 seconds. Use the scan button to discover - new devices. - -
-
-
-
- - ); -} - -function ScaleList(props) { - const { isLoading, isInfoLoading, isError, isInfoError, scaleData, onConnect } = props; - if (isError || isInfoError) { - return ( -
- Error loading devices. Please try again. -
- ); - } - if (scaleData.length > 0) { - return ( -
- {scaleData.map((scale, i) => ( -
-
-
- -
-
-

{scale.name}

-

- {scale.uuid} - - {scale.rssi}dB - - {scale.connected && scale.hasBattery && typeof scale.battery === 'number' && ( - <> - - - {scale.battery}% - - - )} -

-
-
- {scale.connected ? ( -
Connected
- ) : ( - - )} -
-
-
- ))} -
- ); - } - return ( - <> - {isLoading || isInfoLoading ? ( -
- - Loading devices... -
- ) : ( -
-
-
- -
-
-

No scales found

-

- Click "Scan" to discover Bluetooth scales nearby -

-
-
-
- )} - - ); -}