updated hydrobuddy web interface

landing page + 1st configuration + overview when configured (hoffentlich lol)
ltack kram rausgehauen (Testbench code)
This commit is contained in:
SebastianOtt 2026-05-22 19:55:19 +02:00
parent 57ad33718c
commit 7a3e2830a0
36 changed files with 5075 additions and 8077 deletions

381
pnpm-lock.yaml generated
View file

@ -10,10 +10,10 @@ importers:
dependencies:
'@ant-design/charts':
specifier: ^2.6.1
version: 2.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9))
version: 2.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))
'@astrojs/preact':
specifier: ^4.1.1
version: 4.1.1(@babel/core@7.28.0)(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(preact@10.26.9)(terser@5.43.1)(yaml@2.8.0)
version: 4.1.1(@babel/core@7.28.0)(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(preact@10.26.9)(terser@5.48.0)(yaml@2.8.0)
'@astrojs/ts-plugin':
specifier: ^1.10.5
version: 1.10.5
@ -22,7 +22,7 @@ importers:
version: 1.0.0(nanostores@1.0.1)(preact@10.26.9)
'@playform/compress':
specifier: ^0.1.9
version: 0.1.9(@types/node@24.1.0)(jiti@1.21.7)(rollup@4.45.1)(typescript@5.8.3)(yaml@2.8.0)
version: 0.1.9(@types/node@25.9.1)(jiti@1.21.7)(rollup@4.45.1)(typescript@5.8.3)(yaml@2.8.0)
'@tabler/icons-preact':
specifier: ^3.34.1
version: 3.34.1(preact@10.26.9)
@ -31,13 +31,13 @@ importers:
version: 1.0.10
astro:
specifier: ^5.14.7
version: 5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.43.1)(typescript@5.8.3)(yaml@2.8.0)
version: 5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.48.0)(typescript@5.8.3)(yaml@2.8.0)
astro-compressor:
specifier: ^1.0.0
version: 1.0.0
astro-min:
specifier: ^1.3.1
version: 1.3.1(astro@5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.43.1)(typescript@5.8.3)(yaml@2.8.0))
version: 1.3.1(astro@5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.48.0)(typescript@5.8.3)(yaml@2.8.0))
chart.js:
specifier: ^4.5.0
version: 4.5.0
@ -59,7 +59,7 @@ importers:
devDependencies:
'@astrojs/tailwind':
specifier: ^6.0.2
version: 6.0.2(astro@5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.43.1)(typescript@5.8.3)(yaml@2.8.0))(tailwindcss@3.4.17)
version: 6.0.2(astro@5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.48.0)(typescript@5.8.3)(yaml@2.8.0))(tailwindcss@3.4.17)
daisyui:
specifier: ^4.12.24
version: 4.12.24(postcss@8.5.6)
@ -764,6 +764,9 @@ packages:
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@ -771,6 +774,9 @@ packages:
'@jridgewell/source-map@0.3.10':
resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
'@jridgewell/source-map@0.3.11':
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
@ -780,6 +786,9 @@ packages:
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
@ -1095,6 +1104,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/fontkit@2.0.8':
resolution: {integrity: sha512-wN+8bYxIpJf+5oZdrdtaX04qUuWHcKxcDEgRS9Qm9ZClSHjzEn13SxUC+5eRM+4yXIeTYk8mTzLAWGF64847ew==}
@ -1125,6 +1137,9 @@ packages:
'@types/node@24.1.0':
resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==}
'@types/node@25.9.1':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
'@types/stylis@4.2.5':
resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==}
@ -1205,6 +1220,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@ -1218,8 +1238,8 @@ packages:
peerDependencies:
ajv: ^8.8.2
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
@ -1301,6 +1321,11 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.32:
resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==}
engines: {node: '>=6.0.0'}
hasBin: true
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@ -1330,6 +1355,11 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
bubblesets-js@2.3.4:
resolution: {integrity: sha512-DyMjHmpkS2+xcFNtyN00apJYL3ESdp9fTrkDr5+9Qg/GPqFmcWgGsK1akZnttE1XFxJ/VMy4DNNGMGYtmFp1Sg==}
@ -1361,6 +1391,9 @@ packages:
caniuse-lite@1.0.30001727:
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@ -1733,6 +1766,9 @@ packages:
electron-to-chromium@1.5.190:
resolution: {integrity: sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==}
electron-to-chromium@1.5.361:
resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==}
emoji-regex@10.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@ -1746,8 +1782,8 @@ packages:
resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==}
engines: {node: '>= 4'}
enhanced-resolve@5.18.2:
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
enhanced-resolve@5.22.0:
resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==}
engines: {node: '>=10.13.0'}
entities@4.5.0:
@ -1834,8 +1870,8 @@ packages:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fast-uri@3.0.6:
resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==}
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fastparse@1.1.2:
resolution: {integrity: sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==}
@ -2165,8 +2201,8 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
loader-runner@4.3.0:
resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==}
loader-runner@4.3.2:
resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==}
engines: {node: '>=6.11.5'}
loader-utils@2.0.4:
@ -2433,6 +2469,10 @@ packages:
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
node-releases@2.0.46:
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
engines: {node: '>=18'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@ -2613,9 +2653,6 @@ packages:
radix3@1.1.2:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
rbush@3.0.1:
resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==}
@ -2723,17 +2760,14 @@ packages:
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
schema-utils@4.3.2:
resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==}
schema-utils@4.3.3:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
semver@6.3.1:
@ -2745,9 +2779,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
@ -2872,23 +2903,50 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
tapable@2.2.2:
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
terser-webpack-plugin@5.3.14:
resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==}
terser-webpack-plugin@5.6.0:
resolution: {integrity: sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==}
engines: {node: '>= 10.13.0'}
peerDependencies:
'@minify-html/node': '*'
'@swc/core': '*'
'@swc/css': '*'
'@swc/html': '*'
clean-css: '*'
cssnano: '*'
csso: '*'
esbuild: '*'
html-minifier-terser: '*'
lightningcss: '*'
postcss: '*'
uglify-js: '*'
webpack: ^5.1.0
peerDependenciesMeta:
'@minify-html/node':
optional: true
'@swc/core':
optional: true
'@swc/css':
optional: true
'@swc/html':
optional: true
clean-css:
optional: true
cssnano:
optional: true
csso:
optional: true
esbuild:
optional: true
html-minifier-terser:
optional: true
lightningcss:
optional: true
postcss:
optional: true
uglify-js:
optional: true
@ -2897,8 +2955,8 @@ packages:
engines: {node: '>=10'}
hasBin: true
terser@5.43.1:
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
terser@5.48.0:
resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==}
engines: {node: '>=10'}
hasBin: true
@ -2975,6 +3033,9 @@ packages:
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
undici-types@7.8.0:
resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==}
@ -3085,6 +3146,12 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
utf-8-validate@5.0.10:
resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
engines: {node: '>=6.14.2'}
@ -3160,15 +3227,15 @@ packages:
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
watchpack@2.4.4:
resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==}
watchpack@2.5.1:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
engines: {node: '>=10.13.0'}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
webpack-sources@3.3.3:
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
webpack-sources@3.5.0:
resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==}
engines: {node: '>=10.13.0'}
webpack@5.99.9:
@ -3289,9 +3356,9 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@ant-design/charts@2.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9))':
'@ant-design/charts@2.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))':
dependencies:
'@ant-design/graphs': 2.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9))
'@ant-design/graphs': 2.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))
'@ant-design/plots': 2.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
lodash: 4.17.21
react: 19.1.0
@ -3299,12 +3366,12 @@ snapshots:
transitivePeerDependencies:
- workerize-loader
'@ant-design/graphs@2.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9))':
'@ant-design/graphs@2.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))':
dependencies:
'@ant-design/charts-util': 0.0.1-alpha.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@antv/g6': 5.0.49(workerize-loader@2.0.2(webpack@5.99.9))
'@antv/g6-extension-react': 0.2.4(@antv/g6@5.0.49(workerize-loader@2.0.2(webpack@5.99.9)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@antv/graphin': 3.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9))
'@antv/g6': 5.0.49(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))
'@antv/g6-extension-react': 0.2.4(@antv/g6@5.0.49(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6))))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@antv/graphin': 3.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))
lodash: 4.17.21
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
@ -3501,15 +3568,15 @@ snapshots:
flru: 1.0.2
pdfast: 0.2.0
'@antv/g6-extension-react@0.2.4(@antv/g6@5.0.49(workerize-loader@2.0.2(webpack@5.99.9)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@antv/g6-extension-react@0.2.4(@antv/g6@5.0.49(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6))))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@antv/g': 6.1.27
'@antv/g-svg': 2.0.41
'@antv/g6': 5.0.49(workerize-loader@2.0.2(webpack@5.99.9))
'@antv/g6': 5.0.49(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@antv/g6@5.0.49(workerize-loader@2.0.2(webpack@5.99.9))':
'@antv/g6@5.0.49(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))':
dependencies:
'@antv/algorithm': 0.1.26
'@antv/component': 2.1.5
@ -3519,7 +3586,7 @@ snapshots:
'@antv/g-plugin-dragndrop': 2.0.37
'@antv/graphlib': 2.0.4
'@antv/hierarchy': 0.6.14
'@antv/layout': 1.2.14-beta.9(workerize-loader@2.0.2(webpack@5.99.9))
'@antv/layout': 1.2.14-beta.9(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))
'@antv/util': 3.3.11
bubblesets-js: 2.3.4
transitivePeerDependencies:
@ -3533,9 +3600,9 @@ snapshots:
'@antv/g-web-animations-api': 2.1.27
'@babel/runtime': 7.27.6
'@antv/graphin@3.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9))':
'@antv/graphin@3.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))':
dependencies:
'@antv/g6': 5.0.49(workerize-loader@2.0.2(webpack@5.99.9))
'@antv/g6': 5.0.49(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
transitivePeerDependencies:
@ -3547,12 +3614,12 @@ snapshots:
'@antv/hierarchy@0.6.14': {}
'@antv/layout@1.2.14-beta.9(workerize-loader@2.0.2(webpack@5.99.9))':
'@antv/layout@1.2.14-beta.9(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))':
dependencies:
'@antv/event-emitter': 0.1.3
'@antv/graphlib': 2.0.4
'@antv/util': 3.3.11
'@naoak/workerize-transferable': 0.1.0(workerize-loader@2.0.2(webpack@5.99.9))
'@naoak/workerize-transferable': 0.1.0(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))
comlink: 4.4.2
d3-force: 3.0.0
d3-force-3d: 3.0.6
@ -3655,13 +3722,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@astrojs/preact@4.1.1(@babel/core@7.28.0)(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(preact@10.26.9)(terser@5.43.1)(yaml@2.8.0)':
'@astrojs/preact@4.1.1(@babel/core@7.28.0)(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(preact@10.26.9)(terser@5.48.0)(yaml@2.8.0)':
dependencies:
'@preact/preset-vite': 2.10.2(@babel/core@7.28.0)(preact@10.26.9)(vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0))
'@preact/preset-vite': 2.10.2(@babel/core@7.28.0)(preact@10.26.9)(vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0))
'@preact/signals': 2.3.2(preact@10.26.9)
preact: 10.26.9
preact-render-to-string: 6.6.2(preact@10.26.9)
vite: 6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0)
vite: 6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
@ -3681,9 +3748,9 @@ snapshots:
dependencies:
prismjs: 1.30.0
'@astrojs/tailwind@6.0.2(astro@5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.43.1)(typescript@5.8.3)(yaml@2.8.0))(tailwindcss@3.4.17)':
'@astrojs/tailwind@6.0.2(astro@5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.48.0)(typescript@5.8.3)(yaml@2.8.0))(tailwindcss@3.4.17)':
dependencies:
astro: 5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.43.1)(typescript@5.8.3)(yaml@2.8.0)
astro: 5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.48.0)(typescript@5.8.3)(yaml@2.8.0)
autoprefixer: 10.4.21(postcss@8.5.6)
postcss: 8.5.6
postcss-load-config: 4.0.2(postcss@8.5.6)
@ -4126,6 +4193,11 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/source-map@0.3.10':
@ -4133,6 +4205,11 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/source-map@0.3.11':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/sourcemap-codec@1.5.4': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
@ -4142,6 +4219,11 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kurkle/color@0.3.4': {}
'@minify-html/node-darwin-x64@0.15.0':
@ -4158,9 +4240,9 @@ snapshots:
nanostores: 1.0.1
preact: 10.26.9
'@naoak/workerize-transferable@0.1.0(workerize-loader@2.0.2(webpack@5.99.9))':
'@naoak/workerize-transferable@0.1.0(workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)))':
dependencies:
workerize-loader: 2.0.2(webpack@5.99.9)
workerize-loader: 2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6))
'@nodelib/fs.scandir@2.1.5':
dependencies:
@ -4179,12 +4261,12 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@playform/compress@0.1.9(@types/node@24.1.0)(jiti@1.21.7)(rollup@4.45.1)(typescript@5.8.3)(yaml@2.8.0)':
'@playform/compress@0.1.9(@types/node@25.9.1)(jiti@1.21.7)(rollup@4.45.1)(typescript@5.8.3)(yaml@2.8.0)':
dependencies:
'@playform/pipe': 0.1.3
'@types/csso': 5.0.4
'@types/html-minifier-terser': 7.0.2
astro: 5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.39.0)(typescript@5.8.3)(yaml@2.8.0)
astro: 5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.39.0)(typescript@5.8.3)(yaml@2.8.0)
commander: 13.1.0
csso: 5.0.5
deepmerge-ts: 7.1.5
@ -4234,18 +4316,18 @@ snapshots:
deepmerge-ts: 7.1.5
fast-glob: 3.3.3
'@preact/preset-vite@2.10.2(@babel/core@7.28.0)(preact@10.26.9)(vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0))':
'@preact/preset-vite@2.10.2(@babel/core@7.28.0)(preact@10.26.9)(vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.0)
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.0)
'@prefresh/vite': 2.4.8(preact@10.26.9)(vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0))
'@prefresh/vite': 2.4.8(preact@10.26.9)(vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0))
'@rollup/pluginutils': 4.2.1
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.0)
debug: 4.4.1
picocolors: 1.1.1
vite: 6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0)
vite-prerender-plugin: 0.5.11(vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0))
vite: 6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0)
vite-prerender-plugin: 0.5.11(vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0))
transitivePeerDependencies:
- preact
- supports-color
@ -4265,7 +4347,7 @@ snapshots:
'@prefresh/utils@1.2.1': {}
'@prefresh/vite@2.4.8(preact@10.26.9)(vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0))':
'@prefresh/vite@2.4.8(preact@10.26.9)(vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0))':
dependencies:
'@babel/core': 7.28.0
'@prefresh/babel-plugin': 0.5.2
@ -4273,7 +4355,7 @@ snapshots:
'@prefresh/utils': 1.2.1
'@rollup/pluginutils': 4.2.1
preact: 10.26.9
vite: 6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0)
vite: 6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0)
transitivePeerDependencies:
- supports-color
@ -4463,15 +4545,17 @@ snapshots:
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/eslint@9.6.1':
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@types/estree@1.0.8': {}
'@types/estree@1.0.9': {}
'@types/fontkit@2.0.8':
dependencies:
'@types/node': 24.1.0
@ -4504,6 +4588,10 @@ snapshots:
dependencies:
undici-types: 7.8.0
'@types/node@25.9.1':
dependencies:
undici-types: 7.24.6
'@types/stylis@4.2.5': {}
'@types/trusted-types@2.0.7':
@ -4611,19 +4699,21 @@ snapshots:
acorn@8.15.0: {}
ajv-formats@2.1.1(ajv@8.17.1):
acorn@8.16.0: {}
ajv-formats@2.1.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.17.1
ajv: 8.20.0
ajv-keywords@5.1.0(ajv@8.17.1):
ajv-keywords@5.1.0(ajv@8.20.0):
dependencies:
ajv: 8.17.1
ajv: 8.20.0
fast-deep-equal: 3.1.3
ajv@8.17.1:
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.0.6
fast-uri: 3.1.2
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@ -4658,13 +4748,13 @@ snapshots:
astro-compressor@1.0.0: {}
astro-min@1.3.1(astro@5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.43.1)(typescript@5.8.3)(yaml@2.8.0)):
astro-min@1.3.1(astro@5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.48.0)(typescript@5.8.3)(yaml@2.8.0)):
dependencies:
'@redwerkz/html-min': 0.15.1
astro: 5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.43.1)(typescript@5.8.3)(yaml@2.8.0)
astro: 5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.48.0)(typescript@5.8.3)(yaml@2.8.0)
svgo: 3.3.2
astro@5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.39.0)(typescript@5.8.3)(yaml@2.8.0):
astro@5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.39.0)(typescript@5.8.3)(yaml@2.8.0):
dependencies:
'@astrojs/compiler': 2.12.2
'@astrojs/internal-helpers': 0.7.4
@ -4720,8 +4810,8 @@ snapshots:
unist-util-visit: 5.0.0
unstorage: 1.17.1
vfile: 6.0.3
vite: 6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.39.0)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0))
vite: 6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.39.0)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
@ -4765,7 +4855,7 @@ snapshots:
- uploadthing
- yaml
astro@5.14.7(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.43.1)(typescript@5.8.3)(yaml@2.8.0):
astro@5.14.7(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.45.1)(terser@5.48.0)(typescript@5.8.3)(yaml@2.8.0):
dependencies:
'@astrojs/compiler': 2.12.2
'@astrojs/internal-helpers': 0.7.4
@ -4821,8 +4911,8 @@ snapshots:
unist-util-visit: 5.0.0
unstorage: 1.17.1
vfile: 6.0.3
vite: 6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0))
vite: 6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
@ -4890,6 +4980,8 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.32: {}
big.js@5.2.2: {}
binary-extensions@2.3.0: {}
@ -4926,6 +5018,14 @@ snapshots:
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.1)
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.32
caniuse-lite: 1.0.30001793
electron-to-chromium: 1.5.361
node-releases: 2.0.46
update-browserslist-db: 1.2.3(browserslist@4.28.2)
bubblesets-js@2.3.4: {}
buffer-from@1.1.2: {}
@ -4951,6 +5051,8 @@ snapshots:
caniuse-lite@1.0.30001727: {}
caniuse-lite@1.0.30001793: {}
ccount@2.0.1: {}
chalk@5.4.1: {}
@ -5286,6 +5388,8 @@ snapshots:
electron-to-chromium@1.5.190: {}
electron-to-chromium@1.5.361: {}
emoji-regex@10.4.0: {}
emoji-regex@8.0.0: {}
@ -5294,10 +5398,10 @@ snapshots:
emojis-list@3.0.0: {}
enhanced-resolve@5.18.2:
enhanced-resolve@5.22.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.2
tapable: 2.3.3
entities@4.5.0: {}
@ -5407,7 +5511,7 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
fast-uri@3.0.6: {}
fast-uri@3.1.2: {}
fastparse@1.1.2: {}
@ -5670,7 +5774,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 24.1.0
'@types/node': 25.9.1
merge-stream: 2.0.0
supports-color: 8.1.1
@ -5745,7 +5849,7 @@ snapshots:
lines-and-columns@1.2.4: {}
loader-runner@4.3.0: {}
loader-runner@4.3.2: {}
loader-utils@2.0.4:
dependencies:
@ -6188,6 +6292,8 @@ snapshots:
node-releases@2.0.19: {}
node-releases@2.0.46: {}
normalize-path@3.0.0: {}
normalize-range@0.1.2: {}
@ -6352,10 +6458,6 @@ snapshots:
radix3@1.1.2: {}
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
rbush@3.0.1:
dependencies:
quickselect: 2.0.0
@ -6526,27 +6628,21 @@ snapshots:
rw@1.3.3: {}
safe-buffer@5.2.1: {}
safer-buffer@2.1.2: {}
scheduler@0.26.0: {}
schema-utils@4.3.2:
schema-utils@4.3.3:
dependencies:
'@types/json-schema': 7.0.15
ajv: 8.17.1
ajv-formats: 2.1.1(ajv@8.17.1)
ajv-keywords: 5.1.0(ajv@8.17.1)
ajv: 8.20.0
ajv-formats: 2.1.1(ajv@8.20.0)
ajv-keywords: 5.1.0(ajv@8.20.0)
semver@6.3.1: {}
semver@7.7.2: {}
serialize-javascript@6.0.2:
dependencies:
randombytes: 2.1.0
shallowequal@1.1.0: {}
sharp@0.33.5:
@ -6759,16 +6855,18 @@ snapshots:
transitivePeerDependencies:
- ts-node
tapable@2.2.2: {}
tapable@2.3.3: {}
terser-webpack-plugin@5.3.14(webpack@5.99.9):
terser-webpack-plugin@5.6.0(lightningcss@1.29.3)(postcss@8.5.6)(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)):
dependencies:
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
terser: 5.43.1
webpack: 5.99.9
schema-utils: 4.3.3
terser: 5.48.0
webpack: 5.99.9(lightningcss@1.29.3)(postcss@8.5.6)
optionalDependencies:
lightningcss: 1.29.3
postcss: 8.5.6
terser@5.39.0:
dependencies:
@ -6777,10 +6875,10 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
terser@5.43.1:
terser@5.48.0:
dependencies:
'@jridgewell/source-map': 0.3.10
acorn: 8.15.0
'@jridgewell/source-map': 0.3.11
acorn: 8.16.0
commander: 2.20.3
source-map-support: 0.5.21
@ -6837,6 +6935,8 @@ snapshots:
undici-types@6.20.0: {}
undici-types@7.24.6: {}
undici-types@7.8.0: {}
unicode-properties@1.4.1:
@ -6924,6 +7024,12 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
escalade: 3.2.0
picocolors: 1.1.1
utf-8-validate@5.0.10:
dependencies:
node-gyp-build: 4.8.4
@ -6945,7 +7051,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.2
vite-prerender-plugin@0.5.11(vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0)):
vite-prerender-plugin@0.5.11(vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0)):
dependencies:
kolorist: 1.8.0
magic-string: 0.30.17
@ -6953,9 +7059,9 @@ snapshots:
simple-code-frame: 1.3.0
source-map: 0.7.4
stack-trace: 1.0.0-pre2
vite: 6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0)
vite: 6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0)
vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.39.0)(yaml@2.8.0):
vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.39.0)(yaml@2.8.0):
dependencies:
esbuild: 0.25.8
fdir: 6.4.6(picomatch@4.0.3)
@ -6964,14 +7070,14 @@ snapshots:
rollup: 4.45.1
tinyglobby: 0.2.14
optionalDependencies:
'@types/node': 24.1.0
'@types/node': 25.9.1
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.29.3
terser: 5.39.0
yaml: 2.8.0
vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0):
vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0):
dependencies:
esbuild: 0.25.8
fdir: 6.4.6(picomatch@4.0.3)
@ -6980,59 +7086,68 @@ snapshots:
rollup: 4.45.1
tinyglobby: 0.2.14
optionalDependencies:
'@types/node': 24.1.0
'@types/node': 25.9.1
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.29.3
terser: 5.43.1
terser: 5.48.0
yaml: 2.8.0
vitefu@1.1.1(vite@6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0)):
vitefu@1.1.1(vite@6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0)):
optionalDependencies:
vite: 6.4.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.43.1)(yaml@2.8.0)
vite: 6.4.1(@types/node@25.9.1)(jiti@1.21.7)(lightningcss@1.29.3)(terser@5.48.0)(yaml@2.8.0)
vscode-languageserver-textdocument@1.0.12: {}
vscode-uri@3.1.0: {}
watchpack@2.4.4:
watchpack@2.5.1:
dependencies:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
web-namespaces@2.0.1: {}
webpack-sources@3.3.3: {}
webpack-sources@3.5.0: {}
webpack@5.99.9:
webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
browserslist: 4.25.1
acorn: 8.16.0
browserslist: 4.28.2
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.2
enhanced-resolve: 5.22.0
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.0
loader-runner: 4.3.2
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.2
tapable: 2.2.2
terser-webpack-plugin: 5.3.14(webpack@5.99.9)
watchpack: 2.4.4
webpack-sources: 3.3.3
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.0(lightningcss@1.29.3)(postcss@8.5.6)(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6))
watchpack: 2.5.1
webpack-sources: 3.5.0
transitivePeerDependencies:
- '@minify-html/node'
- '@swc/core'
- '@swc/css'
- '@swc/html'
- clean-css
- cssnano
- csso
- esbuild
- html-minifier-terser
- lightningcss
- postcss
- uglify-js
websocket@1.0.35:
@ -7056,10 +7171,10 @@ snapshots:
dependencies:
string-width: 7.2.0
workerize-loader@2.0.2(webpack@5.99.9):
workerize-loader@2.0.2(webpack@5.99.9(lightningcss@1.29.3)(postcss@8.5.6)):
dependencies:
loader-utils: 2.0.4
webpack: 5.99.9
webpack: 5.99.9(lightningcss@1.29.3)(postcss@8.5.6)
wrap-ansi@7.0.0:
dependencies:

View file

@ -0,0 +1,209 @@
---
import IconEye from "../icons/IconEye.astro";
import IconEyeClosed from "../icons/IconEyeClosed.astro";
---
<dialog id="ap-config-modal" class="modal modal-middle">
<div class="modal-box w-11/12 sm:w-6/12 max-w-5xl flex flex-col">
<h1 class="text-lg font-bold">Access Point Configuration</h1>
<div class="divider divider-primary"></div>
<div class="flex flex-col gap-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- SSID -->
<div class="flex flex-col gap-1.5">
<span class="label-text font-semibold ml-1">Network SSID</span>
<label class="input w-full focus-within:bg-primary/10 focus-within:border-none focus-within:outline-primary" id="ap-ssid-wrap">
<input type="text" class="w-full border-none outline-none bg-transparent" id="ap-ssid" placeholder="AP-Name" />
</label>
<p id="ap-ssid-error" class="text-sm text-error hidden"></p>
<span class="text-sm opacity-60 ml-1">Name of your WiFi hotspot</span>
</div>
<!-- Password -->
<div class="flex flex-col gap-1.5">
<span class="label-text font-semibold ml-1">Password</span>
<div class="relative">
<label class="input w-full focus-within:bg-primary/10 focus-within:border-none focus-within:outline-primary pr-10" id="ap-password-wrap">
<input type="password" class="w-full border-none outline-none bg-transparent" id="ap-password" placeholder="Leave empty for open network" />
</label>
<button type="button" id="toggle-ap-password-btn" class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs p-1 min-h-0 h-7 hover:bg-primary hover:text-primary-content">
<span id="ap-eye-icon-open">
<IconEye size={20} class="opacity-70 hover:opacity-100" />
</span>
<span id="ap-eye-icon-closed" class="hidden">
<IconEyeClosed size={20} class="opacity-70 hover:opacity-100" />
</span>
</button>
</div>
<p id="ap-password-error" class="text-sm text-error hidden"></p>
<span class="text-sm opacity-60 ml-1">Leave this empty for an open (unsecured) network or set a Password (min. 8 symbols)</span>
</div>
</div>
<!-- Channel -->
<div class="flex flex-col gap-1.5">
<span class="label-text font-semibold ml-1">WiFi Channel</span>
<label class="input w-full focus-within:bg-primary/10 focus-within:border-none focus-within:outline-primary p-0 overflow-hidden">
<select class="w-full h-full bg-transparent border-none outline-none pl-4 pr-8 cursor-pointer" id="ap-channel">
<option value="0">Auto</option>
{Array.from({ length: 13 }, (_, i) => i + 1).map(ch => (
<option value={ch}>Channel {ch}</option>
))}
</select>
</label>
<span class="text-sm opacity-60 ml-1">Select 'Auto' for automatic channel selection</span>
</div>
<div class="divider my-0"></div>
<div class="alert alert-info py-3">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="text-sm">
<p class="font-semibold">Access Point Mode Info</p>
<p>When in AP mode, your device creates a WiFi network. Other devices can connect to it using the SSID and password you configure above.</p>
</div>
</div>
</div>
<div class="modal-action mt-6">
<form method="dialog">
<button class="btn">Cancel</button>
</form>
<button class="btn btn-primary" id="save-ap-btn">Save Configuration</button>
</div>
</div>
</dialog>
<script>
import {
$current_network_configuration,
send_new_network_configuration_to_alpha,
ConnectionMode,
} from "../backend_components/NetworkManagerStore";
const modal = document.getElementById("ap-config-modal") as HTMLDialogElement;
const apSSID = document.getElementById("ap-ssid") as HTMLInputElement;
const apSSIDWrap = document.getElementById("ap-ssid-wrap");
const apSSIDError = document.getElementById("ap-ssid-error");
const apPassword = document.getElementById("ap-password") as HTMLInputElement;
const apPasswordWrap = document.getElementById("ap-password-wrap");
const apPasswordError = document.getElementById("ap-password-error");
const apChannel = document.getElementById("ap-channel") as HTMLSelectElement;
const toggleApPasswordBtn = document.getElementById("toggle-ap-password-btn");
const apEyeOpen = document.getElementById("ap-eye-icon-open");
const apEyeClosed = document.getElementById("ap-eye-icon-closed");
function resetModal() {
if (apSSID) apSSID.value = "";
if (apPassword) { apPassword.value = ""; apPassword.type = "password"; }
if (apChannel) apChannel.value = "0";
apSSIDWrap?.classList.remove("input-error");
apSSIDError?.classList.add("hidden");
apPasswordWrap?.classList.remove("input-error");
apPasswordError?.classList.add("hidden");
apEyeOpen?.classList.remove("hidden");
apEyeClosed?.classList.add("hidden");
}
function prefillFromConfig() {
const cfg = $current_network_configuration.get();
if (apSSID) apSSID.value = cfg.SSID_AP || "";
if (apPassword) apPassword.value = cfg.Password_AP || "";
if (apChannel) apChannel.value = String(cfg.AP_Channel ?? 0);
}
if (modal) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "open" && modal.open) {
resetModal();
prefillFromConfig();
}
});
});
observer.observe(modal, { attributes: true, attributeFilter: ["open"] });
}
toggleApPasswordBtn?.addEventListener("click", () => {
if (!apPassword) return;
const isVisible = apPassword.type === "text";
apPassword.type = isVisible ? "password" : "text";
apEyeOpen?.classList.toggle("hidden", !isVisible);
apEyeClosed?.classList.toggle("hidden", isVisible);
});
apSSID?.addEventListener("input", () => {
apSSIDWrap?.classList.remove("input-error");
apSSIDError?.classList.add("hidden");
});
apPassword?.addEventListener("input", () => {
apPasswordWrap?.classList.remove("input-error");
apPasswordError?.classList.add("hidden");
});
modal?.addEventListener("close", resetModal);
const saveApBtn = document.getElementById("save-ap-btn");
saveApBtn?.addEventListener("click", () => {
let valid = true;
if (!apSSID?.value || apSSID.value.length === 0) {
apSSIDWrap?.classList.add("input-error");
if (apSSIDError) { apSSIDError.textContent = "SSID is required."; apSSIDError.classList.remove("hidden"); }
valid = false;
} else if (apSSID.value.length > 31) {
apSSIDWrap?.classList.add("input-error");
if (apSSIDError) { apSSIDError.textContent = "SSID must be shorter than 32 characters."; apSSIDError.classList.remove("hidden"); }
valid = false;
}
if (apPassword?.value && apPassword.value.length < 8) {
apPasswordWrap?.classList.add("input-error");
if (apPasswordError) { apPasswordError.textContent = "Password must be at least 8 characters."; apPasswordError.classList.remove("hidden"); }
valid = false;
} else if (apPassword?.value && apPassword.value.length > 31) {
apPasswordWrap?.classList.add("input-error");
if (apPasswordError) { apPasswordError.textContent = "Password must be shorter than 32 characters."; apPasswordError.classList.remove("hidden"); }
valid = false;
}
if (!valid) return;
const base = $current_network_configuration.get();
// Derive the APSTA connection mode: keep ETH DHCP/static, switch WiFi to APSTA
let apstaMode: ConnectionMode;
switch (base.Connection_Mode) {
case ConnectionMode.CONNECTION_MODE_ETH_STATIC_NO_WIFI:
case ConnectionMode.CONNECTION_MODE_ETH_STATIC_APSTA:
case ConnectionMode.CONNECTION_MODE_ETH_STATIC_STA_DHCP:
case ConnectionMode.CONNECTION_MODE_ETH_STATIC_STA_STATIC:
apstaMode = ConnectionMode.CONNECTION_MODE_ETH_STATIC_APSTA;
break;
default:
apstaMode = ConnectionMode.CONNECTION_MODE_ETH_DHCP_APSTA;
}
const updatedConfig = {
...base,
Connection_Mode: apstaMode,
WiFi_Enabled: true,
SSID_AP: apSSID.value,
Password_AP: apPassword?.value ?? "",
AP_Channel: parseInt(apChannel?.value ?? "0", 10),
};
const sent = send_new_network_configuration_to_alpha(updatedConfig);
if (sent) {
window.dispatchEvent(new CustomEvent("network-config-applying"));
modal?.close();
} else {
console.error("Failed to send AP configuration to device.");
}
});
</script>

View file

@ -1,689 +1,13 @@
import { atom } from 'nanostores';
import pkg from "websocket";
const { w3cwebsocket } = pkg;
import { atom } from "nanostores";
// Update Status Enum - matches backend enum in update_notifier.h
export enum UpdateStatus {
ERROR = -1,
STARTED = 0,
IN_PROGRESS = 1,
SUCCESS = 2,
NO_UPDATE = 3
}
export const $websocket = atom<WebSocket | null>(null);
export const $activePage = atom<string>('dashboard');
export const $freeInternalHeap = atom<number>(375 * 1024);
export const $totalInternalHeap = atom<number>(375 * 1024); // ESP32-P4 has 375KB internal heap (heap_caps_get_info mit MALLOC_CAP_INTERNAL)
export const $modulationInput = atom<number>(0);
export const $inputVoltage = atom<number>(0);
export const $driverTemp = atom<number>(0);
export const $counter = atom<number>(0);
export const $ref = atom<number>(0);
export const $dcdcState = atom<number>(0);
export const $dcdc_errorStatus = atom<string>('--');
export const $idCountDashboard = atom<number>(2);
export const $wsConnectionStatus = atom<string>('Disconnected');
export const $modulationInputMethod = atom<string>('--');
export const $gpioState = atom<{ [key: number]: boolean }>({
4: false,
5: false,
6: false,
7: false,
8: false,
9: false,
});
export const $validGpioKeys = atom<number[]>([4, 5, 6, 7, 8, 9]);
/* DCDC */
export const $dcdc_state = atom<number>(0);
export const $dcdc_state_string = atom<string>('--');
export const $dcdc_state_desc_string = atom<string>('--');
export const $dcdc_error_flag = atom<number>(0);
export const $POWERGOOD = atom<number>(0);
export const $POWERGOOD_string = atom<string>('--');
export const $ref_value = atom<number>(0);
/* LASER */
export const $laser_state = atom<number>(2); // LASER_STOP
export const $laser_state_string = atom<string>('--');
export const $laser_state_desc_string = atom<string>('--');
export const $is_REF_calibrated = atom<number>(0);
export const $has_duty_lookup_table = atom<number>(0);
export const $laser_error_flag = atom<number>(0);
export const $laser_error_flag_string = atom<string>('--');
export const $LASERGOOD = atom<number>(0);
export const $LASERGOOD_string = atom<string>('--');
export const $laser_set_temp = atom<number>(0);
export const $laser_max_temp = atom<number>(0);
export const $interlock = atom<number>(0);
export const $set_current = atom<number>(0);
export const $max_current = atom<number>(0);
export const $threshold_current = atom<number>(0);
export const $current_reading = atom<number>(0);
export const $current_reading_string = atom<string>('--');
export const $laser_temp = atom<number>(0);
export const $laser_temp_string = atom<string>('--');
/* TEMPERATURE CHART DATA */
export const $temperature_chart_data = atom<{
timestamps: string[];
driverTemps: number[];
laserTemps: number[];
}>({
timestamps: [],
driverTemps: [],
laserTemps: []
});
/* DRIVER */
export const $driver_temp = atom<number>(0);
export const $driver_temp_string = atom<string>('--');
export const $driver_max_temp = atom<number>(0);
export const $driver_max_temp_string = atom<string>('--');
export const $vcc_voltage = atom<number>(0);
export const $vcc_voltage_string = atom<string>('--');
export const $pd_voltage = atom<number>(0);
export const $pd_voltage_string = atom<string>('--');
/* CALIBRATION TABLE */
export const $calibration_table_data = atom<{
values: number[];
min_duty: number;
max_duty: number;
points_count: number;
}>
({
values: [],
min_duty: 0,
max_duty: 0,
points_count: 0,
});
/* UPDATE SYSTEM */
export interface UpdateInfo {
core: string;
currentVersion: string;
newVersion: string;
icon: string;
hasProgressBar: boolean;
updateAvailable: boolean;
}
// Neue Status-Definitionen
export enum TargetUpdateStatus {
WAITING = 'waiting',
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
ERROR = 'error',
NO_UPDATE = 'no_update'
}
export enum TotalUpdateStatus {
INITIAL = 'initial',
STARTED = 'started',
UPDATING = 'updating',
SUCCESS = 'success',
ERROR = 'error',
RESTARTING = 'restarting'
}
export const $availableUpdates = atom<UpdateInfo[]>([]);
export const $updateChangelog = atom<string>('');
export const $updateInProgress = atom<boolean>(false);
// Separate progress stores for each update type
// [progress, status] where status uses UpdateStatus enum
export const $p4FirmwareProgress = atom<[number, TargetUpdateStatus]>([0, TargetUpdateStatus.NO_UPDATE]); // [progress, TargetUpdateStatus]
export const $p4WebinterfaceProgress = atom<[number, TargetUpdateStatus]>([0, TargetUpdateStatus.NO_UPDATE]); // [progress, TargetUpdateStatus]
export const $stm32FirmwareProgress = atom<[number, TargetUpdateStatus]>([0, TargetUpdateStatus.NO_UPDATE]); // [progress, TargetUpdateStatus]
export const $c6FirmwareProgress = atom<[number, TargetUpdateStatus]>([0, TargetUpdateStatus.NO_UPDATE]); // [progress, TargetUpdateStatus]
// Neuer Store für den Gesamt-Update-Status
export const $totalUpdateStatus = atom<TotalUpdateStatus>(TotalUpdateStatus.INITIAL);
export const $currentUpdateTargetIndex = atom<number>(0);
export const $updateErrorMessages = atom<{ [key: number]: string }>({});
export const $updatedTargets = atom<boolean[]>([false, false, false, false]);
// Add update state to global store
export const $updateState = atom<'initial' | 'updating' | 'success' | 'error' | 'reloading' | 'restarting'>('initial');
// Global modal state
export const $isUpdaterModalOpen = atom<boolean>(false);
export const $isSuccessPopupOpen = atom<boolean>(false);
export const $isErrorPopupOpen = atom<boolean>(false);
export const getDcdcStateString = (state: number): string => {
switch (state) {
case 0: return "IDLE";
case 1: return "INIT";
case 2: return "START";
case 3: return "RUN";
case 4: return "STOP";
case 5: return "ERROR";
default: return "UNKNOWN";
}
};
export const getDcdcStateDescString = (state: number): string => {
switch (state) {
case 0: return "DCDC is idle";
case 1: return "Initializing Voltage ramping";
case 2: return "Ramping to target voltage";
case 3: return "DCDC is running";
case 4: return "DCDC is stopped";
case 5: return "DCDC is in error state"; // TODO: add error description
default: return "UNKNOWN";
}
};
export const getLaserStateString = (state: number): string => {
switch (state) {
case 0: return "INTERNAL MODULATION";
case 1: return "EXTERNAL MODULATION";
case 2: return "STOP";
case 3: return "ERROR";
case 4: return "REF CALIBRATION";
case 5: return "LASER CALIBRATION";
default: return "UNKNOWN";
}
};
export const getLaserStateDescString = (state: number): string => {
switch (state) {
case 0: return "Internal laser modulation running";
case 1: return "External laser modulation running";
case 2: return "Laser stopped";
case 3: return "Laser in error state"; // TODO: add error description
case 4: return "Calibrating REF valie";
case 5: return "Calibrating laser";
default: return "Unknown laser state";
}
};
const setDCDCState = (state: number) => {
$dcdc_state.set(state);
$dcdc_state_string.set(getDcdcStateString(state));
$dcdc_state_desc_string.set(getDcdcStateDescString(state));
}
const setLaserState = (state: number) => {
$laser_state.set(state);
$laser_state_string.set(getLaserStateString(state));
$laser_state_desc_string.set(getLaserStateDescString(state));
}
const setPOWERGOOD = (state: number) => {
$POWERGOOD.set(state);
$POWERGOOD_string.set(state ? "Yes" : "No");
}
const setLASERGOOD = (state: number) => {
$LASERGOOD.set(state);
$LASERGOOD_string.set(state ? "Yes" : "No");
}
const setDcdcErrorStatus = (state: number) => {
$dcdc_error_flag.set(state);
$dcdc_errorStatus.set(state ? "Error" : "No Errors");
}
const setLaserErrorStatus = (state: number) => {
$laser_error_flag.set(state);
$laser_error_flag_string.set(state ? "Error" : "No Errors");
}
const setREF_value = (state: number) => {
$ref_value.set(state);
}
const setModulationInputMethod = (state: number) => {
$modulationInputMethod.set(state === 0 ? "INTERNAL MODULATION" : state === 1 ? "EXTERNAL MODULATION" : "---");
}
const setREF_calibrated = (state: number) => {
$is_REF_calibrated.set(state);
}
const setHAS_DUTY_LOOKUP_TABLE = (state: number) => {
$has_duty_lookup_table.set(state);
}
const setLaserSetTemp = (state: number) => {
$laser_set_temp.set(state);
}
const setLaserMaxTemp = (state: number) => {
$laser_max_temp.set(state);
}
const setInterlock = (state: number) => {
$interlock.set(state);
}
const setModulationInput = (state: number) => {
$modulationInput.set(state);
}
const setSetCurrent = (state: number) => {
$set_current.set(state);
}
const setMaxCurrent = (state: number) => {
$max_current.set(state);
}
const setThresholdCurrent = (state: number) => {
$threshold_current.set(state);
}
const setCurrentReading = (state: number) => {
$current_reading.set(state);
$current_reading_string.set(state + " mA");
}
const setLaserTemp = (state: number) => {
$laser_temp.set(state);
$laser_temp_string.set(state + " °C");
}
const setDriverTemp = (state: number) => {
$driver_temp.set(state);
$driver_temp_string.set(state + " °C");
}
const setDriverMaxTemp = (state: number) => {
$driver_max_temp.set(state);
$driver_max_temp_string.set(state + " °C");
}
// Temperature chart data management
const maxTemperatureDataPoints = 300; // 5 minutes worth of data at 1 second intervals
export const addTemperatureDataPoint = (driverTemp: number, laserTemp: number) => {
const currentData = $temperature_chart_data.get();
const now = new Date();
const timeString = now.toLocaleTimeString();
const newData = {
timestamps: [...currentData.timestamps, timeString],
driverTemps: [...currentData.driverTemps, driverTemp],
laserTemps: [...currentData.laserTemps, laserTemp]
};
// Remove old data points to maintain 5-minute window
if (newData.timestamps.length > maxTemperatureDataPoints) {
newData.timestamps = newData.timestamps.slice(-maxTemperatureDataPoints);
newData.driverTemps = newData.driverTemps.slice(-maxTemperatureDataPoints);
newData.laserTemps = newData.laserTemps.slice(-maxTemperatureDataPoints);
}
$temperature_chart_data.set(newData);
}
export const clearTemperatureData = () => {
$temperature_chart_data.set({
timestamps: [],
driverTemps: [],
laserTemps: []
});
}
const setVccVoltage = (state: number) => {
$vcc_voltage.set(state);
$vcc_voltage_string.set(state + " V");
}
const setPdVoltage = (state: number) => {
$pd_voltage.set(state);
$pd_voltage_string.set(state + " V");
}
const setCalibrationTableData = (values: number[], min_duty: number, max_duty: number, points_count: number) => {
$calibration_table_data.set({
values: values,
min_duty: min_duty,
max_duty: max_duty,
points_count: points_count
});
}
const setAvailableUpdates = (updates: UpdateInfo[]) => {
$availableUpdates.set(updates);
}
const setUpdateChangelog = (changelog: string) => {
$updateChangelog.set(changelog);
}
const setUpdateInProgress = (inProgress: boolean) => {
$updateInProgress.set(inProgress);
}
const setP4FirmwareProgress = (progress: number, status: TargetUpdateStatus = TargetUpdateStatus.NO_UPDATE) => {
$p4FirmwareProgress.set([progress, status]);
}
const setP4WebinterfaceProgress = (progress: number, status: TargetUpdateStatus = TargetUpdateStatus.NO_UPDATE) => {
$p4WebinterfaceProgress.set([progress, status]);
}
const setStm32FirmwareProgress = (progress: number, status: TargetUpdateStatus = TargetUpdateStatus.NO_UPDATE) => {
$stm32FirmwareProgress.set([progress, status]);
}
const setC6FirmwareProgress = (progress: number, status: TargetUpdateStatus = TargetUpdateStatus.NO_UPDATE) => {
$c6FirmwareProgress.set([progress, status]);
}
const setTotalUpdateStatus = (status: TotalUpdateStatus) => {
console.log(`🎯 Total update status change: ${$totalUpdateStatus.get()} → ${status}`);
$totalUpdateStatus.set(status);
}
const startUpdate = () => {
console.log(`🚀 Starting update process`);
setTotalUpdateStatus(TotalUpdateStatus.STARTED);
}
const setCurrentUpdateTargetIndex = (index: number) => {
$currentUpdateTargetIndex.set(index);
}
const setUpdateErrorMessages = (errorMessages: { [key: number]: string }) => {
$updateErrorMessages.set(errorMessages);
}
const setUpdateState = (state: 'initial' | 'updating' | 'success' | 'error' | 'restarting') => {
console.log(`🎯 State change: ${$updateState.get()} → ${state}`);
$updateState.set(state);
// Synchronize with total update status
switch (state) {
case 'initial':
setTotalUpdateStatus(TotalUpdateStatus.INITIAL);
break;
case 'updating':
setTotalUpdateStatus(TotalUpdateStatus.UPDATING);
break;
case 'success':
setTotalUpdateStatus(TotalUpdateStatus.SUCCESS);
break;
case 'error':
setTotalUpdateStatus(TotalUpdateStatus.ERROR);
break;
case 'restarting':
setTotalUpdateStatus(TotalUpdateStatus.UPDATING);
break;
}
}
const setUpdaterModalOpen = (open: boolean) => {
console.log(`🚀 Setting updater modal: ${open ? 'OPEN' : 'CLOSED'}`);
$isUpdaterModalOpen.set(open);
}
// reset all states to offiline states, when websocket is disconnected
const setWebSocketConnectionStatus = (status: string) => {
$wsConnectionStatus.set(status);
// set states to offline states! But do not reset calibration table/update states etc.
if (status === "Disconnected") {
$modulationInput.set(0);
$inputVoltage.set(0);
$driverTemp.set(0);
$counter.set(0);
$ref.set(0);
$dcdcState.set(0);
$dcdc_errorStatus.set('--');
$modulationInputMethod.set('--');
$gpioState.set({
4: false,
5: false,
6: false,
7: false,
8: false,
9: false,
});
/* DCDC */
$dcdc_state.set(0);
$dcdc_state_string.set('--');
$dcdc_state_desc_string.set('--');
$dcdc_error_flag.set(0);
$POWERGOOD.set(0);
$POWERGOOD_string.set('--');
$ref_value.set(0);
/* LASER */
$laser_state.set(2); // LASER_STOP
$laser_state_string.set('--');
$laser_state_desc_string.set('--');
$is_REF_calibrated.set(0);
$has_duty_lookup_table.set(0);
$laser_error_flag.set(0);
$laser_error_flag_string.set('--');
$LASERGOOD.set(0);
$LASERGOOD_string.set('--');
$laser_set_temp.set(0);
$laser_max_temp.set(0);
$interlock.set(0);
$set_current.set(0);
$max_current.set(0);
$threshold_current.set(0);
$current_reading.set(0);
$current_reading_string.set('--');
$laser_temp.set(0);
$laser_temp_string.set('--');
/* TEMPERATURE CHART DATA */
clearTemperatureData();
/* DRIVER */
$driver_temp.set(0);
$driver_temp_string.set('--');
$driver_max_temp.set(0);
$driver_max_temp_string.set('--');
$vcc_voltage.set(0);
$vcc_voltage_string.set('--');
$pd_voltage.set(0);
$pd_voltage_string.set('--');
}
}
const resetUpdateStatus = () => {
console.log("Resetting update status");
$updateState.set('initial');
$updateInProgress.set(false);
$updateErrorMessages.set({});
$currentUpdateTargetIndex.set(0);
$p4FirmwareProgress.set([0, TargetUpdateStatus.NO_UPDATE]);
$p4WebinterfaceProgress.set([0, TargetUpdateStatus.NO_UPDATE]);
$stm32FirmwareProgress.set([0, TargetUpdateStatus.NO_UPDATE]);
$c6FirmwareProgress.set([0, TargetUpdateStatus.NO_UPDATE]);
$availableUpdates.set([]);
$updateChangelog.set('');
$updatedTargets.set([false, false, false, false]);
}
const setErrorPopupOpen = (isOpen: boolean) => {
console.log(`❌ Setting error popup: ${isOpen ? 'OPEN' : 'CLOSED'}`);
$isErrorPopupOpen.set(isOpen);
}
const setSuccessPopupOpen = (isOpen: boolean) => {
console.log(`🎉 Setting success popup: ${isOpen ? 'OPEN' : 'CLOSED'}`);
$isSuccessPopupOpen.set(isOpen);
}
const setFreeInternalHeap = (heap: number) => {
$freeInternalHeap.set(heap);
}
const setUpdatedTargets = (targets: boolean[]) => {
console.log("🎯 Setting updated targets:", targets);
$updatedTargets.set(targets);
}
// WebSocket connection state — set by WebSocketProvider on open/close/error.
// Consumers can subscribe to react to connectivity (e.g. show a toast, gate UI actions).
export const $wsConnectionStatus = atom<string>("Disconnected");
// Centralised mutators so WebSocketProvider doesn't need to know about every atom.
// More setters can be added here as the app gains state beyond the WiFi config UI.
export const stateSetters = {
setWebSocketConnectionStatus,
setDCDCState,
setLaserState,
setPOWERGOOD,
setLASERGOOD,
setDcdcErrorStatus,
setLaserErrorStatus,
setREF_value,
setModulationInputMethod,
setREF_calibrated,
setHAS_DUTY_LOOKUP_TABLE,
setLaserSetTemp,
setLaserMaxTemp,
setInterlock,
setModulationInput,
setSetCurrent,
setMaxCurrent,
setThresholdCurrent,
setCurrentReading,
setLaserTemp,
setDriverTemp,
setDriverMaxTemp,
setVccVoltage,
setPdVoltage,
setCalibrationTableData,
setAvailableUpdates,
setUpdateChangelog,
setUpdateInProgress,
setP4FirmwareProgress,
setP4WebinterfaceProgress,
setStm32FirmwareProgress,
setC6FirmwareProgress,
setTotalUpdateStatus,
startUpdate,
setCurrentUpdateTargetIndex,
setUpdateErrorMessages,
setUpdateState,
setUpdaterModalOpen,
setSuccessPopupOpen,
setErrorPopupOpen,
resetUpdateStatus,
setFreeInternalHeap,
setUpdatedTargets
setWebSocketConnectionStatus: (status: string) => {
$wsConnectionStatus.set(status);
},
};
const send_uart_cmd_without_data = (command: number) => {
if ($websocket.get() !== null && $websocket.get()?.readyState === $websocket.get()?.OPEN) {
const buffer = new Uint8Array([0xAB, command]);
$websocket.get()?.send(buffer);
} else {
console.error("WebSocket ist nicht verbunden");
}
};
const send_uart_cmd_with_data = (command: number, data: number) => {
if ($websocket.get() !== null && $websocket.get()?.readyState === $websocket.get()?.OPEN) {
const buffer = new Uint8Array(6);
buffer[0] = 0xAA;
buffer[1] = command;
buffer[2] = (data >> 24) & 0xFF;
buffer[3] = (data >> 16) & 0xFF;
buffer[4] = (data >> 8) & 0xFF;
buffer[5] = data & 0xFF;
$websocket.get()?.send(buffer);
}
};
const send_network_cmd = (command: number) => {
if ($websocket.get() !== null && $websocket.get()?.readyState === $websocket.get()?.OPEN) {
const buffer = new Uint8Array([0xBA, command]);
$websocket.get()?.send(buffer);
} else {
console.error("WebSocket ist nicht verbunden");
}
};
const send_firmware_update_cmd = (command: number) => {
if ($websocket.get() !== null && $websocket.get()?.readyState === $websocket.get()?.OPEN) {
const buffer = new Uint8Array([0xBB, command]);
$websocket.get()?.send(buffer);
} else {
console.error("WebSocket ist nicht verbunden");
}
}
export const start_DCDC = () => {
send_uart_cmd_without_data(0x00);
}
export const stop_DCDC = () => {
send_uart_cmd_without_data(0x01);
}
export const reset_DCDC = () => {
send_uart_cmd_without_data(0x02);
}
export const run_Laser_External = () => {
send_uart_cmd_without_data(0x03);
}
export const run_Laser_Internal = () => {
send_uart_cmd_without_data(0x04);
}
export const stop_Laser = () => {
send_uart_cmd_without_data(0x05);
}
export const reset_Laser = () => {
send_uart_cmd_without_data(0x06);
}
export const start_REF_Calibration = () => {
send_uart_cmd_without_data(0x07);
}
export const start_Laser_Calibration = () => {
send_uart_cmd_without_data(0x08);
}
export const set_Laser_Threshold = (threshold: number) => {
send_uart_cmd_with_data(0x09, threshold);
}
export const set_Laser_MAX_Current = (current: number) => {
send_uart_cmd_with_data(0x0A, current);
}
export const set_Laser_Set_Current = (current: number) => {
send_uart_cmd_with_data(0x0B, current);
}
export const set_Laser_Set_Temp = (temp: number) => {
send_uart_cmd_with_data(0x0C, temp);
}
export const set_Laser_Max_Temp = (temp: number) => {
send_uart_cmd_with_data(0x0D, temp);
}
export const set_Driver_Max_Temp = (temp: number) => {
send_uart_cmd_with_data(0x0E, temp);
}
export const get_calibration_table = () => {
console.log("Getting calibration table");
send_uart_cmd_without_data(0x0F);
}
export const set_REF_value = (value: number) => {
send_uart_cmd_with_data(0x10, value);
}
export const set_Overcurrent_Protection_Value = (value: number) => {
send_uart_cmd_with_data(0x11, value);
}
// UPDATE SYSTEM COMMANDS
export const request_available_updates = () => {
console.log("Requesting available updates");
send_firmware_update_cmd(0x00);
}
export const start_firmware_update_p4_fw = () => {
console.log("Starting P4 Firmware update");
send_firmware_update_cmd(0x02);
}
export const start_firmware_update_p4_web = () => {
console.log("Starting P4 Webinterface update");
send_firmware_update_cmd(0x03);
}
export const start_firmware_update_stm32_fw = () => {
console.log("Starting STM32 Firmware update");
send_firmware_update_cmd(0x04);
}
export const start_firmware_update_c6_fw = () => {
console.log("Starting C6 Firmware update");
send_firmware_update_cmd(0x05);
}
export const request_update_progress = () => {
console.log("Requesting update progress");
}

View file

@ -1,45 +0,0 @@
import { useStore } from '@nanostores/preact';
import { $wsConnectionStatus } from './AppProviderStore';
export default function ConnectionStatusBadge() {
const status = useStore($wsConnectionStatus);
let colorClass = 'bg-gray-400 text-white';
if (status.startsWith('Connected')) colorClass = 'bg-green-500 text-white';
else if (status.startsWith('Connecting')) colorClass = 'bg-yellow-400 text-black';
else if (
status.startsWith('Error') ||
status.startsWith('Failed') ||
status.startsWith('Disconnected')
)
colorClass = 'bg-red-500 text-white';
// Passe die Breite ggf. an den längsten Status an!
const maxText = 'Connecting ...';
return (
<span
class={`badge ${colorClass} font-mono relative inline-block transition-all duration-200`}
style={{ width: '120px', minWidth: '120px', maxWidth: '120px', padding: 0 }}
>
{/* Unsichtbarer Platzhalter für feste Breite und Position */}
<span
class="opacity-0 select-none pointer-events-none"
style={{
display: 'inline-block',
width: '100%',
textAlign: 'left',
}}
>
{maxText}
</span>
{/* Sichtbarer Status absolut darüber */}
<span
class="absolute left-0 top-0 w-full h-full flex items-center"
style={{ justifyContent: 'left', paddingLeft: '0.75rem' /* ggf. anpassen */ }}
>
{status}
</span>
</span>
);
}

View file

@ -1,234 +0,0 @@
---
const pageTitle = "Laser Control";
---
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">{pageTitle}</h1>
<!-- Control Panel -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- DCDC Controls -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">DCDC Control</h2>
<!-- Mode Selection -->
<div class="join w-full mb-4">
<button class="btn join-item flex-1" id="dcdc-start">Start</button>
<button class="btn join-item flex-1 btn-success" id="dcdc-run">Run</button>
<button class="btn join-item flex-1 btn-error" id="dcdc-stop">Stop</button>
<button class="btn join-item flex-1 btn-error error" id="dcdc-error">Error</button>
</div>
<div class="divider"></div>
<div class="form-control">
<label class="label">
<span class="label-text">Target Voltage (V)</span>
<span class="label-text-alt" id="voltage-display">25V</span>
</label>
<input type="range" min="10" max="50" value="25" class="range range-primary" step="1" id="voltage-slider" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>10V</span>
<span>20V</span>
<span>30V</span>
<span>40V</span>
<span>50V</span>
</div>
</div>
<div class="flex gap-4 mt-6">
<div class="badge badge-success gap-2 p-4">
<span class="font-semibold">Input Voltage:</span> <span id="input-voltage">24.5V</span>
</div>
<div class="badge badge-error gap-2 p-4">
<span class="font-semibold">PD:</span> <span id="pd-voltage">3.2V</span>
</div>
</div>
<div class="mt-6 flex gap-2">
<button class="btn btn-primary" id="apply-dcdc">Apply Settings</button>
<button class="btn btn-outline" id="reset-dcdc">Reset</button>
</div>
</div>
</div>
<!-- Laser Control -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Laser Control</h2>
<div class="join w-full mb-4">
<button class="btn join-item flex-1" id="laser-start">Start</button>
<button class="btn join-item flex-1 btn-success" id="laser-run">Run</button>
<button class="btn join-item flex-1 btn-error" id="laser-stop">Stop</button>
<button class="btn join-item flex-1 btn-error error" id="laser-error">Error</button>
</div>
<div class="divider"></div>
<div class="flex justify-between items-center mb-4">
<span class="text-lg">Interlock Status:</span>
<div class="badge badge-success gap-2 p-4" id="interlock-status">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5V6.75a4.5 4.5 0 1 1 9 0v3.75M3.75 21.75h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H3.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<span>Unlocked</span>
</div>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text">Response Time</span>
</label>
<select class="select select-bordered w-full" id="response-time">
<option>Fast (High Bandwidth)</option>
<option selected>Normal</option>
<option>Slow (Low Noise)</option>
</select>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text">Stability Mode</span>
</label>
<div class="join w-full">
<button class="btn join-item flex-1 btn-active" id="stability-normal">Normal</button>
<button class="btn join-item flex-1" id="stability-high">High Stability</button>
<button class="btn join-item flex-1" id="stability-low">Low Noise</button>
</div>
</div>
<div class="alert alert-info mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>The laser mode is dependent on the DCDC status and safety settings.</span>
</div>
</div>
</div>
</div>
<!-- Safety Settings -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Safety Settings</h2>
<!-- Horizontal row of toggles -->
<div class="flex flex-wrap gap-4 mt-4">
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Interlock Check</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-interlock" />
</label>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Temperature Protection</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-temp" />
</label>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">OverCurrent Protection</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-overcurrent" />
</label>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Input Voltage Protection</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-voltage" />
</label>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Autostart DCDC</span>
<input type="checkbox" class="toggle toggle-error" id="toggle-autostart-dcdc" />
</label>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Autostart Laser</span>
<input type="checkbox" class="toggle toggle-error" id="toggle-autostart-laser" />
</label>
</div>
</div>
<div class="mt-4">
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Disabling safety features may damage your hardware or create hazardous conditions.</span>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Voltage slider interaction
const voltageSlider = document.getElementById('voltage-slider') as HTMLInputElement;
const voltageDisplay = document.getElementById('voltage-display');
if (voltageSlider && voltageDisplay) {
voltageSlider.addEventListener('input', function() {
voltageDisplay.textContent = this.value + 'V';
});
}
// DCDC button interactions
const dcdcButtons = {
start: document.getElementById('dcdc-start'),
run: document.getElementById('dcdc-run'),
stop: document.getElementById('dcdc-stop'),
error: document.getElementById('dcdc-error')
};
Object.entries(dcdcButtons).forEach(([action, btn]) => {
if (btn) {
btn.addEventListener('click', function() {
console.log('DCDC:', action);
// Send WebSocket command
});
}
});
// Laser button interactions
const laserButtons = {
start: document.getElementById('laser-start'),
run: document.getElementById('laser-run'),
stop: document.getElementById('laser-stop'),
error: document.getElementById('laser-error')
};
Object.entries(laserButtons).forEach(([action, btn]) => {
if (btn) {
btn.addEventListener('click', function() {
console.log('Laser:', action);
// Send WebSocket command
});
}
});
// Safety toggle interactions
const toggles = document.querySelectorAll('.toggle');
toggles.forEach(toggle => {
toggle.addEventListener('change', () => {
updateToggleClass(toggle as HTMLInputElement);
});
updateToggleClass(toggle as HTMLInputElement);
});
function updateToggleClass(toggle: HTMLInputElement) {
if (toggle.checked) {
toggle.classList.remove('toggle-error');
toggle.classList.add('toggle-success');
} else {
toggle.classList.remove('toggle-success');
toggle.classList.add('toggle-error');
}
}
});
</script>

View file

@ -1,549 +0,0 @@
---
import {
$wsConnectionStatus,
$dcdc_state_string,
$dcdc_state_desc_string,
$laser_state_string,
$laser_state_desc_string,
$laser_temp,
$laser_temp_string,
$driver_temp,
$driver_temp_string,
$driver_max_temp,
$driver_max_temp_string,
$temperature_chart_data,
addTemperatureDataPoint,
} from "./AppProviderStore";
const pageTitle = "Dashboard";
---
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">{pageTitle}</h1>
<!-- Status Cards - Static HTML that gets updated via WebSocket -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 bg-base-100">
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200">
<div class="stat-title">DCDC State</div>
<div class="stat-value" id="dcdc-state-value">
{$dcdc_state_string.get()}
</div>
<div class="stat-desc flex items-center">
<span class="text-info mr-1" id="dcdc-state-icon">● </span>
<span id="dcdc-state-text">{$dcdc_state_desc_string.get()}</span>
</div>
</div>
</div>
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200">
<div class="stat-title">Laser State</div>
<div class="stat-value" id="laser-state-value">
{$laser_state_string.get()}
</div>
<div class="stat-desc flex items-center">
<span class="text-error mr-1" id="laser-state-icon">●</span>
<span id="laser-state-text">{$laser_state_desc_string.get()}</span>
</div>
</div>
</div>
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200">
<div class="stat-title">Driver Temperature</div>
<div class="stat-value" id="driver-temp-value1">{$driver_temp_string.get()}</div>
<div class="stat-desc flex items-center">
<span class="text-info mr-1" id="driver-max-temp-icon">●</span>
<span id="driver-max-temp-text">Maximal allowed: {$driver_max_temp_string.get()}</span>
</div>
</div>
</div>
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200">
<div class="stat-title">WebSocket</div>
<div class="stat-value" id="ws-status-value">
{$wsConnectionStatus.get()}
</div>
<div class="stat-desc flex items-center" id="ws-status-desc">
<span class="text-error mr-1" id="ws-status-icon">●</span>
Connection status
</div>
</div>
</div>
</div>
<!-- Quick Actions & Controls - Interactive Parts -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- DCDC Controls -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">DCDC Control</h2>
<div class="flex flex-col gap-2">
<button class="btn btn-primary" id="dcdc-start-btn">Start DCDC</button
>
<button class="btn btn-warning" id="dcdc-stop-btn">Stop DCDC</button>
<button class="btn btn-outline" id="dcdc-reset-btn">Reset DCDC</button
>
</div>
<div class="mt-2">
<span class="text-sm"
>Power Good: <span id="power-good-status" class="font-bold">--</span
></span
>
</div>
</div>
</div>
<!-- Laser Controls -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Laser Control</h2>
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-3">
<button class="btn btn-primary flex-1 rounded-lg shadow-md transition hover:scale-105" id="ref-calibrate-btn">Calibrate REF</button>
<button class="btn btn-primary flex-1 rounded-lg shadow-md transition hover:scale-105" id="laser-calibrate-btn">Calibrate Laser</button>
</div>
<div class="flex flex-row gap-3">
<button
class="btn btn-success flex-1 rounded-lg shadow-md transition hover:scale-105"
id="laser-start-external-btn">Start Laser External</button
>
<button
class="btn btn-success flex-1 rounded-lg shadow-md transition hover:scale-105"
id="laser-start-internal-btn">Start Laser Internal</button
>
</div>
<button class="btn btn-error" id="laser-stop-btn">Stop Laser</button>
<button class="btn btn-outline" id="laser-reset-btn"
>Reset Laser</button
>
</div>
<div class="mt-2">
<span class="text-sm"
>Laser Good: <span id="laser-good-status" class="font-bold">--</span
></span
>
</div>
</div>
</div>
<!-- System Status -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body bg-base-100 rounded-lg">
<h2 class="card-title">System Status</h2>
<div class="space-y-2">
<div class="flex justify-between">
<span>Driver Temp:</span>
<span id="driver-temp-value2" class="font-mono">--°C</span>
</div>
<div class="flex justify-between">
<span>VCC Voltage:</span>
<span id="vcc-voltage-value" class="font-mono">--V</span>
</div>
<div class="flex justify-between">
<span>PD Voltage:</span>
<span id="pd-voltage-value" class="font-mono">--V</span>
</div>
<div class="flex justify-between">
<span>Laser Current:</span>
<span id="laser-current-value" class="font-mono">--mA</span>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-100 rounded-lg relative">
<h2 class="card-title">Calibration Table</h2>
<!-- Chart container with placeholder -->
<div class="flex-1 min-h-[300px] max-h-[500px] relative">
<!-- Placeholder when no data -->
<div id="chart-placeholder" class="flex items-center justify-center h-full bg-base-200 rounded-lg">
<div class="text-center">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chart-line mx-auto"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l16 0" /><path d="M4 15l4 -6l4 2l4 -5l4 4" /></svg>
<div class="text-lg font-semibold">No Calibration Data</div>
<div class="text-sm text-base-content/70">Click the button below to request data from device</div>
</div>
</div>
<!-- Chart canvas (hidden initially) -->
<canvas id="calibration-table-canvas" class="hidden w-full h-full"></canvas>
</div>
<!-- Small button positioned bottom right -->
<button class="btn btn-primary btn-xs absolute bottom-2 right-2" id="calibration-table-btn">
Request Calibration Table
</button>
</div>
</div>
<!-- Temperature Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-100 rounded-lg relative">
<h2 class="card-title">Temperature Monitor</h2>
<!-- Chart container -->
<div class="flex-1 min-h-[300px] max-h-[500px] relative">
<!-- Chart canvas -->
<canvas id="temperature-chart-canvas" class="w-full h-full"></canvas>
</div>
<!-- Status indicators -->
<div class="flex justify-between items-center mt-2 text-sm">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<div class="w-3 h-3 bg-blue-500 rounded"></div>
<span>Driver Temp: <span id="driver-temp-current" class="font-mono">--°C</span></span>
</div>
<div class="flex items-center gap-1">
<div class="w-3 h-3 bg-red-500 rounded"></div>
<span>Laser Temp: <span id="laser-temp-current" class="font-mono">--°C</span></span>
</div>
</div>
<div class="text-xs text-base-content/70">
Last 5 minutes
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Hydrate only the interactive parts -->
<script>
import {
$wsConnectionStatus,
$dcdc_state_string,
$dcdc_state_desc_string,
$laser_state_string,
$laser_state_desc_string,
start_DCDC,
$laser_temp,
$laser_temp_string,
$driver_temp,
$driver_temp_string,
$driver_max_temp,
$driver_max_temp_string,
$temperature_chart_data,
addTemperatureDataPoint,
$vcc_voltage,
$vcc_voltage_string,
$pd_voltage,
$pd_voltage_string,
$current_reading,
$current_reading_string,
$POWERGOOD_string,
$LASERGOOD_string,
stop_DCDC,
reset_DCDC,
run_Laser_External,
run_Laser_Internal,
stop_Laser,
reset_Laser,
get_calibration_table,
$calibration_table_data,
start_REF_Calibration,
start_Laser_Calibration,
} from "./AppProviderStore";
import { Chart, type ChartItem } from "chart.js/auto";
// subscribe to the wsConnectionStatus store and update the value of the ws-status-value element
$wsConnectionStatus.subscribe((value) => {
const el = document.getElementById("ws-status-value");
const elIcon = document.getElementById("ws-status-icon");
// overwrite the value of the ws-status-value element
if (el) el.textContent = value;
// overwrite the icon color of the ws-status-icon element
if (elIcon && value === "Connected") {
elIcon.classList.remove("text-error");
elIcon.classList.add("text-success");
} else if (elIcon && value === "Disconnected") {
elIcon.classList.remove("text-success");
elIcon.classList.add("text-error");
} else if (elIcon && value === "Error") {
elIcon.classList.remove("text-success");
elIcon.classList.add("text-error");
} else if (elIcon && value === "Failed") {
elIcon.classList.remove("text-success");
elIcon.classList.add("text-error");
} else if (elIcon && value === "Connecting") {
elIcon.classList.remove("text-error");
elIcon.classList.add("text-warning");
}
});
// subscribe to the dcdcStateString store and update the value of the dcdc-state-value element
$dcdc_state_string.subscribe((value) => {
const el = document.getElementById("dcdc-state-value");
console.log("dcdcStateString", value);
if (el) el.textContent = value;
});
// subscribe to the laserStateString store and update the value of the laser-state-value element
$laser_state_string.subscribe((value) => {
const el = document.getElementById("laser-state-value");
if (el) el.textContent = value;
});
$driver_temp.subscribe((value) => {
const el1 = document.getElementById("driver-temp-value1");
const el2 = document.getElementById("driver-temp-value2");
const elMax = document.getElementById("driver-max-temp-text");
if (el1) el1.textContent = $driver_temp_string.get();
if (el2) el2.textContent = $driver_temp_string.get();
});
$driver_max_temp_string.subscribe((value) => {
const elMax = document.getElementById("driver-max-temp-text");
if (elMax) elMax.textContent = "Maximal allowed: " + $driver_max_temp_string.get();
});
// Temperature chart subscriptions
$driver_temp.subscribe((value) => {
const el = document.getElementById("driver-temp-current");
if (el) el.textContent = $driver_temp_string.get();
// Add data point to temperature chart using store function
addTemperatureDataPoint($driver_temp.get(), $laser_temp.get());
});
$laser_temp.subscribe((value) => {
const el = document.getElementById("laser-temp-current");
if (el) el.textContent = $laser_temp_string.get();
// Temperature data will be added when driver temp updates to avoid duplicate entries
});
$vcc_voltage.subscribe((value) => {
const el = document.getElementById("vcc-voltage-value");
if (el) el.textContent = $vcc_voltage_string.get();
});
$pd_voltage_string.subscribe((value) => {
const el = document.getElementById("pd-voltage-value");
if (el) el.textContent = $pd_voltage_string.get();
});
$current_reading_string.subscribe((value) => {
const el = document.getElementById("laser-current-value");
if (el) el.textContent = $current_reading_string.get();
});
$POWERGOOD_string.subscribe((value) => {
const el = document.getElementById("power-good-status");
if (el) el.textContent = $POWERGOOD_string.get();
});
$LASERGOOD_string.subscribe((value) => {
const el = document.getElementById("laser-good-status");
if (el) el.textContent = $LASERGOOD_string.get();
});
$dcdc_state_desc_string.subscribe((value) => {
const el = document.getElementById("dcdc-state-text");
if (el) el.textContent = $dcdc_state_desc_string.get();
});
$laser_state_desc_string.subscribe((value) => {
const el = document.getElementById("laser-state-text");
if (el) el.textContent = $laser_state_desc_string.get();
});
// Button click handlers
document
.getElementById("dcdc-start-btn")
?.addEventListener("click", start_DCDC);
document
.getElementById("dcdc-stop-btn")
?.addEventListener("click", stop_DCDC);
document
.getElementById("dcdc-reset-btn")
?.addEventListener("click", reset_DCDC);
document
.getElementById("laser-start-external-btn")
?.addEventListener("click", run_Laser_External);
document
.getElementById("laser-start-internal-btn")
?.addEventListener("click", run_Laser_Internal);
document
.getElementById("laser-stop-btn")
?.addEventListener("click", stop_Laser);
document
.getElementById("laser-reset-btn")
?.addEventListener("click", reset_Laser);
document.getElementById("ref-calibrate-btn")?.addEventListener("click", start_REF_Calibration);
document.getElementById("laser-calibrate-btn")?.addEventListener("click", start_Laser_Calibration);
// Chart management
let chart: Chart | null = null;
const canvas = document.getElementById("calibration-table-canvas");
const placeholder = document.getElementById("chart-placeholder");
function showPlaceholder() {
if (placeholder) placeholder.classList.remove("hidden");
if (canvas) canvas.classList.add("hidden");
if (chart) {
chart.destroy();
chart = null;
}
}
function showChart() {
if (placeholder) placeholder.classList.add("hidden");
if (canvas) canvas.classList.remove("hidden");
}
function createChart(data: any) {
if (chart) {
chart.destroy();
}
chart = new Chart(canvas as ChartItem, {
type: "line",
data: {
labels: [500,1000,1500,2000,2500,3000,3500],
datasets: [
{
label: "Calibration Curve",
data: data,
fill: false,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Disable animations to prevent text stretching during resize
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Duty Cycle'
}
},
y: {
display: true,
title: {
display: true,
text: 'Current'
},
min:0,
max:6000,
ticks: {
stepSize: 1000
}
}
}
},
});
}
// create chart when calibration table data is available
$calibration_table_data.subscribe((value) => {
console.log("Calibration table data updated");
createChart(value.values);
showChart();
});
// Request calibration data button
document.getElementById("calibration-table-btn")?.addEventListener("click", () => {
// reuest table
get_calibration_table();
});
// Temperature Chart Management
let temperatureChart: Chart | null = null;
const temperatureCanvas = document.getElementById("temperature-chart-canvas");
function createTemperatureChart() {
if (temperatureChart) {
temperatureChart.destroy();
}
const chartData = $temperature_chart_data.get();
temperatureChart = new Chart(temperatureCanvas as ChartItem, {
type: "line",
data: {
labels: chartData.timestamps,
datasets: [
{
label: "Driver Temperature",
data: chartData.driverTemps,
borderColor: "#3B82F6", // blue-500
backgroundColor: "#3B82F680",
fill: false,
tension: 0.1,
pointRadius: 0,
pointHoverRadius: 5,
},
{
label: "Laser Temperature",
data: chartData.laserTemps,
borderColor: "#EF4444", // red-500
backgroundColor: "#EF444480",
fill: false,
tension: 0.1,
pointRadius: 0,
pointHoverRadius: 5,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Disable animations for real-time performance
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Time'
},
ticks: {
maxTicksLimit: 10 // Show only every nth label to avoid crowding
}
},
y: {
display: true,
title: {
display: true,
text: 'Temperature (°C)'
},
min: 0,
max: 100,
ticks: {
stepSize: 10
}
}
},
plugins: {
legend: {
display: true,
position: 'top' as const
}
},
interaction: {
intersect: false,
mode: 'index' as const
}
},
});
}
// Initialize temperature chart
createTemperatureChart();
// Subscribe to temperature chart data updates
$temperature_chart_data.subscribe((chartData) => {
if (temperatureChart) {
temperatureChart.data.labels = chartData.timestamps;
temperatureChart.data.datasets[0].data = chartData.driverTemps;
temperatureChart.data.datasets[1].data = chartData.laserTemps;
temperatureChart.update('none'); // No animation for real-time
}
});
// Initialize with placeholder
showPlaceholder();
</script>

View file

@ -1,283 +0,0 @@
---
const pageTitle = "DCDC Control";
---
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">{pageTitle}</h1>
<!-- Mode Control -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Mode Control</h2>
<div class="join w-full">
<button class="btn join-item flex-1 btn-primary" id="dcdc-start">Start</button>
<button class="btn join-item flex-1 btn-success" id="dcdc-run">Run</button>
<button class="btn join-item flex-1 btn-error" id="dcdc-stop">Stop</button>
<button class="btn join-item flex-1 btn-error error-striped" id="dcdc-error">Error</button>
</div>
</div>
</div>
<!-- Voltage Control & Monitoring -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Voltage Control</h2>
<div class="form-control">
<label class="label">
<span class="label-text">Target Output Voltage</span>
<span class="label-text-alt" id="voltage-display">25V</span>
</label>
<input type="range" min="10" max="50" value="25" class="range range-primary" step="0.1" id="voltage-slider" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>10V</span>
<span>20V</span>
<span>30V</span>
<span>40V</span>
<span>50V</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-6">
<div class="stat">
<div class="stat-title">Input Voltage</div>
<div class="stat-value text-sm" id="input-voltage">24.5V</div>
<div class="stat-desc">Range: 18-28V</div>
</div>
<div class="stat">
<div class="stat-title">Output Voltage</div>
<div class="stat-value text-sm" id="output-voltage">25.0V</div>
<div class="stat-desc" id="voltage-accuracy">±0.1V</div>
</div>
</div>
<div class="mt-4">
<button class="btn btn-primary mr-2" id="apply-voltage">Apply</button>
<button class="btn btn-outline" id="reset-voltage">Reset</button>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Current & Power</h2>
<div class="grid grid-cols-2 gap-4">
<div class="stat">
<div class="stat-title">Current</div>
<div class="stat-value text-sm" id="output-current">2.1A</div>
<div class="stat-desc">Max: 5.0A</div>
</div>
<div class="stat">
<div class="stat-title">Power</div>
<div class="stat-value text-sm" id="output-power">52.5W</div>
<div class="stat-desc">Max: 150W</div>
</div>
</div>
<div class="mt-4">
<div class="flex justify-between text-sm mb-1">
<span>Efficiency</span>
<span id="efficiency">92.5%</span>
</div>
<progress class="progress progress-success w-full" value="92.5" max="100" id="efficiency-progress"></progress>
</div>
<div class="mt-4">
<div class="flex justify-between text-sm mb-1">
<span>Temperature</span>
<span id="dcdc-temp">45.2°C</span>
</div>
<progress class="progress progress-warning w-full" value="45.2" max="85" id="temp-progress"></progress>
<div class="text-xs text-gray-500 mt-1">Warning at 85°C</div>
</div>
</div>
</div>
</div>
<!-- Protection Settings -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Protection Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
<div class="form-control">
<label class="label">
<span class="label-text">Over-Current Limit (A)</span>
</label>
<input type="range" min="1" max="6" value="5" step="0.1" class="range range-error" id="overcurrent-limit" />
<div class="w-full flex justify-between text-xs px-2">
<span>1A</span>
<span>3A</span>
<span>6A</span>
</div>
<div class="label">
<span class="label-text-alt" id="overcurrent-display">5.0A</span>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Over-Temperature (°C)</span>
</label>
<input type="range" min="60" max="100" value="85" step="1" class="range range-error" id="overtemp-limit" />
<div class="w-full flex justify-between text-xs px-2">
<span>60°C</span>
<span>80°C</span>
<span>100°C</span>
</div>
<div class="label">
<span class="label-text-alt" id="overtemp-display">85°C</span>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Under-Voltage (V)</span>
</label>
<input type="range" min="15" max="22" value="18" step="0.1" class="range range-warning" id="undervoltage-limit" />
<div class="w-full flex justify-between text-xs px-2">
<span>15V</span>
<span>18V</span>
<span>22V</span>
</div>
<div class="label">
<span class="label-text-alt" id="undervoltage-display">18.0V</span>
</div>
</div>
</div>
<div class="flex flex-wrap gap-4 mt-6">
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Auto-Start on Power</span>
<input type="checkbox" class="toggle toggle-primary" id="toggle-autostart" />
</label>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Soft-Start Enable</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-softstart" />
</label>
</div>
</div>
</div>
</div>
<!-- Status & Diagnostics -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Status & Diagnostics</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<h3 class="font-semibold mb-2">Operating Status</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span>PWM Frequency:</span>
<span id="pwm-freq">100kHz</span>
</div>
<div class="flex justify-between">
<span>Duty Cycle:</span>
<span id="duty-cycle">65.2%</span>
</div>
<div class="flex justify-between">
<span>Switching Losses:</span>
<span id="switching-losses">2.1W</span>
</div>
<div class="flex justify-between">
<span>Uptime:</span>
<span id="dcdc-uptime">2h 45m</span>
</div>
</div>
</div>
<div>
<h3 class="font-semibold mb-2">Error Log</h3>
<div class="bg-base-200 p-3 rounded-lg h-32 overflow-y-auto" id="error-log">
<div class="text-sm space-y-1">
<div class="text-success">14:32 - System started</div>
<div class="text-info">14:33 - Voltage ramped to 25V</div>
<div class="text-warning">14:35 - Temp spike to 60°C</div>
<div class="text-success">14:36 - Normal operation</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.error-striped {
background-image: repeating-linear-gradient(45deg, hsl(var(--er)) 0, hsl(var(--er)) 10px, hsl(var(--er) / 0.8) 10px, hsl(var(--er) / 0.8) 20px);
animation: error-blink 0.25s linear infinite;
}
@keyframes error-blink {
0% { opacity: 0.7; }
50% { opacity: 1; }
100% { opacity: 0.7; }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Voltage slider
const voltageSlider = document.getElementById('voltage-slider') as HTMLInputElement;
const voltageDisplay = document.getElementById('voltage-display');
if (voltageSlider && voltageDisplay) {
voltageSlider.addEventListener('input', function() {
voltageDisplay.textContent = parseFloat(this.value).toFixed(1) + 'V';
});
}
// Protection limit sliders
const sliders = [
{ id: 'overcurrent-limit', display: 'overcurrent-display', unit: 'A', decimals: 1 },
{ id: 'overtemp-limit', display: 'overtemp-display', unit: '°C', decimals: 0 },
{ id: 'undervoltage-limit', display: 'undervoltage-display', unit: 'V', decimals: 1 }
];
sliders.forEach(slider => {
const element = document.getElementById(slider.id) as HTMLInputElement;
const display = document.getElementById(slider.display);
if (element && display) {
element.addEventListener('input', function() {
const value = parseFloat(this.value).toFixed(slider.decimals);
display.textContent = value + slider.unit;
});
}
});
// DCDC mode buttons
const modeButtons = ['dcdc-start', 'dcdc-run', 'dcdc-stop', 'dcdc-error'];
modeButtons.forEach(buttonId => {
const button = document.getElementById(buttonId);
if (button) {
button.addEventListener('click', function() {
console.log('DCDC mode:', buttonId.replace('dcdc-', ''));
// Send WebSocket command
});
}
});
// Function to update DCDC values (called from WebSocket)
(window as any).updateDcdcValues = function(data: any) {
const fields = [
'input-voltage', 'output-voltage', 'output-current', 'output-power',
'efficiency', 'dcdc-temp', 'pwm-freq', 'duty-cycle', 'switching-losses', 'dcdc-uptime'
];
fields.forEach(field => {
const element = document.getElementById(field);
if (element && data[field.replace('-', '_')]) {
element.textContent = data[field.replace('-', '_')];
}
});
};
});
</script>

View file

@ -1,165 +0,0 @@
import { useState, useEffect } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import { $wsConnectionStatus, $updateState } from './AppProviderStore';
import UpdaterModal from './UpdaterModal';
import UpdateSuccessPopup from './UpdateSuccessPopup';
import UpdateErrorPopup from './UpdateErrorPopup';
import { $isUpdaterModalOpen, $isSuccessPopupOpen, $isErrorPopupOpen, stateSetters } from './AppProviderStore';
// Extend window interface
declare global {
interface Window {
openStatusModal?: () => void;
openFirmwareModal?: () => void;
openUpdaterModal?: () => void;
}
}
export default function GlobalModals() {
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);
const [isFirmwareModalOpen, setIsFirmwareModalOpen] = useState(false);
const updateState = useStore($updateState);
const isUpdaterModalOpen = useStore($isUpdaterModalOpen);
const isSuccessPopupOpen = useStore($isSuccessPopupOpen);
const isErrorPopupOpen = useStore($isErrorPopupOpen);
// Auto-open success/error popups when update state changes
useEffect(() => {
console.log("🎭 GlobalModals useEffect triggered:", {
updateState,
isSuccessPopupOpen,
isErrorPopupOpen,
isUpdaterModalOpen
});
if (updateState === 'success' && !isSuccessPopupOpen) {
console.log("🟢 Opening SUCCESS popup - update completed");
stateSetters.setSuccessPopupOpen(true);
} else if (updateState === 'restarting') {
console.log("🔄 RESTARTING state - ensuring SUCCESS popup stays open for countdown");
// Ensure success popup is open for restarting state (it will show restart UI)
if (!isSuccessPopupOpen) {
console.log("🔄 Opening SUCCESS popup for RESTARTING state - will show countdown");
stateSetters.setSuccessPopupOpen(true);
} else {
console.log("🔄 SUCCESS popup already open - countdown should be visible");
}
} else if (updateState === 'error' && !isErrorPopupOpen) {
console.log("❌ Opening ERROR popup - update failed");
stateSetters.setErrorPopupOpen(true);
} else {
console.log("🎭 No modal action needed - conditions not met");
}
}, [updateState, isSuccessPopupOpen, isErrorPopupOpen]);
// Listen for global modal events
if (typeof window !== 'undefined') {
window.openStatusModal = () => setIsStatusModalOpen(true);
window.openFirmwareModal = () => setIsFirmwareModalOpen(true);
window.openUpdaterModal = () => {
if (!$isUpdaterModalOpen.get()) {
stateSetters.setUpdaterModalOpen(true);
}
};
}
return (
<>
{/* Status Modal */}
{isStatusModalOpen && (
<dialog class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">System Status</h3>
<div class="overflow-x-auto">
<table class="table table-zebra">
<tbody>
<tr>
<td class="font-bold">WebSocket Status</td>
<td>{useStore($wsConnectionStatus)}</td>
</tr>
<tr>
<td class="font-bold">P4 Firmware Version</td>
<td>1.2.5</td>
</tr>
<tr>
<td class="font-bold">C6 Firmware Version</td>
<td>0.9.3</td>
</tr>
<tr>
<td class="font-bold">G4 Firmware Version</td>
<td>2.1.0</td>
</tr>
<tr>
<td class="font-bold">Activated Plugins</td>
<td>- none -</td>
</tr>
<tr>
<td class="font-bold">Serial Number</td>
<td>HydroBuddy-00001</td>
</tr>
<tr>
<td class="font-bold">Operating Hours</td>
<td>127.5 h</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex justify-between">
<button class="btn btn-primary">Customer Service</button>
<button class="btn" onClick={() => setIsStatusModalOpen(false)}>Close</button>
</div>
</div>
<div class="modal-backdrop" onClick={() => setIsStatusModalOpen(false)}></div>
</dialog>
)}
{/* Firmware Update Modal */}
{isFirmwareModalOpen && (
<dialog class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Firmware Update</h3>
<div class="flex flex-col items-center justify-center py-6">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-center">Checking for firmware updates...</p>
</div>
<div class="modal-action">
<button class="btn" onClick={() => setIsFirmwareModalOpen(false)}>Close</button>
</div>
</div>
<div class="modal-backdrop" onClick={() => setIsFirmwareModalOpen(false)}></div>
</dialog>
)}
{/* Updater Modal */}
<UpdaterModal
isOpen={isUpdaterModalOpen}
onClose={() => { stateSetters.setUpdaterModalOpen(false); }}
/>
{/* Update Success Popup */}
<UpdateSuccessPopup
isOpen={isSuccessPopupOpen}
onClose={() => {
stateSetters.setSuccessPopupOpen(false);
// Reset update state after popup is closed
stateSetters.setUpdateState('initial');
}}
/>
{/* Update Error Popup */}
<UpdateErrorPopup
isOpen={isErrorPopupOpen}
onClose={() => {
stateSetters.setErrorPopupOpen(false);
// Reset update state after popup is closed
stateSetters.setUpdateState('initial');
}}
/>
</>
);
}

View file

@ -0,0 +1,43 @@
<dialog id="hostname-changed-modal" class="modal modal-middle">
<div class="modal-box w-11/12 sm:w-5/12 max-w-2xl flex flex-col">
<h1 class="text-lg font-bold">Hostname Changed</h1>
<div class="divider divider-primary"></div>
<p class="text-sm text-base-content/70 mb-4">
The device hostname has been updated. You will be automatically redirected to the new address.
</p>
<div class="bg-base-200 rounded-lg p-4 mb-5">
<p class="text-xs text-base-content/50 uppercase tracking-wide mb-2">New address</p>
<a
id="hostname-changed-link"
href="#"
class="text-base font-mono font-bold text-primary hover:underline break-all"
target="_self"
>
http://<span id="hostname-changed-new-name">...</span>.local
</a>
</div>
<div class="mb-2">
<div class="flex justify-between items-baseline text-sm mb-2">
<span class="text-base-content/60">Redirecting automatically in</span>
<span class="font-mono font-bold text-base">
<span id="hostname-redirect-countdown">10</span>s
</span>
</div>
<progress
id="hostname-redirect-progress"
class="progress progress-primary w-full"
value="100"
max="100"
></progress>
</div>
<div class="modal-action mt-auto">
<a id="hostname-redirect-now-btn" href="#" class="btn btn-primary" target="_self">
Redirect Now
</a>
</div>
</div>
</dialog>

View file

@ -1,144 +0,0 @@
---
const pageTitle = "Logs";
---
<div class="flex flex-col gap-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">System Logs</h1>
<div class="join">
<button class="btn join-item">Export</button>
<button class="btn join-item btn-primary">Refresh</button>
</div>
</div>
<div class="card bg-base-200 shadow-lg mb-4">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title text-lg">Filters</h2>
<form class="filter grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Log Level</span>
</label>
<div class="join w-full">
<input class="btn join-item flex-1" type="radio" name="log-level" aria-label="All" checked />
<input class="btn join-item flex-1" type="radio" name="log-level" aria-label="Error" />
<input class="btn join-item flex-1" type="radio" name="log-level" aria-label="Warning" />
<input class="btn join-item flex-1" type="radio" name="log-level" aria-label="Info" />
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Date Range</span>
</label>
<select class="select select-bordered w-full">
<option selected>Today</option>
<option>Last 3 Days</option>
<option>Last Week</option>
<option>Last Month</option>
<option>Custom Range</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Search</span>
</label>
<div class="join w-full">
<input type="text" placeholder="Search logs..." class="input input-bordered join-item w-full" />
<button class="btn join-item">Search</button>
</div>
</div>
<div class="col-span-1 md:col-span-3">
<button type="reset" class="btn btn-sm btn-outline">Reset Filters</button>
</div>
</form>
</div>
</div>
<div class="card bg-base-200 shadow-lg">
<div class="card-body bg-base-200 rounded-lg">
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Timestamp</th>
<th>Level</th>
<th>Component</th>
<th>Message</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>2025-03-15 14:32:45</td>
<td><div class="badge badge-error">ERROR</div></td>
<td>Driver</td>
<td>Failed to initialize temperature sensor</td>
<td><button class="btn btn-xs btn-ghost">Details</button></td>
</tr>
<tr>
<td>2025-03-15 14:30:12</td>
<td><div class="badge badge-warning">WARN</div></td>
<td>Modulator</td>
<td>PWM frequency outside optimal range</td>
<td><button class="btn btn-xs btn-ghost">Details</button></td>
</tr>
<tr>
<td>2025-03-15 14:28:05</td>
<td><div class="badge badge-info">INFO</div></td>
<td>System</td>
<td>User authentication successful</td>
<td><button class="btn btn-xs btn-ghost">Details</button></td>
</tr>
<tr>
<td>2025-03-15 14:25:59</td>
<td><div class="badge">DEBUG</div></td>
<td>Control</td>
<td>PID controller parameters updated</td>
<td><button class="btn btn-xs btn-ghost">Details</button></td>
</tr>
<tr>
<td>2025-03-15 14:20:33</td>
<td><div class="badge badge-info">INFO</div></td>
<td>System</td>
<td>Laser driver initialization complete</td>
<td><button class="btn btn-xs btn-ghost">Details</button></td>
</tr>
<tr>
<td>2025-03-15 14:18:22</td>
<td><div class="badge badge-success">SUCCESS</div></td>
<td>Hardware</td>
<td>All hardware components detected</td>
<td><button class="btn btn-xs btn-ghost">Details</button></td>
</tr>
<tr>
<td>2025-03-15 14:15:07</td>
<td><div class="badge badge-info">INFO</div></td>
<td>System</td>
<td>System boot initiated</td>
<td><button class="btn btn-xs btn-ghost">Details</button></td>
</tr>
</tbody>
</table>
</div>
<div class="flex justify-between items-center mt-4">
<div>
<span class="text-sm">Showing 1-7 of 42 entries</span>
</div>
<div class="join">
<button class="join-item btn btn-sm">«</button>
<button class="join-item btn btn-sm btn-active">1</button>
<button class="join-item btn btn-sm">2</button>
<button class="join-item btn btn-sm">3</button>
<button class="join-item btn btn-sm">»</button>
</div>
</div>
</div>
</div>
</div>

View file

@ -1,103 +0,0 @@
import { useStore } from '@nanostores/preact';
import { $freeInternalHeap, $totalInternalHeap } from './AppProviderStore';
interface MemoryProgressBarProps {
showDetails?: boolean;
className?: string;
}
export default function MemoryProgressBar({ showDetails = true, className = "" }: MemoryProgressBarProps) {
const freeInternalHeap = useStore($freeInternalHeap);
const totalInternalHeap = useStore($totalInternalHeap);
// Calculate used and percentage
const usedInternalHeap = totalInternalHeap - freeInternalHeap;
const usagePercentage = totalInternalHeap > 0 ? (usedInternalHeap / totalInternalHeap) * 100 : 0;
const freePercentage = 100 - usagePercentage;
// Determine color based on usage
const getProgressColor = (percentage: number) => {
if (percentage < 50) return 'bg-green-500';
if (percentage < 75) return 'bg-yellow-500';
if (percentage < 90) return 'bg-orange-500';
return 'bg-red-500';
};
const getTextColor = (percentage: number) => {
if (percentage < 50) return 'text-green-600';
if (percentage < 75) return 'text-yellow-600';
if (percentage < 90) return 'text-orange-600';
return 'text-red-600';
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
// Create segments for the memory bar (like Linux htop)
const totalSegments = 20;
const usedSegments = Math.round((usagePercentage / 100) * totalSegments);
const getSegmentColor = (segmentIndex: number) => {
if (segmentIndex >= usedSegments) return 'bg-gray-300'; // Empty
const percentageAtSegment = (segmentIndex / totalSegments) * 100;
if (percentageAtSegment < 50) return 'bg-green-500';
if (percentageAtSegment < 75) return 'bg-yellow-500';
if (percentageAtSegment < 90) return 'bg-orange-500';
return 'bg-red-500';
};
return (
<div className={`space-y-2 ${className}`}>
{/* Linux-style Memory Bar with segments */}
<div className="flex items-center space-x-3">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-700">Internal Heap</span>
<div className="text-right">
<span className={`text-sm font-medium ${getTextColor(usagePercentage)}`}>
{usagePercentage.toFixed(1)}%
</span>
<div className="text-xs text-gray-600">
{formatBytes(usedInternalHeap)}/{formatBytes(totalInternalHeap)}
</div>
</div>
</div>
{/* Memory segments */}
<div className="flex space-x-0.5">
{Array.from({ length: totalSegments }, (_, i) => (
<div
key={i}
className={`h-3 flex-1 transition-colors duration-300 ${getSegmentColor(i)}`}
style={{ minWidth: '2px' }}
></div>
))}
</div>
</div>
</div>
{/* Memory Stats */}
{showDetails && (
<div className="grid grid-cols-3 gap-3 text-xs text-gray-600">
<div className="flex justify-between">
<span>Used:</span>
<span className="font-medium">{formatBytes(usedInternalHeap)}</span>
</div>
<div className="flex justify-between">
<span>Free:</span>
<span className="font-medium text-green-600">{formatBytes(freeInternalHeap)}</span>
</div>
<div className="flex justify-between">
<span>Total:</span>
<span className="font-medium">{formatBytes(totalInternalHeap)}</span>
</div>
</div>
)}
</div>
);
}

View file

@ -1,505 +0,0 @@
---
// This is server-side - runs at build time
const pageTitle = "Modulation";
---
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">Laser Modulation</h1>
<!-- Modulation Mode Selection -->
<div class="card bg-base-200 shadow">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Modulation Mode</h2>
<!-- Main Mode Tabs - Keep horizontal even on mobile -->
<div class="tabs tabs-boxed mb-4">
<button class="tab tab-active flex-1" id="internal-tab" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12l2 2 4-4"/>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"/>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"/>
<path d="M3 12h6m6 0h6"/>
</svg>
Internal
</button>
<button class="tab flex-1" id="external-tab" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 3v12"/>
<path d="M18 3v12"/>
<path d="M6 21l12-9-12-3z"/>
</svg>
External
</button>
</div>
<!-- Internal Modes - Stack on mobile -->
<div id="internal-modes" class="mb-4">
<h3 class="font-medium mb-3">Internal Modulation Modes</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
<button class="btn btn-active" id="cc-tab" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
Constant Current
</button>
<button class="btn" id="cp-tab" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2"/>
<path d="M12 20v2"/>
<path d="m4.93 4.93 1.41 1.41"/>
<path d="m17.66 17.66 1.41 1.41"/>
<path d="M2 12h2"/>
<path d="M20 12h2"/>
<path d="m6.34 17.66-1.41 1.41"/>
<path d="m19.07 4.93-1.41 1.41"/>
</svg>
Constant Power
</button>
<button class="btn" id="pattern-continuous-tab" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h6l4 8 4-16 4 8h6"/>
</svg>
Pattern Continuous
</button>
<button class="btn" id="pattern-triggered-tab" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 6l4-4 4 4"/>
<path d="M12 3v9"/>
<path d="M3 12h6l4 8 4-8h6"/>
</svg>
Pattern Triggered
</button>
</div>
</div>
<!-- External Modes - Stack on mobile -->
<div id="external-modes" class="mb-4 hidden">
<h3 class="font-medium mb-3">External Modulation Modes</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button class="btn btn-active" id="ttl-tab" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14"/>
<path d="M5 12v7"/>
<path d="M19 12v7"/>
<path d="M5 5v7"/>
<path d="M19 5v7"/>
</svg>
TTL
</button>
<button class="btn" id="mod2current-tab" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/>
<path d="M7 12l4-4 4 4 4-4"/>
</svg>
Mod2Current
</button>
<button class="btn" id="schmitt-trigger-tab" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h6l2-8 2 16 2-8h6"/>
</svg>
Schmitt Trigger
</button>
</div>
</div>
</div>
</div>
<!-- Mode Configuration Panels -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Constant Current Panel -->
<div id="cc-panel" class="card bg-base-200 shadow">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
Constant Current Mode
</h2>
<div class="form-control">
<label class="label">
<span class="label-text">Current (A)</span>
<span class="label-text-alt">5.0A</span>
</label>
<input type="range" min="0" max="10" value="5" step="0.1" class="range range-primary" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>0A</span>
<span>2.5A</span>
<span>5.0A</span>
<span>7.5A</span>
<span>10.0A</span>
</div>
</div>
<div class="form-control mt-4">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Enable Soft Start</span>
<input type="checkbox" class="toggle toggle-success" checked onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)" />
</label>
</div>
<div class="alert alert-info mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Constant Current mode provides stable current output regardless of load changes.</span>
</div>
</div>
</div>
<!-- Constant Power Panel -->
<div id="cp-panel" class="card bg-base-200 shadow hidden">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2"/>
<path d="M12 20v2"/>
<path d="m4.93 4.93 1.41 1.41"/>
<path d="m17.66 17.66 1.41 1.41"/>
<path d="M2 12h2"/>
<path d="M20 12h2"/>
<path d="m6.34 17.66-1.41 1.41"/>
<path d="m19.07 4.93-1.41 1.41"/>
</svg>
Constant Power Mode
</h2>
<div class="form-control">
<label class="label">
<span class="label-text">Power (W)</span>
<span class="label-text-alt">5.0W</span>
</label>
<input type="range" min="0" max="10" value="5" step="0.1" class="range range-secondary" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>0W</span>
<span>2.5W</span>
<span>5.0W</span>
<span>7.5W</span>
<span>10.0W</span>
</div>
</div>
<div class="form-control mt-4">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Enable Feedback Control</span>
<input type="checkbox" class="toggle toggle-success" checked onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)" />
</label>
</div>
<div class="alert alert-info mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Constant Power mode uses feedback from the photodiode to maintain stable output power.</span>
</div>
</div>
</div>
<!-- Pattern Continuous Panel -->
<div id="pattern-continuous-panel" class="card bg-base-200 shadow hidden">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h6l4 8 4-16 4 8h6"/>
</svg>
Pattern Continuous Mode
</h2>
<div class="bg-base-200 rounded-lg p-6">
<div class="flex items-center justify-center h-40">
<div class="text-center">
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
<p class="text-lg font-bold">Coming Soon</p>
<p class="text-sm mt-2">Pattern configuration interface under development</p>
</div>
</div>
</div>
</div>
</div>
<!-- Pattern Triggered Panel -->
<div id="pattern-triggered-panel" class="card bg-base-200 shadow-xl border-l-4 border-l-warning hidden">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title text-warning">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 6l4-4 4 4"/>
<path d="M12 3v9"/>
<path d="M3 12h6l4 8 4-8h6"/>
</svg>
Pattern Triggered Mode
</h2>
<div class="bg-warning/5 border border-warning/20 rounded-lg p-6">
<div class="flex items-center justify-center h-40">
<div class="text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-warning opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 6l4-4 4 4"/>
<path d="M12 3v9"/>
<path d="M3 12h6l4 8 4-8h6"/>
</svg>
<p class="text-lg font-bold">Coming Soon</p>
<p class="text-sm mt-2">Triggered pattern configuration interface under development</p>
</div>
</div>
</div>
</div>
</div>
<!-- TTL Panel -->
<div id="ttl-panel" class="card bg-base-200 shadow-xl border-l-4 border-l-info hidden">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title text-info">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14"/>
<path d="M5 12v7"/>
<path d="M19 12v7"/>
<path d="M5 5v7"/>
<path d="M19 5v7"/>
</svg>
TTL Mode
</h2>
<div class="bg-info/5 border border-info/20 rounded-lg p-6">
<div class="flex items-center justify-center h-40">
<div class="text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-info opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14"/>
<path d="M5 12v7"/>
<path d="M19 12v7"/>
<path d="M5 5v7"/>
<path d="M19 5v7"/>
</svg>
<p class="text-lg font-bold">Coming Soon</p>
<p class="text-sm mt-2">TTL interface configuration under development</p>
</div>
</div>
</div>
</div>
</div>
<!-- Mod2Current Panel -->
<div id="mod2current-panel" class="card bg-base-200 shadow-xl border-l-4 border-l-success hidden">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title text-success">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/>
<path d="M7 12l4-4 4 4 4-4"/>
</svg>
Mod2Current Mode
</h2>
<div class="bg-success/5 border border-success/20 rounded-lg p-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4">
<button class="btn btn-active" onclick="if('vibrate' in navigator) navigator.vibrate(15)">0-5V</button>
<button class="btn" onclick="if('vibrate' in navigator) navigator.vibrate(15)">0-10V</button>
<button class="btn" onclick="if('vibrate' in navigator) navigator.vibrate(15)">Custom</button>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Current Limit (A)</span>
<span class="label-text-alt">7.5A</span>
</label>
<input type="range" min="0" max="10" value="7.5" step="0.1" class="range range-success" />
</div>
</div>
<div class="alert alert-info mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Mod2Current converts an analog input voltage to a proportional current output.</span>
</div>
</div>
</div>
<!-- Schmitt Trigger Panel -->
<div id="schmitt-trigger-panel" class="card bg-base-200 shadow-xl border-l-4 border-l-error hidden">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title text-error">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h6l2-8 2 16 2-8h6"/>
</svg>
Schmitt Trigger Mode
</h2>
<div class="bg-error/5 border border-error/20 rounded-lg p-6">
<div class="flex items-center justify-center h-40">
<div class="text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-error opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h6l2-8 2 16 2-8h6"/>
</svg>
<p class="text-lg font-bold">Coming Soon</p>
<p class="text-sm mt-2">Schmitt Trigger configuration interface under development</p>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Parameters -->
<div class="card bg-base-200 shadow-xl border-l-4 border-l-primary">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"/>
<path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/>
<path d="M12 2v2"/>
<path d="M12 22v-2"/>
<path d="m17 20.66-1-1.73"/>
<path d="M11 10.27 7 3.34"/>
<path d="m20.66 17-1.73-1"/>
<path d="m3.34 7 1.73 1"/>
<path d="M14 12h8"/>
<path d="M2 12h2"/>
<path d="m20.66 7-1.73 1"/>
<path d="m3.34 17 1.73-1"/>
<path d="m17 3.34-1 1.73"/>
<path d="m11 13.73-4 6.93"/>
</svg>
Advanced Parameters
</h2>
<div class="form-control">
<label class="label">
<span class="label-text">Response Time</span>
</label>
<label class="select select-bordered w-full flex items-center">
<svg class="w-4 h-4 opacity-70 absolute left-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12,6 12,12 16,14"/>
</svg>
<select class="pl-10">
<option>Fast (High Bandwidth)</option>
<option selected>Normal</option>
<option>Slow (Low Noise)</option>
</select>
</label>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text">Stability Mode</span>
</label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button class="btn btn-active" onclick="if('vibrate' in navigator) navigator.vibrate(15)">Normal</button>
<button class="btn" onclick="if('vibrate' in navigator) navigator.vibrate(15)">High Stability</button>
<button class="btn" onclick="if('vibrate' in navigator) navigator.vibrate(15)">Low Noise</button>
</div>
</div>
<div class="flex flex-col sm:flex-row justify-end mt-4 gap-2">
<button class="btn btn-outline" onclick="if('vibrate' in navigator) navigator.vibrate(15)">Save Preset</button>
<button class="btn btn-primary" onclick="if('vibrate' in navigator) navigator.vibrate([100, 200, 100])">Apply</button>
</div>
</div>
</div>
</div>
</div>
<script>
// Toggle color management function
function updateToggleColor(toggle: HTMLInputElement) {
if (toggle.checked) {
toggle.classList.remove('toggle-error');
toggle.classList.add('toggle-success');
} else {
toggle.classList.remove('toggle-success');
toggle.classList.add('toggle-error');
}
}
document.addEventListener('DOMContentLoaded', function() {
// Initialize toggle colors
const toggles = document.querySelectorAll('.toggle');
toggles.forEach(toggle => {
updateToggleColor(toggle as HTMLInputElement);
});
// Tab switching logic
const internalTab = document.getElementById('internal-tab');
const externalTab = document.getElementById('external-tab');
const internalModes = document.getElementById('internal-modes');
const externalModes = document.getElementById('external-modes');
// Mode panels
const panels = {
'cc': document.getElementById('cc-panel'),
'cp': document.getElementById('cp-panel'),
'pattern-continuous': document.getElementById('pattern-continuous-panel'),
'pattern-triggered': document.getElementById('pattern-triggered-panel'),
'ttl': document.getElementById('ttl-panel'),
'mod2current': document.getElementById('mod2current-panel'),
'schmitt-trigger': document.getElementById('schmitt-trigger-panel')
};
// Mode buttons
const modeButtons = {
'cc': document.getElementById('cc-tab'),
'cp': document.getElementById('cp-tab'),
'pattern-continuous': document.getElementById('pattern-continuous-tab'),
'pattern-triggered': document.getElementById('pattern-triggered-tab'),
'ttl': document.getElementById('ttl-tab'),
'mod2current': document.getElementById('mod2current-tab'),
'schmitt-trigger': document.getElementById('schmitt-trigger-tab')
};
// Show/hide panels
function showPanel(panelId: string) {
Object.values(panels).forEach(panel => panel?.classList.add('hidden'));
panels[panelId as keyof typeof panels]?.classList.remove('hidden');
// Update button states
Object.values(modeButtons).forEach(btn => btn?.classList.remove('btn-active'));
modeButtons[panelId as keyof typeof modeButtons]?.classList.add('btn-active');
}
// Tab switching
if (internalTab && externalTab && internalModes && externalModes) {
internalTab.addEventListener('click', function() {
internalTab.classList.add('tab-active');
externalTab.classList.remove('tab-active');
internalModes.classList.remove('hidden');
externalModes.classList.add('hidden');
showPanel('cc'); // Show default internal mode
});
externalTab.addEventListener('click', function() {
externalTab.classList.add('tab-active');
internalTab.classList.remove('tab-active');
externalModes.classList.remove('hidden');
internalModes.classList.add('hidden');
showPanel('ttl'); // Show default external mode
});
}
// Mode button event listeners
Object.entries(modeButtons).forEach(([mode, button]) => {
if (button) {
button.addEventListener('click', () => showPanel(mode));
}
});
// Stability mode buttons
const stabilityButtons = document.querySelectorAll('.grid .btn');
stabilityButtons.forEach(btn => {
btn.addEventListener('click', function(this: HTMLElement) {
// Only update buttons in the same grid
const grid = this.closest('.grid');
if (grid) {
grid.querySelectorAll('.btn').forEach(b => b.classList.remove('btn-active'));
this.classList.add('btn-active');
}
});
});
// Initialize with Constant Current mode
showPanel('cc');
});
</script>

View file

@ -1,193 +0,0 @@
---
// This is server-side - runs at build time
const pageTitle = "Monitoring";
---
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">{pageTitle}</h1>
<!-- Real-time Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="card bg-base-200 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Temperature</h2>
<div class="flex items-center justify-center h-40">
<div class="text-center">
<div class="radial-progress text-primary" style="--value:65; --size:8rem;" id="temp-progress">65%</div>
<div class="mt-2" id="temp-reading">25.6°C</div>
</div>
</div>
<div class="flex justify-between text-sm mt-2">
<span>Min: <span id="temp-min">22.1°C</span></span>
<span>Max: <span id="temp-max">28.3°C</span></span>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Current</h2>
<div class="flex items-center justify-center h-40">
<div class="text-center">
<div class="radial-progress text-secondary" style="--value:42; --size:8rem;" id="current-progress">42%</div>
<div class="mt-2" id="current-reading">125mA</div>
</div>
</div>
<div class="flex justify-between text-sm mt-2">
<span>Min: <span id="current-min">0mA</span></span>
<span>Max: <span id="current-max">300mA</span></span>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Output Power</h2>
<div class="flex items-center justify-center h-40">
<div class="text-center">
<div class="radial-progress text-accent" style="--value:50; --size:8rem;" id="power-progress">50%</div>
<div class="mt-2" id="power-reading">500mW</div>
</div>
</div>
<div class="flex justify-between text-sm mt-2">
<span>Min: <span id="power-min">0mW</span></span>
<span>Max: <span id="power-max">1000mW</span></span>
</div>
</div>
</div>
</div>
<!-- System Health -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">System Health</h2>
<div class="overflow-x-auto">
<table class="table">
<tbody>
<tr>
<td>CPU Temperature</td>
<td class="w-full">
<div class="flex items-center gap-2">
<div class="w-full h-4 bg-base-300 rounded-lg relative">
<div class="absolute inset-0 flex justify-between px-1 text-[10px] text-base-content z-10">
<span>0°C</span>
<span>30°C</span>
<span>60°C</span>
<span>90°C</span>
<span>120°C</span>
</div>
<div class="absolute top-0 bottom-0 left-0 rounded-lg"
style="width: 35.25%; background: linear-gradient(90deg, #3b82f6 0%, #22c55e 37.5%, #f59e0b 75%, #ef4444 100%);" id="cpu-temp-bar">
</div>
</div>
<span id="cpu-temp">42.3°C</span>
</div>
</td>
</tr>
<tr>
<td>GaN Temperature</td>
<td class="w-full">
<div class="flex items-center gap-2">
<div class="w-full h-4 bg-base-300 rounded-lg relative">
<div class="absolute inset-0 flex justify-between px-1 text-[10px] text-base-content z-10">
<span>0°C</span>
<span>30°C</span>
<span>60°C</span>
<span>90°C</span>
<span>120°C</span>
</div>
<div class="absolute top-0 bottom-0 left-0 rounded-lg"
style="width: 54.8%; background: linear-gradient(90deg, #3b82f6 0%, #22c55e 37.5%, #f59e0b 75%, #ef4444 100%);" id="gan-temp-bar">
</div>
</div>
<span id="gan-temp">65.8°C</span>
</div>
</td>
</tr>
<tr>
<td>WiFi Signal Strength</td>
<td class="w-full">
<div class="flex items-center gap-2">
<div class="w-full h-4 bg-base-300 rounded-lg relative">
<div class="absolute inset-0 flex justify-between px-1 text-[10px] text-base-content z-10">
<span>-120dBm</span>
<span>-90dBm</span>
<span>-60dBm</span>
<span>-30dBm</span>
<span>0dBm</span>
</div>
<div class="absolute top-0 bottom-0 left-0 rounded-lg"
style="width: 65%; background: linear-gradient(90deg, #ef4444 0%, #f59e0b 40%, #22c55e 80%, #22c55e 100%);" id="wifi-signal-bar">
</div>
</div>
<span id="wifi-signal">-42dBm</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Historical Data -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="card bg-base-200 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Temperature History</h2>
<div class="flex items-center justify-center h-64 bg-base-200 rounded-box">
<p class="text-base-content opacity-50">Temperature Chart Placeholder</p>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Power Output History</h2>
<div class="flex items-center justify-center h-64 bg-base-200 rounded-box">
<p class="text-base-content opacity-50">Power Chart Placeholder</p>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Update radial progress values
function updateRadialProgress(elementId: string, value: number) {
const element = document.getElementById(elementId);
if (element) {
element.style.setProperty('--value', value.toString());
element.textContent = value + '%';
}
}
// Function to update monitoring values (called from WebSocket)
(window as any).updateMonitoringValues = function(data: any) {
if (data.temperature) {
const tempProgress = Math.round((data.temperature / 100) * 100);
updateRadialProgress('temp-progress', tempProgress);
const tempReading = document.getElementById('temp-reading');
if (tempReading) tempReading.textContent = data.temperature + '°C';
}
if (data.current) {
const currentProgress = Math.round((data.current / 300) * 100);
updateRadialProgress('current-progress', currentProgress);
const currentReading = document.getElementById('current-reading');
if (currentReading) currentReading.textContent = data.current + 'mA';
}
if (data.power) {
const powerProgress = Math.round((data.power / 1000) * 100);
updateRadialProgress('power-progress', powerProgress);
const powerReading = document.getElementById('power-reading');
if (powerReading) powerReading.textContent = data.power + 'mW';
}
};
});
</script>

View file

@ -0,0 +1,234 @@
import { atom } from "nanostores";
import { $websocket } from "./WebSocketAtom";
// Mirrors network_config_command_id in components/types_and_commands/inc/command_interface.h.
// UI only uses APPLY_CONFIG, SET_MDNS_HOSTNAME, and GET_WIFI_NETWORKS — the rest are legacy single-purpose
// commands that APPLY_CONFIG supersedes.
export enum NetworkManagerCommands {
NETWORK_CMD_SET_WIFI_STATION_MODE = 0, // legacy: configure WiFi Station mode
NETWORK_CMD_SET_WIFI_AP_MODE = 1, // legacy: configure WiFi Access Point mode
NETWORK_CMD_SET_STATIC_IP_STATION_MODE = 2, // legacy: set static IP for Station mode
NETWORK_CMD_SET_MDNS_HOSTNAME = 3, // Set MDNS hostname
NETWORK_CMD_GET_WIFI_NETWORKS = 4, // Scan and return available WiFi networks
NETWORK_CMD_APPLY_CONFIG = 5, // Apply full network configuration (mode + IPs + credentials)
NETWORK_CMD_UNKNOWN = 6, // Unknown Command
}
// Mirrors connection_mode_t in components/types_and_commands/inc/data_types.h.
// No ethernet on this board — only WiFi APSTA or WiFi station.
export enum ConnectionMode {
CONNECTION_MODE_UNKNOWN = 0, // Not yet configured; firmware defaults to APSTA on boot
CONNECTION_MODE_APSTA = 1, // WiFi APSTA (default before user configures STA)
CONNECTION_MODE_STA_DHCP = 2, // WiFi station, DHCP
CONNECTION_MODE_STA_STATIC = 3, // WiFi station, static IP
}
// Mirrors network_config_nvs in components/nvs_management/inc/nvs_management.h.
export type NetworkConfigNVS = {
Connection_Mode: ConnectionMode;
DHCP_Mode_Station: boolean; // true = DHCP, false = Static IP (DHCP off)
AP_Channel: number; // AP Channel, if AP is configured
IP_Address_Station: string; // actual Station IP Address
Gateway_IP_Address_Station: string; // actual Gateway IP Address for Station Mode
Netmask_Station: string; // actual Netmask for Station Mode
Static_Cfg_IP_Address_Station: string; // saved configuration static IP Address for Station Mode
Static_Cfg_Gateway_IP_Address_Station: string; // saved configuration Gateway IP Address for Station Mode
Static_Cfg_Netmask_Station: string; // saved configuration Netmask for Station Mode
SSID_Station: string; // Station SSID saved
Password_Station: string; // Station Password saved
SSID_AP: string; // AP SSID saved
Password_AP: string; // AP Password saved
MDNS_Hostname: string; // MDNS Hostname saved
};
export const $current_network_configuration = atom<NetworkConfigNVS>({
Connection_Mode: ConnectionMode.CONNECTION_MODE_UNKNOWN,
DHCP_Mode_Station: true,
AP_Channel: 0,
IP_Address_Station: "",
Gateway_IP_Address_Station: "",
Netmask_Station: "",
Static_Cfg_IP_Address_Station: "",
Static_Cfg_Gateway_IP_Address_Station: "",
Static_Cfg_Netmask_Station: "",
SSID_Station: "",
Password_Station: "",
SSID_AP: "",
Password_AP: "",
MDNS_Hostname: "hydrobuddy",
});
export type WiFiNetwork = {
SSID: string; // SSID of the network
RSSI: number; // Signal strength in dBm
Channel: number; // Channel number
SecurityType: string; // Security type (WPA, WPA2, WEP, etc.)
};
export function authmodeToSecurity(mode: number): string {
switch (mode) {
case 0:
return "Open";
case 1:
return "WEP";
case 2:
return "WPA";
case 3:
return "WPA2";
case 4:
return "WPA/WPA2";
case 5:
return "WPA3";
case 6:
return "WPA2-Enterprise";
case 7:
return "WPA3-Enterprise";
default:
return "Unknown";
}
}
export const $scanned_wifi_networks = atom<WiFiNetwork[]>([]);
// Live status emitted by network_manager_task ~every 500 ms via "network_status_update".
// Fields with no ethernet equivalent and no ws_connected_clients (intentionally dropped — single-purpose web UI).
export type NetworkStatusDisplay = {
Connection_Mode: ConnectionMode;
AP_Channel: number;
IP_Address_Station: string;
Gateway_IP_Address_Station: string;
Netmask_Station: string;
MDNS_Hostname: string;
MAC_Address_WiFi: string;
rssi: number | null; // null when WiFi STA not connected
};
export const $network_status_display = atom<NetworkStatusDisplay>({
Connection_Mode: ConnectionMode.CONNECTION_MODE_UNKNOWN,
AP_Channel: 0,
IP_Address_Station: "",
Gateway_IP_Address_Station: "",
Netmask_Station: "",
MDNS_Hostname: "",
MAC_Address_WiFi: "",
rssi: null,
});
/* StationConfigModal / wizard helper nanostores */
// depending on radio button in modal or on current network configuration, the toggle shall be set/unset. Used for state saving/retrieval.
export const $wifi_static_ip_config_enabled = atom<boolean>(false);
export const $selected_ssid = atom<string>("");
export const $wifi_network_pw = atom<string>("");
export const $temporary_static_ip_address = atom<string>("");
export const $temporary_static_ip_subnet_mask = atom<string>("");
export const $temporary_static_ip_gateway_ip_address = atom<string>("");
export const $hostname_input_field = atom<string>("");
function wsOpen(): WebSocket | null {
const ws = $websocket.get();
return ws && (ws as WebSocket).readyState === WebSocket.OPEN ? (ws as WebSocket) : null;
}
export function send_new_network_configuration_to_alpha(network_config: NetworkConfigNVS): boolean {
// send the network configuration to the device
const ws = wsOpen();
if (!ws) {
console.error("WebSocket not connected, cannot send new network configuration!");
return false;
}
console.log("Sending new network configuration!");
console.log("Current configuration: ", $current_network_configuration.get());
console.log("New configuration: ", network_config);
const mode = network_config.Connection_Mode;
const hasApsta = mode === ConnectionMode.CONNECTION_MODE_APSTA;
const hasSta = mode === ConnectionMode.CONNECTION_MODE_STA_DHCP || mode === ConnectionMode.CONNECTION_MODE_STA_STATIC;
const hasStaticSta = mode === ConnectionMode.CONNECTION_MODE_STA_STATIC;
if (mode === ConnectionMode.CONNECTION_MODE_UNKNOWN) {
console.error("Cannot send config with UNKNOWN connection mode!");
return false;
}
if (hasApsta && (network_config.SSID_AP === "" || network_config.SSID_AP.length >= 32 || network_config.Password_AP.length >= 32)) {
console.error("APSTA AP SSID/password invalid!");
return false;
}
if (hasSta && (network_config.SSID_Station === "" || network_config.SSID_Station.length >= 32 || network_config.Password_Station.length >= 32)) {
console.error("STA SSID/password invalid!");
return false;
}
if (
hasStaticSta &&
(network_config.Static_Cfg_IP_Address_Station === "" ||
network_config.Static_Cfg_Netmask_Station === "" ||
network_config.Static_Cfg_Gateway_IP_Address_Station === "")
) {
console.error("Static STA IP fields missing!");
return false;
}
ws.send(
JSON.stringify({
message_type: "network_cmd",
network_command: NetworkManagerCommands.NETWORK_CMD_APPLY_CONFIG,
network_config: network_config,
}),
);
return true;
}
// Request the firmware to broadcast its current network_config (mode + saved SSID etc.).
// Called on WebSocket open so the UI knows whether to show the wizard or the overview.
export const request_network_config = (): void => {
wsOpen()?.send(JSON.stringify({ message_type: "request_network_config" }));
};
export const scan_wifi_networks = (): void => {
$scanned_wifi_networks.set([]);
wsOpen()?.send(
JSON.stringify({
message_type: "network_cmd",
network_command: NetworkManagerCommands.NETWORK_CMD_GET_WIFI_NETWORKS,
}),
);
};
export const set_hostname = (hostname: string): boolean => {
const ws = wsOpen();
if (!ws) {
console.error("WebSocket not connected, cannot set hostname!");
return false;
}
if (!hostname || hostname.length === 0 || hostname.length > 31) {
console.error("Invalid hostname! Must be 1-31 characters.");
return false;
} else if (hostname === $current_network_configuration.get().MDNS_Hostname) {
return false;
}
console.log("Setting MDNS hostname:", hostname);
ws.send(
JSON.stringify({
message_type: "set_network_hostname",
network_command: NetworkManagerCommands.NETWORK_CMD_SET_MDNS_HOSTNAME,
hostname: hostname,
}),
);
return true;
};
export const clear_scanned_wifi_networks = (): void => {
$scanned_wifi_networks.set([]);
};
export type NetworkConfigError = {
reason: string;
failed_ssid: string;
connection_mode: ConnectionMode;
};
export const $network_config_error = atom<NetworkConfigError | null>(null);

View file

@ -0,0 +1,543 @@
---
import { IconPlant } from "@tabler/icons-preact";
---
<div class="space-y-6">
<!-- Header strip: title + current mode badge + reconfigure shortcut -->
<div class="card bg-base-100 shadow">
<div class="card-body flex flex-row items-center justify-between flex-wrap gap-3">
<div class="flex items-center gap-3">
<span class="text-emerald-600"><IconPlant size={28} strokeWidth={1.75} /></span>
<div>
<h2 class="card-title text-xl text-emerald-700">HydroBuddy is online</h2>
<p class="text-sm opacity-70" id="overview-subtitle">Loading mode…</p>
</div>
</div>
<button id="overview-forget-btn" class="btn btn-sm btn-ghost">Re-setup WiFi</button>
</div>
</div>
<!-- Stats grid (Mode / WiFi info / Station IP / Hostname) -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Connection Mode -->
<div class="card bg-base-100 card-border border-base-300 shadow min-w-0">
<div class="card-body p-4 gap-1">
<h3 class="text-sm opacity-60 font-medium">Connection Mode</h3>
<div class="text-xl font-bold text-emerald-700 leading-tight wrap-break-word" id="mode-display">—</div>
<div class="text-xs opacity-60" id="mode-sub">—</div>
</div>
</div>
<!-- WiFi (SSID + signal/security) -->
<div class="card bg-base-100 card-border border-base-300 shadow min-w-0" id="wifi-card">
<div class="card-body p-4 gap-1">
<h3 class="text-sm opacity-60 font-medium" id="wifi-card-title">WiFi</h3>
<div class="text-xl font-bold text-emerald-700 truncate" id="ssid-display">—</div>
<div class="text-xs opacity-70 items-center gap-1 hidden" id="password-row">
<span class="flex items-center gap-1">
PW: <span id="password-display" data-password="">—</span>
<button type="button" class="btn btn-ghost btn-xs p-0 min-h-0 h-4" id="toggle-password-visibility">
<span id="pw-eye-text" class="text-xs opacity-70">show</span>
</button>
</span>
</div>
<div class="text-xs opacity-70" id="rssi-row">RSSI: <span id="signal-display">—</span></div>
<div class="text-xs opacity-60" id="signal-quality">—</div>
</div>
</div>
<!-- Station IP (hidden in APSTA mode — no STA IP exists then) -->
<div class="card bg-base-100 card-border border-base-300 shadow min-w-0" id="station-ip-card">
<div class="card-body p-4 gap-1">
<h3 class="text-sm opacity-60 font-medium">Station IP</h3>
<div class="text-xl font-bold text-emerald-700 truncate" id="sta-ip-display">—</div>
<div class="text-xs opacity-70 truncate">GW: <span id="sta-gw-display">—</span></div>
<div class="text-xs opacity-70 truncate">Mask: <span id="sta-mask-display">—</span></div>
<div class="text-xs opacity-70 truncate">MAC: <span id="sta-mac-display">—</span></div>
</div>
</div>
<!-- Hostname -->
<div class="card bg-base-100 card-border border-base-300 shadow min-w-0">
<div class="card-body p-4 gap-1">
<h3 class="text-sm opacity-60 font-medium">Hostname</h3>
<div class="text-xl font-bold text-emerald-700 truncate" id="hostname-display">—</div>
<a
class="text-xs opacity-70 truncate hover:opacity-100 hover:text-emerald-700 transition-opacity"
id="hostname-link"
href="http://hydrobuddy.local"
target="_blank"
>
http://<span id="hostname-preview-card">hydrobuddy</span>.local
</a>
</div>
</div>
</div>
<!-- Hostname editor -->
<div class="card bg-base-100 card-border border-base-300">
<div class="card-body">
<h3 class="font-semibold text-base mb-1">Device name</h3>
<p class="text-sm opacity-70 mb-3">Reach the web interface at this name.local from any device on the same network.</p>
<div class="flex gap-2 w-full">
<label
class="input flex-1 flex items-center gap-2 font-mono text-sm focus-within:bg-emerald-50 focus-within:outline-emerald-500"
>
<span class="opacity-60 select-none">http://</span>
<input
type="text"
id="overview-hostname-input"
placeholder="hydrobuddy"
maxlength="31"
class="grow min-w-0 bg-transparent border-none outline-none"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
/>
<span class="opacity-60 select-none">.local</span>
</label>
<button id="overview-hostname-apply" class="btn btn-primary" disabled>Apply</button>
</div>
<p id="overview-hostname-error" class="text-sm text-error hidden mt-2"></p>
</div>
</div>
<!-- IP Assignment (STA only) -->
<div class="card bg-base-100 card-border border-base-300" id="ip-assignment-card">
<div class="card-body">
<h3 class="font-semibold text-base mb-1">IP Address Assignment</h3>
<p class="text-sm opacity-70 mb-3">DHCP is the safe default. Static IP only if your network requires it.</p>
<div class="join w-full mb-3">
<input class="join-item btn flex-1" type="radio" name="ip-mode" aria-label="Automatic (DHCP)" value="dhcp" id="ip-mode-dhcp" />
<input class="join-item btn flex-1" type="radio" name="ip-mode" aria-label="Static IP" value="static" id="ip-mode-static" />
</div>
<div id="static-ip-fields" class="hidden space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label class="form-control">
<span class="label-text text-xs">IP Address</span>
<input type="text" id="ov-static-ip" placeholder="192.168.1.50" class="input input-bordered input-sm font-mono" />
</label>
<label class="form-control">
<span class="label-text text-xs">Netmask</span>
<input type="text" id="ov-static-mask" placeholder="255.255.255.0" class="input input-bordered input-sm font-mono" />
</label>
<label class="form-control">
<span class="label-text text-xs">Gateway</span>
<input type="text" id="ov-static-gw" placeholder="192.168.1.1" class="input input-bordered input-sm font-mono" />
</label>
</div>
<p id="ov-static-error" class="text-sm text-error hidden"></p>
<button id="ov-static-apply" class="btn btn-primary btn-sm">Apply Static IP</button>
</div>
</div>
</div>
<!-- Mode switch -->
<div class="card bg-base-100 card-border border-base-300">
<div class="card-body">
<h3 class="font-semibold text-base mb-1">WiFi Mode</h3>
<p class="text-sm opacity-70 mb-3">Choose how the device connects.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<button
class="h-auto py-4 mode-card bg-base-200 hover:bg-emerald-50 transition-all rounded-lg border-2 border-transparent hover:border-emerald-500"
data-mode="station"
id="overview-mode-station"
>
<div class="flex flex-col items-center gap-2">
<span class="font-semibold text-emerald-700">WiFi Station</span>
<span class="text-xs opacity-70">Join an existing WiFi network</span>
</div>
</button>
<button
class="h-auto py-4 mode-card bg-base-200 hover:bg-emerald-50 transition-all rounded-lg border-2 border-transparent hover:border-emerald-500"
data-mode="apsta"
id="overview-mode-apsta"
>
<div class="flex flex-col items-center gap-2">
<span class="font-semibold text-emerald-700">Access Point</span>
<span class="text-xs opacity-70">Create a HydroBuddy hotspot</span>
</div>
</button>
</div>
<!-- Inline APSTA form (shown when "Access Point" is clicked) -->
<div id="apsta-form" class="hidden mt-4 space-y-3 p-4 rounded-lg bg-base-200/50">
<h4 class="font-semibold text-sm">Configure Access Point</h4>
<label class="form-control">
<span class="label-text text-xs">AP SSID</span>
<input type="text" id="ov-ap-ssid" maxlength="31" class="input input-bordered input-sm" />
</label>
<label class="form-control">
<span class="label-text text-xs">AP Password (leave blank for open)</span>
<input type="text" id="ov-ap-password" maxlength="31" class="input input-bordered input-sm font-mono" />
</label>
<div class="flex justify-end gap-2">
<button id="ov-ap-cancel" class="btn btn-ghost btn-sm">Cancel</button>
<button id="ov-ap-apply" class="btn btn-primary btn-sm">Apply AP Mode</button>
</div>
</div>
</div>
</div>
</div>
<!-- Network-error dialog (raised when network_config_failed arrives) -->
<dialog id="overview-network-error-modal" class="modal modal-middle">
<div class="modal-box w-11/12 sm:w-5/12 max-w-lg">
<h3 class="text-lg font-bold mb-1 text-error">Network configuration failed</h3>
<div class="divider mt-2 mb-3"></div>
<div class="alert alert-error py-3">
<span id="ov-error-text">Configuration could not be applied.</span>
</div>
<p class="text-sm opacity-70 mt-3">
The device has restored the previous working configuration. Check your credentials or IP settings and try again.
</p>
<div class="modal-action">
<form method="dialog"><button class="btn btn-primary">OK</button></form>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<script>
import {
ConnectionMode,
$current_network_configuration,
$network_status_display,
$network_config_error,
type NetworkConfigNVS,
send_new_network_configuration_to_alpha,
set_hostname,
} from "../components/NetworkManagerStore";
import {
isValidHostname,
normalizeHostnameValue,
} from "../components/helper_scripts/hostname_validation";
import {
is_valid_ip_format,
isValidNetmask,
isPracticalNetmask,
isInSameSubnet,
isInValidHostRange,
} from "../components/helper_scripts/IP_Subnetmask_validation";
// ---------- DOM lookup ----------
const subtitleEl = document.getElementById("overview-subtitle");
const modeDisplay = document.getElementById("mode-display");
const modeSub = document.getElementById("mode-sub");
const wifiCardTitle = document.getElementById("wifi-card-title");
const ssidDisplay = document.getElementById("ssid-display");
const passwordRow = document.getElementById("password-row");
const passwordDisplay = document.getElementById("password-display") as HTMLSpanElement | null;
const passwordToggle = document.getElementById("toggle-password-visibility");
const pwEyeText = document.getElementById("pw-eye-text");
const rssiRow = document.getElementById("rssi-row");
const signalDisplay = document.getElementById("signal-display");
const signalQuality = document.getElementById("signal-quality");
const stationIpCard = document.getElementById("station-ip-card");
const staIpDisplay = document.getElementById("sta-ip-display");
const staGwDisplay = document.getElementById("sta-gw-display");
const staMaskDisplay = document.getElementById("sta-mask-display");
const staMacDisplay = document.getElementById("sta-mac-display");
const hostnameDisplay = document.getElementById("hostname-display");
const hostnameLink = document.getElementById("hostname-link") as HTMLAnchorElement | null;
const hostnamePreviewCard = document.getElementById("hostname-preview-card");
const hostnameInput = document.getElementById("overview-hostname-input") as HTMLInputElement | null;
const hostnameError = document.getElementById("overview-hostname-error");
const hostnameApply = document.getElementById("overview-hostname-apply") as HTMLButtonElement | null;
const ipAssignmentCard = document.getElementById("ip-assignment-card");
const ipModeDhcp = document.getElementById("ip-mode-dhcp") as HTMLInputElement | null;
const ipModeStatic = document.getElementById("ip-mode-static") as HTMLInputElement | null;
const staticFields = document.getElementById("static-ip-fields");
const ovStaticIp = document.getElementById("ov-static-ip") as HTMLInputElement | null;
const ovStaticMask = document.getElementById("ov-static-mask") as HTMLInputElement | null;
const ovStaticGw = document.getElementById("ov-static-gw") as HTMLInputElement | null;
const ovStaticError = document.getElementById("ov-static-error");
const ovStaticApply = document.getElementById("ov-static-apply") as HTMLButtonElement | null;
const modeStationBtn = document.getElementById("overview-mode-station");
const modeApstaBtn = document.getElementById("overview-mode-apsta");
const apstaForm = document.getElementById("apsta-form");
const ovApSsid = document.getElementById("ov-ap-ssid") as HTMLInputElement | null;
const ovApPassword = document.getElementById("ov-ap-password") as HTMLInputElement | null;
const ovApCancel = document.getElementById("ov-ap-cancel");
const ovApApply = document.getElementById("ov-ap-apply") as HTMLButtonElement | null;
const forgetBtn = document.getElementById("overview-forget-btn");
const errorModal = document.getElementById("overview-network-error-modal") as HTMLDialogElement | null;
const ovErrorText = document.getElementById("ov-error-text");
// Track password visibility per render — defaults to hidden each config refresh.
let isPasswordVisible = false;
function modeLabel(mode: ConnectionMode): string {
switch (mode) {
case ConnectionMode.CONNECTION_MODE_APSTA:
return "Access Point";
case ConnectionMode.CONNECTION_MODE_STA_DHCP:
return "WiFi Station (DHCP)";
case ConnectionMode.CONNECTION_MODE_STA_STATIC:
return "WiFi Station (Static IP)";
default:
return "Unknown";
}
}
function isSta(mode: ConnectionMode): boolean {
return mode === ConnectionMode.CONNECTION_MODE_STA_DHCP || mode === ConnectionMode.CONNECTION_MODE_STA_STATIC;
}
function renderPassword(pw: string) {
if (!passwordDisplay) return;
passwordDisplay.dataset.password = pw;
passwordDisplay.textContent = isPasswordVisible ? pw || "—" : pw ? "•".repeat(Math.min(pw.length, 12)) : "—";
if (pwEyeText) pwEyeText.textContent = isPasswordVisible ? "hide" : "show";
}
passwordToggle?.addEventListener("click", () => {
isPasswordVisible = !isPasswordVisible;
const pw = passwordDisplay?.dataset.password ?? "";
renderPassword(pw);
});
// ---------- Config subscription ----------
$current_network_configuration.subscribe((cfg) => {
const apsta = cfg.Connection_Mode === ConnectionMode.CONNECTION_MODE_APSTA;
const sta = isSta(cfg.Connection_Mode);
if (modeDisplay) modeDisplay.textContent = modeLabel(cfg.Connection_Mode);
if (modeSub)
modeSub.textContent = apsta
? "Your device is a hotspot"
: sta
? "Joined an existing network"
: "Not yet configured";
if (wifiCardTitle) wifiCardTitle.textContent = apsta ? "WiFi Access Point" : "WiFi Station";
if (ssidDisplay) ssidDisplay.textContent = apsta ? cfg.SSID_AP || "—" : sta ? cfg.SSID_Station || "—" : "—";
// Password row visible whenever a credential exists (STA or APSTA)
const pw = apsta ? cfg.Password_AP : sta ? cfg.Password_Station : "";
if (passwordRow) {
const hasPw = !!pw;
passwordRow.classList.toggle("hidden", !hasPw);
passwordRow.classList.toggle("flex", hasPw);
renderPassword(pw);
}
// RSSI row only meaningful in STA mode (signal strength to the upstream AP).
rssiRow?.classList.toggle("hidden", !sta);
if (signalQuality && !sta) signalQuality.textContent = "";
// Station IP card only in STA modes
stationIpCard?.classList.toggle("hidden", !sta);
// Hostname card + edit form
if (hostnameDisplay) hostnameDisplay.textContent = cfg.MDNS_Hostname || "—";
if (hostnamePreviewCard) hostnamePreviewCard.textContent = cfg.MDNS_Hostname || "hydrobuddy";
if (hostnameLink) hostnameLink.href = `http://${cfg.MDNS_Hostname || "hydrobuddy"}.local`;
// Prime the input only if the user hasn't started typing.
if (hostnameInput && (!hostnameInput.value || hostnameInput.value === hostnameInput.defaultValue)) {
hostnameInput.value = cfg.MDNS_Hostname || "";
hostnameInput.defaultValue = hostnameInput.value;
}
// IP Assignment card — only in STA mode
ipAssignmentCard?.classList.toggle("hidden", !sta);
if (sta) {
if (cfg.DHCP_Mode_Station) {
if (ipModeDhcp) ipModeDhcp.checked = true;
staticFields?.classList.add("hidden");
} else {
if (ipModeStatic) ipModeStatic.checked = true;
staticFields?.classList.remove("hidden");
}
if (ovStaticIp) ovStaticIp.value = cfg.Static_Cfg_IP_Address_Station || "";
if (ovStaticMask) ovStaticMask.value = cfg.Static_Cfg_Netmask_Station || "";
if (ovStaticGw) ovStaticGw.value = cfg.Static_Cfg_Gateway_IP_Address_Station || "";
}
// Subtitle in the header strip
if (subtitleEl) subtitleEl.textContent = `${modeLabel(cfg.Connection_Mode)}`;
});
// ---------- Status subscription (IP/RSSI/MAC live updates) ----------
$network_status_display.subscribe((s) => {
if (staIpDisplay) staIpDisplay.textContent = s.IP_Address_Station || "—";
if (staGwDisplay) staGwDisplay.textContent = s.Gateway_IP_Address_Station || "—";
if (staMaskDisplay) staMaskDisplay.textContent = s.Netmask_Station || "—";
if (staMacDisplay) staMacDisplay.textContent = s.MAC_Address_WiFi || "—";
if (signalDisplay) signalDisplay.textContent = s.rssi != null ? `${s.rssi} dBm` : "—";
if (signalQuality) {
if (s.rssi == null) {
signalQuality.textContent = isSta(s.Connection_Mode) ? "Not connected" : "";
} else {
const r = s.rssi;
signalQuality.textContent = r >= -50 ? "Excellent" : r >= -60 ? "Good" : r >= -70 ? "Fair" : "Weak";
}
}
});
// ---------- Hostname editing ----------
function validateHostnameField() {
if (!hostnameInput || !hostnameError || !hostnameApply) return;
const raw = hostnameInput.value;
const normalized = normalizeHostnameValue(raw);
if (normalized !== raw) hostnameInput.value = normalized;
if (!normalized) {
hostnameError.classList.add("hidden");
hostnameApply.disabled = true;
return;
}
if (!isValidHostname(normalized)) {
hostnameError.textContent = "Invalid hostname. Use a-z, 0-9, hyphens. No leading/trailing hyphen.";
hostnameError.classList.remove("hidden");
hostnameApply.disabled = true;
return;
}
hostnameError.classList.add("hidden");
// Don't enable if it matches what the device already has.
hostnameApply.disabled = normalized === $current_network_configuration.get().MDNS_Hostname;
}
hostnameInput?.addEventListener("input", validateHostnameField);
hostnameApply?.addEventListener("click", () => {
if (!hostnameInput) return;
set_hostname(hostnameInput.value.trim());
});
// ---------- Static IP form ----------
ipModeDhcp?.addEventListener("change", () => {
staticFields?.classList.add("hidden");
// Apply DHCP immediately — no fields needed.
const baseCfg = $current_network_configuration.get();
if (baseCfg.Connection_Mode !== ConnectionMode.CONNECTION_MODE_STA_DHCP) {
send_new_network_configuration_to_alpha({
...baseCfg,
Connection_Mode: ConnectionMode.CONNECTION_MODE_STA_DHCP,
DHCP_Mode_Station: true,
});
}
});
ipModeStatic?.addEventListener("change", () => {
staticFields?.classList.remove("hidden");
});
function validateOvStaticIp(): { ok: boolean; ip: string; mask: string; gw: string } {
if (!ovStaticIp || !ovStaticMask || !ovStaticGw || !ovStaticError) {
return { ok: false, ip: "", mask: "", gw: "" };
}
const ip = ovStaticIp.value.trim();
const mask = ovStaticMask.value.trim();
const gw = ovStaticGw.value.trim();
if (!ip || !mask || !gw) {
ovStaticError.textContent = "All three fields are required.";
ovStaticError.classList.remove("hidden");
return { ok: false, ip, mask, gw };
}
if (!is_valid_ip_format(ip) || !is_valid_ip_format(gw)) {
ovStaticError.textContent = "Invalid IPv4 format.";
ovStaticError.classList.remove("hidden");
return { ok: false, ip, mask, gw };
}
if (!isValidNetmask(mask) || !isPracticalNetmask(mask)) {
ovStaticError.textContent = "Netmask is not practical (use e.g. 255.255.255.0).";
ovStaticError.classList.remove("hidden");
return { ok: false, ip, mask, gw };
}
if (!isInSameSubnet(ip, gw, mask)) {
ovStaticError.textContent = "Gateway must be in the same subnet as the IP.";
ovStaticError.classList.remove("hidden");
return { ok: false, ip, mask, gw };
}
if (!isInValidHostRange(ip, mask) || !isInValidHostRange(gw, mask)) {
ovStaticError.textContent = "Pick host addresses (not network/broadcast).";
ovStaticError.classList.remove("hidden");
return { ok: false, ip, mask, gw };
}
ovStaticError.classList.add("hidden");
return { ok: true, ip, mask, gw };
}
ovStaticIp?.addEventListener("input", validateOvStaticIp);
ovStaticMask?.addEventListener("input", validateOvStaticIp);
ovStaticGw?.addEventListener("input", validateOvStaticIp);
ovStaticApply?.addEventListener("click", () => {
const v = validateOvStaticIp();
if (!v.ok) return;
const baseCfg = $current_network_configuration.get();
send_new_network_configuration_to_alpha({
...baseCfg,
Connection_Mode: ConnectionMode.CONNECTION_MODE_STA_STATIC,
DHCP_Mode_Station: false,
Static_Cfg_IP_Address_Station: v.ip,
Static_Cfg_Netmask_Station: v.mask,
Static_Cfg_Gateway_IP_Address_Station: v.gw,
});
});
// ---------- Mode switch ----------
modeStationBtn?.addEventListener("click", () => {
// "Switch to Station" = re-run the wizard. Easiest path is to nuke SSID_Station
// locally so index.astro shows the wizard. We don't push to the device yet —
// the wizard's Apply step does that.
const baseCfg = $current_network_configuration.get();
$current_network_configuration.set({ ...baseCfg, SSID_Station: "" });
});
modeApstaBtn?.addEventListener("click", () => {
// Open inline AP config form, prefilled with current AP values
const cfg = $current_network_configuration.get();
if (ovApSsid) ovApSsid.value = cfg.SSID_AP || "";
if (ovApPassword) ovApPassword.value = cfg.Password_AP || "";
apstaForm?.classList.remove("hidden");
apstaForm?.scrollIntoView({ behavior: "smooth", block: "nearest" });
});
ovApCancel?.addEventListener("click", () => {
apstaForm?.classList.add("hidden");
});
ovApApply?.addEventListener("click", () => {
const ssid = ovApSsid?.value.trim() ?? "";
if (!ssid) {
// Minimal inline feedback: focus the field
ovApSsid?.focus();
return;
}
const baseCfg = $current_network_configuration.get();
send_new_network_configuration_to_alpha({
...baseCfg,
Connection_Mode: ConnectionMode.CONNECTION_MODE_APSTA,
SSID_AP: ssid,
Password_AP: ovApPassword?.value ?? "",
});
apstaForm?.classList.add("hidden");
});
// ---------- Re-setup ----------
forgetBtn?.addEventListener("click", () => {
// Clear SSID_Station locally so index.astro's pickView swaps to the wizard.
// We don't send anything to the device — the wizard's Apply pushes the new SSID.
const baseCfg = $current_network_configuration.get();
$current_network_configuration.set({ ...baseCfg, SSID_Station: "" });
});
// ---------- Error modal ----------
$network_config_error.subscribe((err) => {
if (!err || !errorModal) return;
if (ovErrorText) {
const ssidPart = err.failed_ssid ? ` (SSID: ${err.failed_ssid})` : "";
ovErrorText.textContent = `${err.reason}${ssidPart}`;
}
errorModal.showModal();
// Clear after surfacing so it can fire again
$network_config_error.set(null);
});
</script>

File diff suppressed because it is too large Load diff

View file

@ -1,78 +0,0 @@
---
// Import all page components
import DashboardPage from "./DashboardPage.astro";
import TestbenchPage from "./TestbenchPage.astro";
import MonitoringPage from "./MonitoringPage.astro";
import ControlPage from "./ControlPage.astro";
import DcdcPage from "./DcdcPage.astro";
import ModulationPage from "./ModulationPage.astro";
import LogsPage from "./LogsPage.astro";
import NetworkPage from "./NetworkPage.astro";
import SettingsPage from "./SettingsPage.astro";
import SecurityPage from "./SecurityPage.astro";
---
<div id="spa-content bg-base-100">
<!-- All pages pre-rendered, dashboard visible by default -->
<div id="page-dashboard" class="page-content">
<DashboardPage />
</div>
<div id="page-testbench" class="page-content" style="display: none;">
<TestbenchPage />
</div>
<div id="page-monitoring" class="page-content" style="display: none;">
<MonitoringPage />
</div>
<div id="page-control" class="page-content" style="display: none;">
<ControlPage />
</div>
<div id="page-dcdc" class="page-content" style="display: none;">
<DcdcPage />
</div>
<div id="page-modulation" class="page-content" style="display: none;">
<ModulationPage />
</div>
<div id="page-logs" class="page-content" style="display: none;">
<LogsPage />
</div>
<div id="page-network" class="page-content" style="display: none;">
<NetworkPage />
</div>
<div id="page-settings" class="page-content" style="display: none;">
<SettingsPage />
</div>
<div id="page-security" class="page-content" style="display: none;">
<SecurityPage />
</div>
</div>
<script>
// Navigation handling
window.navigateTo = function(path) {
// Hide all pages first
document.querySelectorAll('.page-content').forEach(page => {
(page as HTMLElement).style.display = 'none';
});
// Show the selected page
const pageId = `page-${path.slice(1)}`; // Remove leading slash and add 'page-' prefix
const selectedPage = document.getElementById(pageId) as HTMLElement;
if (selectedPage) {
// show navigated page
selectedPage.style.display = 'block';
} else {
// If no matching page found, show dashboard
const dashboard = document.getElementById('page-dashboard') as HTMLElement;
if (dashboard) {
dashboard.style.display = 'block';
}
}
};
// Show dashboard by default
const dashboard = document.getElementById('page-dashboard') as HTMLElement;
if (dashboard) {
dashboard.style.display = 'block';
}
</script>

View file

@ -1,864 +0,0 @@
---
const pageTitle = "Security";
---
<div class="flex flex-col gap-4">
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Web Access Security</h2>
<div class="w-full max-w-md mx-auto">
<input id="security-slider" type="range" min="0" max="3" value="0" class="range range-error" step="1" />
<div class="flex justify-between px-2.5 mt-2 text-xs">
<span class="cursor-pointer hover:opacity-70 transition-opacity" data-slider-value="0">
<!-- None/0: Alert triangle icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-alert-triangle"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 1.67c.955 0 1.845 .467 2.39 1.247l.105 .16l8.114 13.548a2.914 2.914 0 0 1 -2.307 4.363l-.195 .008h-16.225a2.914 2.914 0 0 1 -2.582 -4.2l.099 -.185l8.11 -13.538a2.914 2.914 0 0 1 2.491 -1.403zm.01 13.33l-.127 .007a1 1 0 0 0 0 1.986l.117 .007l.127 -.007a1 1 0 0 0 0 -1.986l-.117 -.007zm-.01 -7a1 1 0 0 0 -.993 .883l-.007 .117v4l.007 .117a1 1 0 0 0 1.986 0l.007 -.117v-4l-.007 -.117a1 1 0 0 0 -.993 -.883z" /></svg>
</span>
<span class="cursor-pointer hover:opacity-70 transition-opacity" data-slider-value="1">
<!-- Button icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-transition-bottom"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M21 17a1 1 0 0 1 1 1a4 4 0 0 1 -4 4h-12a4 4 0 0 1 -4 -4a1 1 0 0 1 2 0a2 2 0 0 0 2 2h12a2 2 0 0 0 1.995 -1.85l.005 -.15a1 1 0 0 1 1 -1m-9 1l-.082 -.004l-.119 -.016l-.111 -.03l-.111 -.044l-.098 -.052l-.096 -.067l-.09 -.08l-3 -3a1 1 0 0 1 1.414 -1.414l1.293 1.293v-4.586h-5a4 4 0 1 1 0 -8h12a4 4 0 1 1 0 8h-5v4.583l1.293 -1.29a1 1 0 0 1 1.32 -.083l.094 .083a1 1 0 0 1 0 1.414l-3 3l-.112 .097l-.11 .071l-.062 .031l-.081 .034l-.076 .024l-.149 .03z" /></svg>
</span>
<span class="cursor-pointer hover:opacity-70 transition-opacity" data-slider-value="2">
<!-- Pin icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-dialpad"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 3h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z" /><path d="M18 3h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z" /><path d="M11 3h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z" /><path d="M4 10h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z" /><path d="M18 10h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z" /><path d="M11 10h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z" /><path d="M11 17h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z" /></svg>
</span>
<span class="cursor-pointer hover:opacity-70 transition-opacity" data-slider-value="3">
<!-- Password icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-keyboard"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M2 6m0 2a2 2 0 0 1 2 -2h16a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-16a2 2 0 0 1 -2 -2z" /><path d="M6 10l0 .01" /><path d="M10 10l0 .01" /><path d="M14 10l0 .01" /><path d="M18 10l0 .01" /><path d="M6 14l0 .01" /><path d="M18 14l0 .01" /><path d="M10 14l4 .01" /></svg>
</span>
</div>
<div class="flex justify-center mt-4">
<span id="security-mode-badge" class="badge text-xl px-6 py-3 font-bold">None</span>
</div>
<div class="flex justify-center mt-2">
<span id="security-mode-explanation" class="text-center text-sm">No security, open access.</span>
</div>
<!-- Fixed height container for mode-specific content to ensure consistent Save button position -->
<div class="w-full flex flex-col items-center" style="min-height: 200px;">
<div id="none-warning" class="flex flex-col items-center mt-4" style="display:none;">
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-cloud-lock-open text-error"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 18a3.5 3.5 0 0 0 0 -7h-1c.397 -1.768 -.285 -3.593 -1.788 -4.787c-1.503 -1.193 -3.6 -1.575 -5.5 -1s-3.315 2.019 -3.712 3.787c-2.199 -.088 -4.155 1.326 -4.666 3.373c-.512 2.047 .564 4.154 2.566 5.027" /><path d="M8 15m0 1a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1z" /><path d="M10 15v-2a2 2 0 0 1 3.736 -1" /></svg>
<span class="text-error font-bold text-base mt-2">NOT RECOMMENDED</span>
</div>
<div id="button-toggle-row" class="flex flex-col items-center mt-16" style="display:none;">
<label class="flex items-center gap-3">
<span>Allow permanent access to devices</span>
<input id="button-permanent-toggle" type="checkbox" class="toggle toggle-error" />
</label>
</div>
<div id="security-input-fields" class="flex flex-col items-center mt-4 w-full" style="display:none;">
<input id="security-input" type="password" maxlength="8" placeholder="Enter PIN/Password" class="input input-bordered mb-2 w-full" />
<input id="security-confirm" type="password" maxlength="8" placeholder="Confirm PIN/Password" class="input input-bordered mb-2 w-full" />
</div>
<!-- Spacer to push Save button to bottom of container -->
<div class="flex-1"></div>
<div class="flex justify-center mt-4">
<button id="security-save" class="btn btn-primary">Save</button>
</div>
</div>
</div>
</div>
</div>
<div id="security-modals-buttons" class="flex flex-col gap-2 items-start mt-4">
<button class="btn btn-accent" id="open-test-modal">Test Modal</button>
<button class="btn btn-primary" id="open-pin-modal">PIN Login</button>
<button class="btn btn-secondary" id="open-password-modal">Password Login</button>
<button class="btn btn-info" id="open-updater-modal">Software Update</button>
</div>
<!-- Development Controls for Updates -->
<div class="card bg-base-100 shadow mt-4">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Development Controls</h2>
<p class="text-sm text-base-content/70 mb-4">Toggle individual firmware update availability for testing purposes.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="cursor-pointer label justify-start gap-3">
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
checked
id="toggle-alpha-core"
onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)"
/>
<span class="label-text">Alpha-Core Update</span>
</label>
<label class="cursor-pointer label justify-start gap-3">
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
checked
id="toggle-filesystem"
onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)"
/>
<span class="label-text">Filesystem (Web Interface)</span>
</label>
<label class="cursor-pointer label justify-start gap-3">
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
checked
id="toggle-wifi"
onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)"
/>
<span class="label-text">WiFi Companion</span>
</label>
<label class="cursor-pointer label justify-start gap-3">
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
checked
id="toggle-laser-cpu"
onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)"
/>
<span class="label-text">Laser Core CPU</span>
</label>
</div>
<div class="alert alert-info mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>These controls simulate firmware update availability. In production, this information comes from the device via WebSocket.</span>
</div>
</div>
</div>
</div>
<!-- DaisyUI Modal for Test (Hold Button) -->
<dialog id="test-modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<form id="test-modal-form" class="flex flex-col gap-4" method="dialog" onsubmit="return false;">
<h3 class="font-bold text-lg text-center">Device Registration</h3>
<label class="floating-label">
<div class="input input-bordered flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-edit opacity-70">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/>
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/>
<path d="M16 5l3 3"/>
</svg>
<input type="text" id="device-name" class="grow bg-transparent border-none outline-none" placeholder=" " required />
<span>Device Name</span>
</div>
</label>
<div class="flex flex-col gap-2">
<div class="flex gap-4 items-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-devices">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M13 9a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1v-10z"/>
<path d="M18 8v-3a1 1 0 0 0 -1 -1h-13a1 1 0 0 0 -1 1v12a1 1 0 0 0 1 1h9"/>
<path d="M16 9h2"/>
</svg>
<span id="device-type" class="text-sm text-gray-500">Unknown</span>
</div>
<div class="flex gap-4 items-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-password-fingerprint">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M17 8c.788 1 1 2 1 3v1"/>
<path d="M9 11c0 -1.578 1.343 -3 3 -3s3 1.422 3 3v2"/>
<path d="M12 11v2"/>
<path d="M6 12v-1.397c-.006 -1.999 1.136 -3.849 2.993 -4.85a6.385 6.385 0 0 1 6.007 -.005"/>
<path d="M12 17v4"/>
<path d="M10 20l4 -2"/>
<path d="M10 18l4 2"/>
<path d="M5 17v4"/>
<path d="M3 20l4 -2"/>
<path d="M3 18l4 2"/>
<path d="M19 17v4"/>
<path d="M17 20l4 -2"/>
<path d="M17 18l4 2"/>
</svg>
<span class="text-sm text-gray-500">01234</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold mb-1 flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-hourglass-empty">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 20v-2a6 6 0 1 1 12 0v2a1 1 0 0 1 -1 1h-10a1 1 0 0 1 -1 -1z"/>
<path d="M6 4v2a6 6 0 1 0 12 0v-2a1 1 0 0 0 -1 -1h-10a1 1 0 0 0 -1 1z"/>
</svg>
Access Expiration
</span>
<div class="w-full flex flex-col sm:flex-row gap-2">
<div class="join join-vertical lg:join-horizontal w-full">
<button type="button" id="exp-session" class="btn join-item btn-outline btn-active flex-1">Session</button>
<button type="button" id="exp-lifetime" class="btn join-item btn-outline flex-1">Lifetime</button>
</div>
</div>
</div>
<div class="flex flex-col items-center gap-2 mt-2">
<span class="text-sm text-gray-500">Hold the Unlock <b>Button on the Alpha Laserdriver</b> for 3 seconds to authenticate.</span>
<button type="button" id="hold-confirm" class="btn btn-error w-full transition-colors duration-300 text-lg" style="background-color: #dc2626;">
<span id="hold-text" class="text-lg">Hold to Authenticate</span>
</button>
<div id="success-message" class="hidden flex flex-col items-center text-success mt-2">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="success-detail">Device added for this session.</span>
</div>
</div>
</form>
</div>
</dialog>
<!-- PIN Modal -->
<dialog id="pin-modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<form id="pin-modal-form" class="flex flex-col gap-6" method="dialog" onsubmit="return false;">
<h3 class="font-bold text-lg text-center">PIN Login</h3>
<label class="input input-bordered flex items-center gap-2">
<span class="flex items-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-key"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14.52 2c1.029 0 2.015 .409 2.742 1.136l3.602 3.602a3.877 3.877 0 0 1 0 5.483l-2.643 2.643a3.88 3.88 0 0 1 -4.941 .452l-.105 -.078l-5.882 5.883a3 3 0 0 1 -1.68 .843l-.22 .027l-.221 .009h-1.172c-1.014 0 -1.867 -.759 -1.991 -1.823l-.009 -.177v-1.172c0 -.704 .248 -1.386 .73 -1.96l.149 -.161l.414 -.414a1 1 0 0 1 .707 -.293h1v-1a1 1 0 0 1 .883 -.993l.117 -.007h1v-1a1 1 0 0 1 .206 -.608l.087 -.1l1.468 -1.469l-.076 -.103a3.9 3.9 0 0 1 -.678 -1.963l-.007 -.236c0 -1.029 .409 -2.015 1.136 -2.742l2.643 -2.643a3.88 3.88 0 0 1 2.741 -1.136m.495 5h-.02a2 2 0 1 0 0 4h.02a2 2 0 1 0 0 -4"/></svg>
</span>
<input type="password" id="pin-input" class="grow" placeholder="Enter PIN" required maxlength="8" inputmode="numeric" pattern="[0-9]*" />
<button type="button" id="toggle-keypad" tabindex="-1" class="ml-2">
<svg id="keypad-open-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-dialpad"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 3h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z"/><path d="M18 3h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z"/><path d="M11 3h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z"/><path d="M4 10h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z"/><path d="M18 10h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z"/><path d="M11 10h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z"/><path d="M11 17h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1z"/></svg>
<svg id="keypad-close-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-dialpad-off" style="display:none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 7h-4v-4"/><path d="M17 3h4v4h-4z"/><path d="M10 6v-3h4v4h-3"/><path d="M3 10h4v4h-3"/><path d="M17 13v-3h4v4h-3"/><path d="M14 14h-4v-4"/><path d="M10 17h4v4h-4z"/><path d="M3 3l18 18"/></svg>
</button>
</label>
<div id="pin-keypad-container" class="w-full flex justify-center items-center" style="display:none;min-height:0;">
<div class="pin-keypad grid place-items-center w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md">
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">1</button>
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">2</button>
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">3</button>
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">4</button>
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">5</button>
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">6</button>
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">7</button>
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">8</button>
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">9</button>
<div></div>
<button type="button" class="btn btn-primary btn-outline btn-lg btn-square pin-key">0</button>
<div></div>
</div>
</div>
<button type="submit" id="pin-submit" class="btn btn-success w-full text-lg flex items-center justify-center gap-2">Login
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-login-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 8v-2a2 2 0 0 1 2 -2h7a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2v-2"/><path d="M3 12h13l-3 -3"/><path d="M13 15l3 -3"/></svg>
</button>
<div id="pin-success-message" class="hidden flex flex-col items-center text-success mt-2">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<span>Access granted!</span>
</div>
</form>
</div>
</dialog>
<!-- Password Modal -->
<dialog id="password-modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<form id="password-modal-form" class="flex flex-col gap-6" method="dialog" onsubmit="return false;">
<h3 class="font-bold text-lg text-center">Password Login</h3>
<label class="input input-bordered flex items-center gap-2">
<span class="flex items-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-key"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14.52 2c1.029 0 2.015 .409 2.742 1.136l3.602 3.602a3.877 3.877 0 0 1 0 5.483l-2.643 2.643a3.88 3.88 0 0 1 -4.941 .452l-.105 -.078l-5.882 5.883a3 3 0 0 1 -1.68 .843l-.22 .027l-.221 .009h-1.172c-1.014 0 -1.867 -.759 -1.991 -1.823l-.009 -.177v-1.172c0 -.704 .248 -1.386 .73 -1.96l.149 -.161l.414 -.414a1 1 0 0 1 .707 -.293h1v-1a1 1 0 0 1 .883 -.993l.117 -.007h1v-1a1 1 0 0 1 .206 -.608l.087 -.1l1.468 -1.469l-.076 -.103a3.9 3.9 0 0 1 -.678 -1.963l-.007 -.236c0 -1.029 .409 -2.015 1.136 -2.742l2.643 -2.643a3.88 3.88 0 0 1 2.741 -1.136m.495 5h-.02a2 2 0 1 0 0 4h.02a2 2 0 1 0 0 -4"/></svg>
</span>
<input type="password" id="password-input" class="grow" placeholder="Enter Password" required maxlength="64" />
</label>
<button type="submit" id="password-submit" class="btn btn-success w-full text-lg flex items-center justify-center gap-2">Login
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-login-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 8v-2a2 2 0 0 1 2 -2h7a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2v-2"/><path d="M3 12h13l-3 -3"/><path d="M13 15l3 -3"/></svg>
</button>
<div id="password-success-message" class="hidden flex flex-col items-center text-success mt-2">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<span>Access granted!</span>
</div>
</form>
</div>
</dialog>
<script>
// Toggle color management function
function updateToggleColor(toggle: HTMLInputElement) {
if (toggle.checked) {
toggle.classList.remove('toggle-error');
toggle.classList.add('toggle-success');
} else {
toggle.classList.remove('toggle-success');
toggle.classList.add('toggle-error');
}
}
document.addEventListener('DOMContentLoaded', function() {
// Initialize toggle colors for all toggles
const allToggles = document.querySelectorAll('.toggle');
allToggles.forEach(toggle => {
updateToggleColor(toggle as HTMLInputElement);
});
/*
// Function to update mock update availability based on toggles
window.updateAvailability = function() {
// Get toggle states
const alphaCoreToggle = document.getElementById('toggle-alpha-core') as HTMLInputElement;
const filesystemToggle = document.getElementById('toggle-filesystem') as HTMLInputElement;
const wifiToggle = document.getElementById('toggle-wifi') as HTMLInputElement;
const laserCpuToggle = document.getElementById('toggle-laser-cpu') as HTMLInputElement;
// Update global mock data (this would be replaced by WebSocket in production)
if (typeof window.mockUpdates !== 'undefined') {
window.mockUpdates[0].updateAvailable = alphaCoreToggle?.checked || false;
window.mockUpdates[1].updateAvailable = filesystemToggle?.checked || false;
window.mockUpdates[2].updateAvailable = laserCpuToggle?.checked || false;
window.mockUpdates[3].updateAvailable = wifiToggle?.checked || false;
}
// Check if any updates are available
const hasUpdates = (alphaCoreToggle?.checked || false) ||
(filesystemToggle?.checked || false) ||
(wifiToggle?.checked || false) ||
(laserCpuToggle?.checked || false);
// Show/hide notification
const notification = document.getElementById('update-notification');
if (notification) {
if (hasUpdates) {
notification.classList.remove('hidden');
} else {
notification.classList.add('hidden');
}
}
// Show/hide sidebar update button
const updateLink = document.getElementById('update-link');
if (updateLink) {
if (hasUpdates) {
updateLink.style.display = '';
} else {
updateLink.style.display = 'none';
}
}
};*/
/*
// Add event listeners to all toggles
const toggles = ['toggle-alpha-core', 'toggle-filesystem', 'toggle-wifi', 'toggle-laser-cpu'];
toggles.forEach(toggleId => {
const toggle = document.getElementById(toggleId);
if (toggle) {
toggle.addEventListener('change', updateAvailability);
}
});
// Initialize update availability on page load
setTimeout(() => {
if (typeof window.updateAvailability === 'function') {
window.updateAvailability();
}
}, 100);*/
// Test Modal logic
const openTestModalBtn = document.getElementById('open-test-modal');
const testModal = document.getElementById('test-modal');
const holdBtn = document.getElementById('hold-confirm') as HTMLButtonElement | null;
const holdText = document.getElementById('hold-text');
const successMsg = document.getElementById('success-message');
let holdTimeout: number | undefined, holdStart: number, holdInterval: number | undefined;
const holdDuration = 3000; // 3 seconds
if (openTestModalBtn && testModal) {
openTestModalBtn.addEventListener('click', () => {
try {
if ((testModal as HTMLDialogElement).open) {
(testModal as HTMLDialogElement).close();
}
(testModal as HTMLDialogElement).showModal();
// Reset device name input without focusing
const deviceNameInput = document.getElementById('device-name') as HTMLInputElement | null;
if (deviceNameInput) {
deviceNameInput.value = '';
deviceNameInput.blur(); // Ensure no focus
}
if (holdBtn) {
holdBtn.disabled = false;
holdBtn.classList.remove('btn-success');
holdBtn.classList.add('btn-error');
holdBtn.style.backgroundColor = '#dc2626';
holdBtn.classList.remove('animate-stripes');
holdBtn.classList.add('text-black');
holdBtn.classList.remove('text-white');
holdBtn.classList.remove('breathing-orange');
}
if (holdText) {
holdText.textContent = 'Hold to Authenticate';
holdText.classList.add('text-black');
holdText.classList.remove('text-white');
}
if (successMsg) successMsg.classList.add('hidden');
} catch (error) {
console.error('Error opening test modal:', error);
}
});
}
if (holdBtn) {
function getHoldColor(progress: number): { color: string, textBlack: boolean } {
let hue: number, sat: number, light: number;
if (progress < 0.85) {
hue = 20 + (25 * (progress / 0.85));
sat = 100;
light = 45 + (10 * (progress / 0.85));
} else {
hue = 45 + (75 * ((progress - 0.85) / 0.15));
sat = 100;
light = 55;
}
const textBlack = hue >= 40;
return { color: `hsl(${hue}, ${sat}%, ${light}%)`, textBlack };
}
const setBtnColor = (color: string, breathing = false, textBlack = false) => {
holdBtn.style.backgroundColor = color;
if (breathing) {
holdBtn.classList.add('breathing-orange');
} else {
holdBtn.classList.remove('breathing-orange');
}
holdBtn.classList.add('text-black');
holdBtn.classList.remove('text-white');
if (holdText) {
holdText.classList.add('text-black');
holdText.classList.remove('text-white');
}
};
const updateProgress = () => {
try {
const elapsed = Date.now() - holdStart;
const progress = Math.min(elapsed / holdDuration, 1);
const { color, textBlack } = getHoldColor(progress);
if (progress < 1) {
setBtnColor(color, true, textBlack);
} else {
setBtnColor(getHoldColor(1).color, false, true);
holdBtn.disabled = true;
holdBtn.classList.remove('btn-error');
holdBtn.classList.add('btn-success');
if (holdText) holdText.textContent = 'Confirmed!';
if (successMsg) {
const detail = successMsg.querySelector('.success-detail') as HTMLElement | null;
if (detail) {
if (typeof expirationValue !== 'undefined' && expirationValue === 'lifetime') {
detail.textContent = 'Device added permanently.';
} else {
detail.textContent = 'Device added for this session.';
}
}
successMsg.classList.remove('hidden');
}
setTimeout(() => {
(testModal as HTMLDialogElement).close();
}, 3000);
}
} catch (error) {
console.error('Error updating progress:', error);
}
};
// Use both mouse and touch events for better mobile support
const startHold = () => {
try {
if (holdBtn.disabled) return;
holdStart = Date.now();
setBtnColor('hsl(20, 100%, 45%)', true, false);
holdInterval = window.setInterval(updateProgress, 50);
holdTimeout = window.setTimeout(() => {
updateProgress();
}, holdDuration);
if (holdText) holdText.textContent = 'Keep Holding...';
} catch (error) {
console.error('Error starting hold:', error);
}
};
const endHold = () => {
try {
if (holdBtn.disabled) return;
clearInterval(holdInterval);
clearTimeout(holdTimeout);
setBtnColor('#dc2626', false, false);
if (holdText) holdText.textContent = 'Hold to Authenticate';
} catch (error) {
console.error('Error ending hold:', error);
}
};
// Add both mouse and touch event listeners
holdBtn.addEventListener('mousedown', startHold);
holdBtn.addEventListener('touchstart', startHold, { passive: true });
holdBtn.addEventListener('mouseup', endHold);
holdBtn.addEventListener('touchend', endHold, { passive: true });
holdBtn.addEventListener('mouseleave', endHold);
holdBtn.addEventListener('touchcancel', endHold, { passive: true });
}
// Expiration button logic
const expSessionBtn = document.getElementById('exp-session');
const expLifetimeBtn = document.getElementById('exp-lifetime');
let expirationValue = 'session';
if (expSessionBtn && expLifetimeBtn) {
expSessionBtn.addEventListener('click', function() {
try {
expSessionBtn.classList.add('btn-active');
expLifetimeBtn.classList.remove('btn-active');
expirationValue = 'session';
} catch (error) {
console.error('Error setting session expiration:', error);
}
});
expLifetimeBtn.addEventListener('click', function() {
try {
expLifetimeBtn.classList.add('btn-active');
expSessionBtn.classList.remove('btn-active');
expirationValue = 'lifetime';
} catch (error) {
console.error('Error setting lifetime expiration:', error);
}
});
}
// PIN Modal logic
const openPinModalBtn = document.getElementById('open-pin-modal');
const pinModal = document.getElementById('pin-modal');
const pinForm = document.getElementById('pin-modal-form') as HTMLFormElement | null;
const pinInput = document.getElementById('pin-input') as HTMLInputElement | null;
const pinSuccessMsg = document.getElementById('pin-success-message');
const isMobile = /android|iphone|ipad|ipod|opera mini|iemobile|mobile/i.test(navigator.userAgent);
if (openPinModalBtn && pinModal) {
openPinModalBtn.addEventListener('click', () => {
try {
if ((pinModal as HTMLDialogElement).open) {
(pinModal as HTMLDialogElement).close();
}
(pinModal as HTMLDialogElement).showModal();
if (pinInput) pinInput.value = '';
if (pinSuccessMsg) pinSuccessMsg.classList.add('hidden');
const pinKeypadContainer = document.getElementById('pin-keypad-container');
const toggleKeypadBtn = document.getElementById('toggle-keypad');
if (isMobile) {
if (pinKeypadContainer) pinKeypadContainer.style.display = 'none';
if (toggleKeypadBtn) toggleKeypadBtn.style.display = 'none';
} else {
if (toggleKeypadBtn) toggleKeypadBtn.style.display = '';
}
} catch (error) {
console.error('Error opening PIN modal:', error);
}
});
}
// Keypad logic
const pinKeypadContainer = document.getElementById('pin-keypad-container');
const toggleKeypadBtn = document.getElementById('toggle-keypad');
const keypadOpenIcon = document.getElementById('keypad-open-icon');
const keypadCloseIcon = document.getElementById('keypad-close-icon');
let keypadOpen = false;
if (toggleKeypadBtn && pinKeypadContainer && keypadOpenIcon && keypadCloseIcon) {
toggleKeypadBtn.addEventListener('click', () => {
try {
keypadOpen = !keypadOpen;
pinKeypadContainer.style.display = keypadOpen ? '' : 'none';
keypadOpenIcon.style.display = keypadOpen ? 'none' : '';
keypadCloseIcon.style.display = keypadOpen ? '' : 'none';
} catch (error) {
console.error('Error toggling keypad:', error);
}
});
}
if (pinKeypadContainer && pinInput) {
pinKeypadContainer.addEventListener('click', (e) => {
try {
const t = e.target as HTMLButtonElement | null;
if (t && t.classList.contains('pin-key')) {
if (pinInput.value.length < 8) pinInput.value += t.textContent ?? '';
t.classList.add('btn-active', 'pulse-key');
setTimeout(() => {
t.classList.remove('btn-active', 'pulse-key');
}, 120);
}
} catch (error) {
console.error('Error handling keypad click:', error);
}
});
}
if (pinForm && pinModal && pinSuccessMsg) {
pinForm.addEventListener('submit', (e) => {
try {
e.preventDefault();
pinSuccessMsg.classList.remove('hidden');
setTimeout(() => {
(pinModal as HTMLDialogElement).close();
}, 1200);
} catch (error) {
console.error('Error submitting PIN form:', error);
}
});
}
// Password Modal logic
const openPasswordModalBtn = document.getElementById('open-password-modal');
const passwordModal = document.getElementById('password-modal');
const passwordForm = document.getElementById('password-modal-form') as HTMLFormElement | null;
const passwordInput = document.getElementById('password-input') as HTMLInputElement | null;
const passwordSuccessMsg = document.getElementById('password-success-message');
if (openPasswordModalBtn && passwordModal) {
openPasswordModalBtn.addEventListener('click', () => {
try {
if ((passwordModal as HTMLDialogElement).open) {
(passwordModal as HTMLDialogElement).close();
}
(passwordModal as HTMLDialogElement).showModal();
if (passwordInput) passwordInput.value = '';
if (passwordSuccessMsg) passwordSuccessMsg.classList.add('hidden');
} catch (error) {
console.error('Error opening password modal:', error);
}
});
}
if (passwordForm && passwordModal && passwordSuccessMsg) {
passwordForm.addEventListener('submit', (e) => {
try {
e.preventDefault();
passwordSuccessMsg.classList.remove('hidden');
setTimeout(() => {
(passwordModal as HTMLDialogElement).close();
}, 1200);
} catch (error) {
console.error('Error submitting password form:', error);
}
});
}
// Updater Modal logic
const openUpdaterModalBtn = document.getElementById('open-updater-modal');
if (openUpdaterModalBtn) {
openUpdaterModalBtn.addEventListener('click', () => {
try {
if (typeof window.openUpdaterModal === 'function') {
window.openUpdaterModal();
} else {
console.error('openUpdaterModal function not available');
}
} catch (error) {
console.error('Error opening updater modal:', error);
}
});
}
// Web Access Security slider logic
const slider = document.getElementById('security-slider');
const badge = document.getElementById('security-mode-badge');
const explanation = document.getElementById('security-mode-explanation');
const inputFields = document.getElementById('security-input-fields');
const input = document.getElementById('security-input');
const confirm = document.getElementById('security-confirm');
const saveBtn = document.getElementById('security-save');
const MODES = [
{ label: 'None', color: 'badge-error', slider: 'range-error', explain: 'No security, open access.', showInput: false },
{ label: 'Button', color: 'badge-info', slider: 'range-info', explain: 'Physical button press required to authenticate.', showInput: false },
{ label: 'PIN', color: 'badge-warning', slider: 'range-warning', explain: '4-6 digit numeric code required for access.', showInput: true, inputType: 'pin' },
{ label: 'Password', color: 'badge-success', slider: 'range-success', explain: 'Alphanumeric password required for access.', showInput: true, inputType: 'password' },
];
let lastMode = 0;
function updateSecurityUI(val: number) {
try {
const mode = MODES[val];
if (badge) {
badge.textContent = mode.label;
badge.className = `badge text-xl px-6 py-3 font-bold ${mode.color}`;
}
if (explanation) {
if (val === 1) {
explanation.textContent = 'Physical button press required to authenticate.';
} else {
explanation.textContent = mode.explain;
}
}
if (slider) slider.className = `range ${mode.slider}`;
if (inputFields && input && confirm) {
const inputEl = input as HTMLInputElement;
const confirmEl = confirm as HTMLInputElement;
if (mode.showInput) {
inputFields.style.display = '';
inputEl.type = mode.inputType === 'pin' ? 'password' : 'password';
inputEl.maxLength = mode.inputType === 'pin' ? 8 : 64;
inputEl.placeholder = mode.inputType === 'pin' ? 'Enter PIN' : 'Enter Password';
confirmEl.type = mode.inputType === 'pin' ? 'password' : 'password';
confirmEl.maxLength = mode.inputType === 'pin' ? 8 : 64;
confirmEl.placeholder = mode.inputType === 'pin' ? 'Confirm PIN' : 'Confirm Password';
} else {
inputFields.style.display = 'none';
inputEl.value = '';
confirmEl.value = '';
}
}
// Show/hide the none-warning icon and text
const noneWarning = document.getElementById('none-warning');
if (noneWarning) {
noneWarning.style.display = (val === 0) ? '' : 'none';
}
// Show/hide the button-toggle-row for BUTTON mode
const buttonToggleRow = document.getElementById('button-toggle-row');
if (buttonToggleRow) {
buttonToggleRow.style.display = (val === 1) ? '' : 'none';
}
// Remove Save button margin manipulation - no longer needed
lastMode = val;
} catch (error) {
console.error('Error updating security UI:', error);
}
}
if (slider) {
updateSecurityUI(Number((slider as HTMLInputElement).value));
slider.addEventListener('input', function() {
try {
updateSecurityUI(Number((slider as HTMLInputElement).value));
} catch (error) {
console.error('Error handling slider input:', error);
}
});
}
if (saveBtn) {
saveBtn.addEventListener('click', function(e) {
try {
e.preventDefault();
// TODO: Save logic
} catch (error) {
console.error('Error saving settings:', error);
}
});
}
// Add click listeners to icons to control slider
const iconContainers = document.querySelectorAll('[data-slider-value]');
iconContainers.forEach(icon => {
icon.addEventListener('click', function(this: HTMLElement) {
try {
const value = this.getAttribute('data-slider-value');
if (slider && value !== null) {
(slider as HTMLInputElement).value = value;
updateSecurityUI(Number(value));
}
} catch (error) {
console.error('Error handling icon click:', error);
}
});
});
});
</script>
<style>
@keyframes breathing {
0% { filter: brightness(1) drop-shadow(0 0 0.5rem #f59e42); }
50% { filter: brightness(1.35) drop-shadow(0 0 1.5rem #fbbf24); }
100% { filter: brightness(1) drop-shadow(0 0 0.5rem #f59e42); }
}
.breathing-orange {
animation: breathing 1.2s infinite;
}
.pin-keypad {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
width: 100%;
justify-items: center;
}
.pin-keypad .pin-key {
min-width: 3rem;
min-height: 3rem;
font-size: 1.25rem;
font-weight: 600;
border-radius: 0.75rem;
}
@media (max-width: 500px) {
.pin-keypad { max-width: 100%; }
.pin-keypad .pin-key { font-size: 1rem; min-width: 2.25rem; min-height: 2.25rem; }
}
#pin-keypad-container {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
#pin-modal-form {
gap: 1.5rem;
}
.pulse-key {
animation: pulse-btn 0.18s cubic-bezier(0.4,0,0.2,1);
}
@keyframes pulse-btn {
0% { filter: brightness(1.1); }
50% { filter: brightness(1.5); }
100% { filter: brightness(1.1); }
}
@keyframes security-fade {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
.animate-security-fade {
animation: security-fade 0.3s;
}
/* Fix hold button border to match background color */
#hold-confirm {
border: 2px solid transparent !important;
}
/* Floating label styles */
.floating-label {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
}
.floating-label > div {
position: relative;
}
.floating-label span {
position: absolute;
left: 3rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.875rem;
transition: all 0.2s ease-in-out;
pointer-events: none;
padding: 0 0.375rem;
z-index: 1;
border-radius: 0.25rem;
}
/* Use DaisyUI's standard label-text color class behavior */
.floating-label span {
@apply text-base-content/60 bg-base-100;
}
.floating-label input:focus ~ span,
.floating-label input:not(:placeholder-shown) ~ span {
top: 0;
transform: translateY(-50%);
font-size: 0.75rem;
left: 2.75rem;
@apply text-primary font-medium;
}
/* Ensure the input doesn't have autofocus */
.floating-label input {
transition: border-color 0.2s ease-in-out;
}
.floating-label input:focus {
border-color: hsl(var(--p));
}
</style>

View file

@ -1,462 +0,0 @@
---
const pageTitle = "System Settings";
---
<div class="flex flex-col gap-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">{pageTitle}</h1>
</div>
<!-- About this Device -->
<div class="flex flex-col gap-4">
<h2 class="text-xl font-bold">About this Device</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200 p-3">
<div class="stat-title text-xs">Device Model</div>
<div class="stat-value text-sm">Alpha GaN Driver</div>
<div class="stat-desc text-xs">50V 25A REV 2.3</div>
</div>
</div>
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200 p-3">
<div class="stat-title text-xs">Serial Number</div>
<div class="stat-value text-sm" id="serial-number">Alpha-0001</div>
<div class="stat-desc text-xs">Lasertack GmbH</div>
</div>
</div>
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200 p-3">
<div class="stat-title text-xs">Operating Hours</div>
<div class="stat-value text-sm" id="operating-hours">1,247</div>
<div class="stat-desc text-xs">Total Runtime</div>
</div>
</div>
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200 p-3">
<div class="stat-title text-xs">Driver Core MCU</div>
<div class="stat-value text-sm" id="driver-firmware-version">FW1.00</div>
<div class="stat-desc text-xs">Build 05.07.2025</div>
</div>
</div>
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200 p-3">
<div class="stat-title text-xs">Host MCU</div>
<div class="stat-value text-sm" id="companion-firmware-version">FW1.00</div>
<div class="stat-desc text-xs">Build 07.07.2025</div>
</div>
</div>
<div class="stats shadow bg-base-200 rounded-lg border-0">
<div class="stat bg-base-200 p-3">
<div class="stat-title text-xs">Wireless MCU</div>
<div class="stat-value text-sm" id="wireless-firmware-version">FW1.00</div>
<div class="stat-desc text-xs">Build 01.06.2025</div>
</div>
</div>
</div>
<!-- System Preferences -->
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">System Preferences</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
<div>
<h3 class="font-semibold mb-4">Time & Locale</h3>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Timezone</span>
</label>
<label class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-70">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M20.884 10.554a9 9 0 1 0 -10.337 10.328" />
<path d="M3.6 9h16.8" />
<path d="M3.6 15h6.9" />
<path d="M11.5 3a17 17 0 0 0 -1.502 14.954" />
<path d="M12.5 3a17 17 0 0 1 2.52 7.603" />
<path d="M18 18m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" />
<path d="M18 16.5v1.5l.5 .5" />
</svg>
<select class="grow bg-transparent border-none outline-none" id="timezone">
<option value="UTC">UTC</option>
<option value="Europe/Berlin" selected>Europe/Berlin</option>
<option value="America/New_York">America/New_York</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
</select>
</label>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Time Format</span>
</label>
<div class="join w-full">
<button class="btn btn-outline join-item flex-1" id="time-12h" onclick="if('vibrate' in navigator) navigator.vibrate(15)">12 Hour</button>
<button class="btn btn-active join-item flex-1" id="time-24h" onclick="if('vibrate' in navigator) navigator.vibrate(15)">24 Hour</button>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Current Time</span>
</label>
<label class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-70">
<circle cx="12" cy="12" r="10"/>
<polyline points="12,6 12,12 16,14"/>
</svg>
<input type="text" class="grow" readonly id="current-time" />
</label>
</div>
</div>
<div>
<h3 class="font-semibold mb-4">Safety & Logging</h3>
<div class="space-y-4">
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Enable System Logging</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-logging" onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)" />
</label>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Auto-save Settings</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-autosave" onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)" />
</label>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Watchdog Timer</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-watchdog" onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)" />
</label>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Temperature Monitoring</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-temp-monitoring" onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)" />
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Device Configuration -->
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Device Configuration</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
<div>
<h3 class="font-semibold mb-4">Hardware Settings</h3>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">CPU Frequency</span>
</label>
<select class="select select-bordered" id="cpu-frequency">
<option value="240">240 MHz (Default)</option>
<option value="160">160 MHz (Power Save)</option>
<option value="80">80 MHz (Low Power)</option>
</select>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Flash Frequency</span>
</label>
<select class="select select-bordered" id="flash-frequency">
<option value="80" selected>80 MHz</option>
<option value="40">40 MHz</option>
</select>
</div>
<div class="form-control mb-4">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Enable Brownout Detector</span>
<input type="checkbox" class="toggle toggle-success" checked id="toggle-brownout" onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)" />
</label>
</div>
</div>
<div>
<h3 class="font-semibold mb-4">Performance Settings</h3>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Task Watchdog Timeout</span>
</label>
<select class="select select-bordered" id="task-watchdog">
<option value="5" selected>5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="0">Disabled</option>
</select>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Stack Size (KB)</span>
</label>
<input type="range" min="4" max="16" value="8" step="1" class="range range-primary" id="stack-size" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>4KB</span>
<span>8KB</span>
<span>12KB</span>
<span>16KB</span>
</div>
</div>
<div class="form-control mb-4">
<label class="cursor-pointer label justify-start gap-4">
<span class="label-text">Enable Debug Output</span>
<input type="checkbox" class="toggle toggle-error" id="toggle-debug" onchange="updateToggleColor(this); if('vibrate' in navigator) navigator.vibrate(15)" />
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">System Actions</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
<button class="btn btn-primary" id="save-settings" onclick="if('vibrate' in navigator) navigator.vibrate([100, 200, 100])">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Save Settings
</button>
<button class="btn btn-outline" id="export-config" onclick="if('vibrate' in navigator) navigator.vibrate(15)">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 3.75H6.912a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.013 1.244h3.218a2.25 2.25 0 002.013-1.244l.256-.512a2.25 2.25 0 012.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H15" />
</svg>
Export Config
</button>
<button class="btn btn-warning" id="restart-system" onclick="if('vibrate' in navigator) navigator.vibrate([500, 200, 500, 200, 500])">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Restart
</button>
<button class="btn btn-error" id="factory-reset" onclick="if('vibrate' in navigator) navigator.vibrate([500, 200, 500, 200, 500])">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
Factory Reset
</button>
</div>
</div>
</div>
</div>
<script>
// Toggle color management function
function updateToggleColor(toggle: HTMLInputElement) {
if (toggle.checked) {
toggle.classList.remove('toggle-error');
toggle.classList.add('toggle-success');
} else {
toggle.classList.remove('toggle-success');
toggle.classList.add('toggle-error');
}
}
document.addEventListener('DOMContentLoaded', function() {
// Initialize toggle colors
const toggles = document.querySelectorAll('.toggle');
toggles.forEach(toggle => {
updateToggleColor(toggle as HTMLInputElement);
});
// Update current time
function updateTime() {
const now = new Date();
const timeElement = document.getElementById('current-time');
if (timeElement) {
(timeElement as HTMLInputElement).value = now.toLocaleString();
}
}
// Update time every second
updateTime();
setInterval(updateTime, 1000);
// Time format buttons
const time12hBtn = document.getElementById('time-12h');
const time24hBtn = document.getElementById('time-24h');
if (time12hBtn && time24hBtn) {
time12hBtn.addEventListener('click', function() {
time12hBtn.classList.add('btn-active');
time24hBtn.classList.remove('btn-active');
});
time24hBtn.addEventListener('click', function() {
time24hBtn.classList.add('btn-active');
time12hBtn.classList.remove('btn-active');
});
}
// Stack size slider
const stackSizeSlider = document.getElementById('stack-size');
if (stackSizeSlider) {
stackSizeSlider.addEventListener('input', function() {
console.log('Stack size:', (this as HTMLInputElement).value + 'KB');
});
}
// Save settings
const saveSettingsBtn = document.getElementById('save-settings');
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', function() {
console.log('Saving settings...');
// Collect all settings and send via WebSocket
// Show success feedback
const originalText = saveSettingsBtn.textContent;
saveSettingsBtn.textContent = 'Saved!';
saveSettingsBtn.classList.add('btn-success');
setTimeout(() => {
saveSettingsBtn.textContent = originalText;
saveSettingsBtn.classList.remove('btn-success');
}, 2000);
});
}
// Export config
const exportConfigBtn = document.getElementById('export-config');
if (exportConfigBtn) {
exportConfigBtn.addEventListener('click', function() {
// Generate and download config file
const config = {
system: {
timezone: (document.getElementById('timezone') as HTMLSelectElement)?.value,
logging: (document.getElementById('toggle-logging') as HTMLInputElement)?.checked,
cpu_frequency: (document.getElementById('cpu-frequency') as HTMLSelectElement)?.value
}
};
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alpha-driver-config-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
}
// Restart system
const restartBtn = document.getElementById('restart-system');
if (restartBtn) {
restartBtn.addEventListener('click', function() {
if (confirm('Are you sure you want to restart the system? This will temporarily interrupt operations.')) {
console.log('Restarting system...');
// Send restart command via WebSocket
}
});
}
// Factory reset
const factoryResetBtn = document.getElementById('factory-reset');
if (factoryResetBtn) {
factoryResetBtn.addEventListener('click', function() {
if (confirm('WARNING: This will reset all settings to factory defaults. This action cannot be undone.\n\nAre you sure you want to continue?')) {
console.log('Performing factory reset...');
// Send factory reset command via WebSocket
}
});
}
// Function to update settings from WebSocket
(window as any).updateSystemSettings = function(data: any) {
if (data.firmware_version) {
const firmwareEl = document.getElementById('firmware-version');
if (firmwareEl) firmwareEl.textContent = data.firmware_version;
}
if (data.mac_address) {
const macEl = document.getElementById('mac-address');
if (macEl) macEl.textContent = data.mac_address;
}
};
// Device Type detection
const deviceTypeEl = document.getElementById('device-type');
if (deviceTypeEl) {
let type = 'Unknown';
const ua = navigator.userAgent;
if (/android/i.test(ua)) type = 'Android Phone';
else if (/iPhone|iPad|iPod/i.test(ua)) type = 'iPhone';
else if (/Windows/i.test(ua)) type = 'Windows Computer';
else if (/Macintosh/i.test(ua)) type = 'Mac Computer';
else if (/Linux/i.test(ua)) type = 'Linux Computer';
deviceTypeEl.textContent = type;
}
});
</script>
<style>
@keyframes breathing {
0% { filter: brightness(1) drop-shadow(0 0 0.5rem #f59e42); }
50% { filter: brightness(1.35) drop-shadow(0 0 1.5rem #fbbf24); }
100% { filter: brightness(1) drop-shadow(0 0 0.5rem #f59e42); }
}
.breathing-orange {
animation: breathing 1.2s infinite;
}
.pin-keypad {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
width: 100%;
justify-items: center;
}
.pin-keypad .pin-key {
min-width: 3rem;
min-height: 3rem;
font-size: 1.25rem;
font-weight: 600;
border-radius: 0.75rem;
}
@media (max-width: 500px) {
.pin-keypad { max-width: 100%; }
.pin-keypad .pin-key { font-size: 1rem; min-width: 2.25rem; min-height: 2.25rem; }
}
#pin-keypad-container {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
#pin-modal-form {
gap: 1.5rem;
}
.pulse-key {
animation: pulse-btn 0.18s cubic-bezier(0.4,0,0.2,1);
}
@keyframes pulse-btn {
0% { filter: brightness(1.1); }
50% { filter: brightness(1.5); }
100% { filter: brightness(1.1); }
}
</style>

View file

@ -1,246 +0,0 @@
---
import ConnectionStatusBadge from "./ConnectionStatusBadge.tsx";
import TestbenchConnectionStatusBadge from "./TestbenchConnectionStatusBadge.tsx";
import { $availableUpdates } from "./AppProviderStore.tsx";
interface NavigationItem {
path: string;
title: string;
icon: string;
viewBox?: string;
}
// Laser section items
const laserNavigationItems: NavigationItem[] = [
{
path: "/dashboard",
title: "Dashboard",
icon: '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
viewBox: "0 0 24 24",
},
{
path: "/testbench",
title: "Testbench",
icon: '<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="6" cy="8" r="1"/><circle cx="10" cy="8" r="1"/><circle cx="14" cy="8" r="1"/><circle cx="18" cy="8" r="1"/>',
viewBox: "0 0 24 24",
},
{
path: "/control",
title: "Control",
icon: '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
viewBox: "0 0 24 24",
},
{
path: "/modulation",
title: "Laser",
icon: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
viewBox: "0 0 24 24",
},
{
path: "/dcdc",
title: "Power",
icon: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
viewBox: "0 0 24 24",
},
{
path: "/logs",
title: "Logs",
icon: '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
viewBox: "0 0 24 24",
},
];
// System section items
const systemNavigationItems: NavigationItem[] = [
{
path: "/network",
title: "Network",
icon: '<rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/>',
viewBox: "0 0 24 24",
},
{
path: "/settings",
title: "Settings",
icon: '<path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"/><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/><path d="M12 2v2"/><path d="M12 22v-2"/><path d="m17 20.66-1-1.73"/><path d="M11 10.27 7 3.34"/><path d="m20.66 17-1.73-1"/><path d="m3.34 7 1.73 1"/><path d="M14 12h8"/><path d="M2 12h2"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m17 3.34-1 1.73"/><path d="m11 13.73-4 6.93"/>',
viewBox: "0 0 24 24",
},
{
path: "/security",
title: "Security",
icon: '<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11.998 2l.118 .007l.059 .008l.061 .013l.111 .034a.993 .993 0 0 1 .217 .112l.104 .082l.255 .218a11 11 0 0 0 7.189 2.537l.342 -.01a1 1 0 0 1 1.005 .717a13 13 0 0 1 -9.208 16.25a1 1 0 0 1 -.502 0a13 13 0 0 1 -9.209 -16.25a1 1 0 0 1 1.005 -.717a11 11 0 0 0 7.531 -2.527l.263 -.225l.096 -.075a.993 .993 0 0 1 .217 -.112l.112 -.034a.97 .97 0 0 1 .119 -.021l.115 -.007zm.002 7a2 2 0 0 0 -1.995 1.85l-.005 .15l.005 .15a2 2 0 0 0 .995 1.581v1.769l.007 .117a1 1 0 0 0 1.993 -.117l.001 -1.768a2 2 0 0 0 -1.001 -3.732z" />',
viewBox: "0 0 24 24",
},
];
---
<aside class="menu bg-base-200 w-70 min-h-full p-4 text-base-content">
<div class="flex items-center justify-center mb-6">
<div class="text-xl font-bold">Lasertack</div>
</div>
<ul class="menu menu-md text-lg">
<li>
<h2 class="menu-title text-base">Laser</h2>
<ul>
{
laserNavigationItems.map((item) => (
<li>
<a href={item.path} class="cursor-pointer">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox={item.viewBox}
stroke-width="2"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
set:html={item.icon}
/>
{item.title}
</a>
</li>
))
}
</ul>
</li>
<li>
<h2 class="menu-title text-base">System</h2>
<ul>
{
systemNavigationItems.map((item) => (
<li>
<a href={item.path} class="cursor-pointer">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox={item.viewBox}
stroke-width="2"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
set:html={item.icon}
/>
{item.title}
</a>
</li>
))
}
<li>
<a href="/modal" class="cursor-pointer flex items-center gap-2" id="status-link">
<span class="text-sm">Driver:</span>
<ConnectionStatusBadge client:load />
</a>
</li>
<li>
<a href="/modal" class="cursor-pointer flex items-center gap-2" id="testbench-status-link">
<span class="text-sm">Testbench:</span>
<TestbenchConnectionStatusBadge client:load />
</a>
</li>
<li>
<a href="/update" class="cursor-pointer" id="update-link">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-refresh-alert"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"></path>
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"></path>
<path d="M12 9l0 3"></path>
<path d="M12 15l.01 0"></path>
</svg>
<span id="update-status" class="text-neutral-500"
>No updates available</span
>
</a>
</li>
</ul>
</li>
</ul>
</aside>
<script>
// Type declaration for global functions
declare global {
interface Window {
handleNavigation: (path: string) => void;
openStatusModal?: () => void;
}
}
// Import the store
import { $availableUpdates } from "./AppProviderStore.tsx";
// Function to update the UI based on available updates
function updateUpdateStatus() {
const updateStatus = document.getElementById("update-status");
const updateLink = document.getElementById("update-link");
if (updateStatus && updateLink) {
const updates = $availableUpdates.get();
const hasUpdates = updates && updates.length > 0;
if (hasUpdates) {
updateStatus.textContent = "Update Available";
updateStatus.className = "text-success";
updateLink.classList.add("btn-success", "text-success");
} else {
updateStatus.textContent = "No updates available";
updateStatus.className = "text-neutral-500";
updateLink.classList.remove("btn-success", "text-success");
}
}
}
// Warten bis das DOM geladen ist
document.addEventListener("DOMContentLoaded", () => {
// Initial update
updateUpdateStatus();
// Subscribe to store changes
$availableUpdates.subscribe(() => {
updateUpdateStatus();
});
// Alle Links in der Sidebar abfangen
document.querySelectorAll('.menu a[href^="/"]').forEach((link) => {
link.addEventListener(
"click",
function (this: HTMLAnchorElement, e: Event) {
e.preventDefault();
const path = this.getAttribute("href");
if (path === "/modal") {
if (typeof window.openStatusModal === "function") {
window.openStatusModal();
}
return;
}
if (path === "/update") {
if (typeof window.openUpdaterModal === "function") {
window.openUpdaterModal();
}
return;
}
if (
window.handleNavigation &&
typeof window.handleNavigation === "function" &&
path
) {
window.handleNavigation(path);
// Optional: URL in der Adresszeile aktualisieren
//window.history.pushState({}, '', path);
}
}
);
});
});
</script>

View file

@ -0,0 +1,944 @@
---
import IconEye from "../icons/IconEye.astro";
import IconEyeClosed from "../icons/IconEyeClosed.astro";
---
<dialog id="station-config-modal" class="modal modal-middle">
<div class="modal-box w-11/12 sm:w-6/12 max-w-5xl flex flex-col">
<h1 class="text-lg font-bold">WiFi Station Configuration</h1>
<div class="divider divider-primary"></div>
<div id="config-form-view" class="flex flex-col gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold mb-2 ml-1">Network SSID</span>
</label>
<input type="text" class="input w-full focus-within:bg-primary/10 focus-within:border-none focus-within:outline-primary" id="station-ssid" placeholder="Select or enter network name" />
<div id="selected-network-info" class="hidden mt-3"></div>
<div class="text-right mt-4 mb-1 hidden" id="refresh-btn-row">
<button class="btn btn-xs btn-ghost gap-1" id="refresh-networks-btn">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"></path>
</svg>
Refresh
</button>
</div>
<ul id="network-scan-list" class="list bg-base-100 rounded-lg border border-base-300 max-h-64 overflow-y-scroll mt-2 p-1 space-y-0.5">
<li class="flex flex-col items-center gap-4 py-8">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="text-sm opacity-60">Scanning for networks...</span>
</li>
</ul>
<p id="station-ssid-error" class="mt-2 text-sm text-error hidden"></p>
</div>
<div id="password-field-wrapper" class="form-control hidden">
<label class="label">
<span class="label-text font-semibold mb-2">Password</span>
</label>
<div class="relative">
<input type="password" class="input w-full focus-within:bg-primary/10 focus-within:border-none focus-within:outline-primary pr-10" id="station-password" placeholder="WiFi password" />
<button type="button" id="toggle-station-password-btn" class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs p-1 min-h-0 h-7 hover:bg-primary hover:text-primary-content">
<span id="station-eye-icon-open">
<IconEye size={20} class="opacity-70 hover:opacity-100" />
</span>
<span id="station-eye-icon-closed" class="hidden">
<IconEyeClosed size={20} class="opacity-70 hover:opacity-100" />
</span>
</button>
</div>
<p id="station-password-error" class="mt-2 text-sm text-error hidden"></p>
</div>
<div class="divider"></div>
<label>
<span class="label-text font-semibold select-none">Enable Static IP Configuration</span>
<input type="checkbox" class="toggle toggle-primary" id="static-ip-toggle-modal" />
</label>
<div id="static-ip-fields" class="hidden mb-2 py-2">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">IP Address</span>
</label>
<label class="input w-full focus-within:bg-primary/10 focus-within:border-none focus-within:outline-primary">
<input type="text" class="w-full border-none outline-none bg-transparent" id="static-ip-modal" placeholder="192.168.1.100" />
</label>
<p id="static-ip-error" class="mt-2 text-sm text-error hidden">IP Address is required and invalid</p>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Netmask</span>
</label>
<label class="input w-full focus-within:bg-primary/10 focus-within:border-none focus-within:outline-primary">
<input type="text" class="w-full border-none outline-none bg-transparent" id="static-netmask-modal" placeholder="255.255.255.0" />
</label>
<p id="static-netmask-error" class="mt-2 text-sm text-error hidden">Netmask is required and invalid</p>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Gateway</span>
</label>
<label class="input w-full focus-within:bg-primary/10 focus-within:border-none focus-within:outline-primary">
<input type="text" class="w-full border-none outline-none bg-transparent" id="static-gateway-modal" placeholder="192.168.1.1" />
</label>
<p id="static-gateway-error" class="mt-2 text-sm text-error hidden">Gateway is required and invalid</p>
</div>
</div>
</div>
</div>
<!-- Configuration Summary and Confirmation View -->
<div id="confirmation-view" class="hidden flex-col gap-2">
<h2 class="text-base font-semibold">Configuration Summary</h2>
<div class="divider my-1"></div>
<div id="confirm-network-entry"></div>
<ul class="list bg-base-100 rounded-lg border border-base-300 mt-2 p-1 space-y-0.5">
<li>
<div class="flex items-center gap-3 px-4 py-3 bg-base-200 rounded-lg">
<span class="flex-1 text-base font-semibold text-base-content">IP Configuration</span>
<span class="text-base text-base-content/70" id="summary-ip-mode"></span>
</div>
</li>
<li id="summary-ip-address-row" class="hidden">
<div class="flex items-center gap-3 px-4 py-3 bg-base-200 rounded-lg">
<span class="flex-1 text-base font-semibold text-base-content">IP Address</span>
<span class="text-base font-mono text-base-content/70" id="summary-ip-address"></span>
</div>
</li>
<li id="summary-netmask-row" class="hidden">
<div class="flex items-center gap-3 px-4 py-3 bg-base-200 rounded-lg">
<span class="flex-1 text-base font-semibold text-base-content">Netmask</span>
<span class="text-base font-mono text-base-content/70" id="summary-netmask"></span>
</div>
</li>
<li id="summary-gateway-row" class="hidden">
<div class="flex items-center gap-3 px-4 py-3 bg-base-200 rounded-lg">
<span class="flex-1 text-base font-semibold text-base-content">Gateway</span>
<span class="text-base font-mono text-base-content/70" id="summary-gateway"></span>
</div>
</li>
</ul>
<div class="alert alert-warning py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
<div class="flex flex-col gap-1">
<span class="font-semibold">Warning</span>
<ul class="list-disc list-inside text-sm space-y-0.5">
<li>The device may not be reachable over WiFi for a while during the configuration process.</li>
<li>
The WiFi credentials must be valid to establish a working connection. Otherwise, the device will revert to the previous valid WiFi configuration, if there was one.
</li>
<li>The device remains reachable via Ethernet (if connected) or USB at any time.</li>
</ul>
</div>
</div>
<div id="ethernet-warning" class="hidden alert alert-info py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="flex flex-col gap-1">
<span class="font-semibold">Ethernet Detected</span>
<ul class="list-disc list-inside text-sm space-y-0.5">
<li>Ethernet and WiFi can be active simultaneously. The device will be reachable through both interfaces.</li>
</ul>
</div>
</div>
<div class="mt-4"></div>
</div>
<div class="modal-action mt-auto">
<form method="dialog">
<button class="btn" id="cancel-btn">Cancel</button>
</form>
<button class="btn btn-outline hidden" id="back-btn">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Back
</button>
<button class="btn btn-primary" id="save-station-btn">Save Configuration</button>
<button class="btn btn-primary hidden" id="wifi-station-apply-btn">Apply</button>
</div>
</div>
</dialog>
<script>
import {
$scanned_wifi_networks,
scan_wifi_networks,
clear_scanned_wifi_networks,
type WiFiNetwork,
type NetworkConfigNVS,
ConnectionMode,
AntennaSelection,
$current_network_configuration,
send_new_network_configuration_to_alpha,
} from "../backend_components/NetworkManagerStore";
import {
is_valid_ip_format,
isValidNetmask,
isPracticalNetmask,
isInValidHostRange,
isInSameSubnet,
} from "../helper_scripts/IP_Subnetmask_validation";
const stationSSID = document.getElementById("station-ssid") as HTMLInputElement;
const stationSsidError = document.getElementById("station-ssid-error");
const stationPassword = document.getElementById("station-password") as HTMLInputElement;
const stationPasswordError = document.getElementById("station-password-error");
const networkInfo = document.getElementById("selected-network-info");
const passwordFieldWrapper = document.getElementById("password-field-wrapper");
const scanList = document.getElementById("network-scan-list");
const refreshBtnRow = document.getElementById("refresh-btn-row");
const toggleStationPasswordBtn = document.getElementById("toggle-station-password-btn") as HTMLButtonElement | null;
const stationEyeOpen = document.getElementById("station-eye-icon-open");
const stationEyeClosed = document.getElementById("station-eye-icon-closed");
toggleStationPasswordBtn?.addEventListener("click", () => {
if (!stationPassword) return;
const isVisible = stationPassword.type === "text";
stationPassword.type = isVisible ? "password" : "text";
stationEyeOpen?.classList.toggle("hidden", !isVisible);
stationEyeClosed?.classList.toggle("hidden", isVisible);
});
const staticIpToggle = document.getElementById("static-ip-toggle-modal") as HTMLInputElement;
const staticIpFields = document.getElementById("static-ip-fields");
const staticIpError = document.getElementById("static-ip-error");
const staticNetmaskError = document.getElementById("static-netmask-error");
const staticGatewayError = document.getElementById("static-gateway-error");
const staticIP_address = document.getElementById("static-ip-modal") as HTMLInputElement;
const staticIP_netmask = document.getElementById("static-netmask-modal") as HTMLInputElement;
const staticIP_gateway = document.getElementById("static-gateway-modal") as HTMLInputElement;
const staticIP_address_wrap = staticIP_address?.parentElement;
const staticIP_netmask_wrap = staticIP_netmask?.parentElement;
const staticIP_gateway_wrap = staticIP_gateway?.parentElement;
const modal = document.getElementById("station-config-modal") as HTMLDialogElement;
const confirmationView = document.getElementById("confirmation-view");
const configFormView = document.getElementById("config-form-view");
const saveStationBtn = document.getElementById("save-station-btn");
const applyBtn = document.getElementById("wifi-station-apply-btn");
const backBtn = document.getElementById("back-btn");
const cancelBtn = document.getElementById("cancel-btn");
const confirmNetworkEntry = document.getElementById("confirm-network-entry");
const summaryIPMode = document.getElementById("summary-ip-mode");
const summaryIPAddressRow = document.getElementById("summary-ip-address-row");
const summaryNetmaskRow = document.getElementById("summary-netmask-row");
const summaryGatewayRow = document.getElementById("summary-gateway-row");
const summaryIPAddress = document.getElementById("summary-ip-address");
const summaryNetmask = document.getElementById("summary-netmask");
const summaryGateway = document.getElementById("summary-gateway");
const ethernetWarning = document.getElementById("ethernet-warning");
stationSSID.addEventListener("input", () => {
filterText = stationSSID.value.trim();
if (!isScanning && !scanTimedOut) {
applyFilter();
}
});
function getSignalStrengthInfo(dbm: number) {
if (dbm >= -50) {
return { quality: "Excellent", color: "text-success", bars: 4 };
} else if (dbm >= -60) {
return { quality: "Good", color: "text-success", bars: 3 };
} else if (dbm >= -70) {
return { quality: "Fair", color: "text-warning", bars: 2 };
} else {
return { quality: "Weak", color: "text-error", bars: 1 };
}
}
function wifiIconHtml(bars: number): string {
const svgBase =
'xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
const pNone = '<path stroke="none" d="M0 0h24v24H0z" fill="none"/>';
const pDot = '<path d="M12 18l.01 0"/>';
const pArc1 = '<path d="M9.172 15.172a4 4 0 0 1 5.656 0"/>';
const pArc2 = '<path d="M6.343 12.343a8 8 0 0 1 11.314 0"/>';
const pArc3 = '<path d="M3.515 9.515c4.686 -4.687 12.284 -4.687 17 0"/>';
const fgPaths = bars >= 4 ? pDot + pArc1 + pArc2 + pArc3 : bars === 3 ? pDot + pArc1 + pArc2 : bars === 2 ? pDot + pArc1 : pDot;
return (
`<div class="relative flex-shrink-0" style="width:24px;height:24px">` +
`<svg ${svgBase} class="absolute inset-0 opacity-20">${pNone}${pDot}${pArc1}${pArc2}${pArc3}</svg>` +
`<svg ${svgBase} class="absolute inset-0 text-primary">${pNone}${fgPaths}</svg>` +
`</div>`
);
}
function rssiBadgeClass(bars: number): string {
return bars >= 3 ? "badge-success" : bars === 2 ? "badge-warning" : "badge-error";
}
function securityBadgeClass(secType: string): string {
if (secType === "Open") return "badge-success";
return "badge-info";
}
function lockIconHtml(): string {
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-60 flex-shrink-0">` +
`<path stroke="none" d="M0 0h24v24H0z" fill="none"/>` +
`<path d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6"/>` +
`<path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0"/>` +
`<path d="M8 11v-4a4 4 0 1 1 8 0v4"/>` +
`</svg>`
);
}
let selectedNetworkForConfirm: { ssid: string; signal: number; security: string; channel: string } | null = null;
let isScanning = false;
let scanTimedOut = false;
let scanTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
let filterText = "";
const SCAN_TIMEOUT_MS = 15000;
function renderNetworkList(networks: readonly WiFiNetwork[]) {
const networkList = document.getElementById("network-scan-list");
if (!networkList) return;
if (isScanning && networks.length === 0) {
refreshBtnRow?.classList.add("hidden");
networkList.innerHTML = `
<li class="flex flex-col items-center gap-4 py-8">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="text-sm opacity-60">Scanning for networks...</span>
</li>
`;
return;
}
if (scanTimedOut && networks.length === 0) {
refreshBtnRow?.classList.add("hidden");
networkList.innerHTML = `
<li class="flex flex-col items-center gap-4 py-8">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-error opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<span class="text-sm text-error font-semibold">Scan timed out — no response from device.</span>
<button class="btn btn-outline btn-error btn-sm" id="retry-btn">Retry</button>
</li>
`;
document.getElementById("retry-btn")?.addEventListener("click", () => {
performScan();
});
return;
}
if (!isScanning && networks.length === 0) {
refreshBtnRow?.classList.add("hidden");
networkList.innerHTML = `
<li class="flex flex-col items-center gap-4 py-8">
<span class="text-sm opacity-60">No networks found. Try scanning again.</span>
<button class="btn btn-outline btn-primary" id="rescan-btn">Rescan</button>
</li>
`;
document.getElementById("rescan-btn")?.addEventListener("click", () => {
performScan();
});
return;
}
refreshBtnRow?.classList.remove("hidden");
networkList.innerHTML = networks
.map((network) => {
const signalInfo = getSignalStrengthInfo(network.RSSI);
return `
<li class="flex items-center gap-3 px-4 py-4 bg-base-200 rounded-lg border border-transparent hover:bg-primary/10 hover:border-primary/30 active:bg-primary/20 transition-colors duration-150 cursor-pointer network-item" data-ssid="${network.SSID}" data-signal="${network.RSSI}" data-security="${network.SecurityType}" data-channel="${network.Channel}">
<div class="flex-1 min-w-0 flex items-center gap-1.5">
<span class="text-xs text-base-content/40 shrink-0">SSID</span>
<span class="text-base font-bold text-primary truncate">${network.SSID}</span>
</div>
<div class="hidden sm:block w-20 shrink-0 text-sm text-base-content/50 text-center">Channel&nbsp;${network.Channel}</div>
<div class="w-5 shrink-0 flex items-center justify-center">${network.SecurityType !== "Open" ? lockIconHtml() : ""}</div>
<div class="shrink-0">${wifiIconHtml(signalInfo.bars)}</div>
<div class="hidden sm:block w-44 shrink-0 ml-2"><span class="badge badge-sm ${rssiBadgeClass(signalInfo.bars)}">${network.RSSI}&nbsp;dBm&nbsp;·&nbsp;${signalInfo.quality}</span></div>
</li>
`;
})
.join("");
document.querySelectorAll(".network-item").forEach((item) => {
item.addEventListener("click", () => {
const ssid = item.getAttribute("data-ssid") || "";
const signal = parseInt(item.getAttribute("data-signal") || "0");
const security = item.getAttribute("data-security") || "";
const channel = item.getAttribute("data-channel") || "";
if (stationSSID) stationSSID.value = ssid;
selectedNetworkForConfirm = { ssid, signal, security, channel };
if (networkInfo) {
const signalInfo = getSignalStrengthInfo(signal);
networkInfo.innerHTML = `
<div class="flex items-center gap-3 px-4 py-4 bg-base-200 rounded-lg border border-primary/30">
<div class="flex-1 min-w-0 flex items-center gap-1.5">
<span class="text-xs text-base-content/40 shrink-0">SSID</span>
<span class="text-base font-bold text-primary truncate">${ssid}</span>
</div>
<div class="hidden sm:block w-20 shrink-0 text-sm text-base-content/50 text-center">Channel&nbsp;${channel}</div>
<div class="w-5 shrink-0 flex items-center justify-center">${security !== "Open" ? lockIconHtml() : ""}</div>
<div class="shrink-0">${wifiIconHtml(signalInfo.bars)}</div>
<div class="hidden sm:block w-44 shrink-0 ml-2"><span class="badge badge-sm ${rssiBadgeClass(signalInfo.bars)}">${signal}&nbsp;dBm&nbsp;·&nbsp;${signalInfo.quality}</span></div>
<button class="btn btn-sm btn-circle btn-ghost hover:bg-primary hover:text-primary-content border-0 shrink-0" id="clear-network-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`;
networkInfo.classList.remove("hidden");
refreshBtnRow?.classList.add("hidden");
scanList?.classList.add("hidden");
if (security !== "Open") {
passwordFieldWrapper?.classList.remove("hidden");
} else {
passwordFieldWrapper?.classList.add("hidden");
if (stationPassword) {
stationPassword.value = "";
stationPassword.classList.remove("input-error");
}
stationPasswordError?.classList.add("hidden");
}
document.getElementById("clear-network-btn")?.addEventListener("click", () => {
if (stationSSID) stationSSID.value = "";
selectedNetworkForConfirm = null;
filterText = "";
networkInfo.classList.add("hidden");
networkInfo.innerHTML = "";
passwordFieldWrapper?.classList.add("hidden");
if (stationPassword) {
stationPassword.value = "";
stationPassword.classList.remove("input-error");
}
stationPasswordError?.classList.add("hidden");
scanList?.classList.remove("hidden");
applyFilter();
});
}
});
});
}
document.getElementById("refresh-networks-btn")?.addEventListener("click", () => {
console.log("refresh-button clicked: sending scan command of wifi networks!");
performScan();
});
function applyFilter() {
const allNetworks = $scanned_wifi_networks.get();
if (!filterText) {
renderNetworkList(allNetworks);
return;
}
const q = filterText.toLowerCase();
const filtered = allNetworks.filter((n) => n.SSID.toLowerCase().includes(q));
if (filtered.length === 0) {
refreshBtnRow?.classList.add("hidden");
const networkList = document.getElementById("network-scan-list");
if (networkList) {
networkList.innerHTML = `
<li class="flex flex-col items-center gap-4 py-8">
<span class="text-sm opacity-60">No network with name "<span class="font-medium">${filterText}</span>" found.</span>
</li>
`;
}
} else {
renderNetworkList(filtered);
}
}
function performScan() {
if (scanTimeoutHandle) {
clearTimeout(scanTimeoutHandle);
scanTimeoutHandle = null;
}
isScanning = true;
scanTimedOut = false;
if (stationSSID) stationSSID.disabled = true;
renderNetworkList([]);
scan_wifi_networks();
scanTimeoutHandle = setTimeout(() => {
scanTimeoutHandle = null;
if (isScanning) {
isScanning = false;
scanTimedOut = true;
if (stationSSID) stationSSID.disabled = false;
renderNetworkList($scanned_wifi_networks.get());
}
}, SCAN_TIMEOUT_MS);
}
$scanned_wifi_networks.subscribe((networks) => {
if (isScanning && networks.length === 0) {
renderNetworkList([]);
return;
}
if (isScanning && networks.length > 0) {
isScanning = false;
scanTimedOut = false;
if (stationSSID) stationSSID.disabled = false;
if (scanTimeoutHandle) {
clearTimeout(scanTimeoutHandle);
scanTimeoutHandle = null;
}
}
applyFilter();
});
if (modal) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "open" && modal.open) {
// Full reset on every open so stale state never leaks between sessions
if (stationSSID) {
stationSSID.value = "";
stationSSID.classList.remove("input-error");
}
if (stationPassword) {
stationPassword.value = "";
stationPassword.classList.remove("input-error");
}
stationPasswordError?.classList.add("hidden");
stationSsidError?.classList.add("hidden");
filterText = "";
passwordFieldWrapper?.classList.add("hidden");
networkInfo?.classList.add("hidden");
if (networkInfo) networkInfo.innerHTML = "";
scanList?.classList.remove("hidden");
selectedNetworkForConfirm = null;
if (stationPassword) stationPassword.type = "password";
stationEyeOpen?.classList.remove("hidden");
stationEyeClosed?.classList.add("hidden");
performScan();
}
});
});
observer.observe(modal, { attributes: true, attributeFilter: ["open"] });
}
modal?.addEventListener("close", () => {
// reset the forms
showFormView();
pendingConfig = null;
if (stationSSID) {
stationSSID.value = "";
stationSSID.classList.remove("input-error");
}
if (stationPassword) {
stationPassword.value = "";
stationPassword.classList.remove("input-error");
}
if (staticIpToggle) staticIpToggle.checked = false;
if (staticIpFields) staticIpFields.classList.add("hidden");
passwordFieldWrapper?.classList.add("hidden");
refreshBtnRow?.classList.add("hidden");
if (stationSsidError) stationSsidError.classList.add("hidden");
if (stationPasswordError) stationPasswordError.classList.add("hidden");
// static configuration fields
staticIP_address_wrap?.classList.remove("input-error");
staticIP_address!.value = "";
staticIP_netmask_wrap?.classList.remove("input-error");
staticIP_netmask!.value = "";
staticIP_gateway_wrap?.classList.remove("input-error");
staticIP_gateway!.value = "";
staticIpError?.classList.add("hidden");
staticNetmaskError?.classList.add("hidden");
staticGatewayError?.classList.add("hidden");
isScanning = false;
scanTimedOut = false;
filterText = "";
if (scanTimeoutHandle) {
clearTimeout(scanTimeoutHandle);
scanTimeoutHandle = null;
}
networkInfo?.classList.add("hidden");
if (networkInfo) networkInfo.innerHTML = "";
scanList?.classList.remove("hidden");
selectedNetworkForConfirm = null;
if (stationPassword) stationPassword.type = "password";
stationEyeOpen?.classList.remove("hidden");
stationEyeClosed?.classList.add("hidden");
clear_scanned_wifi_networks();
renderNetworkList([]);
});
staticIpToggle?.addEventListener("change", () => {
if (staticIpToggle.checked) {
staticIpFields?.classList.remove("hidden");
// Pre-fill from saved static config, or fall back to current DHCP lease values
const cfg = $current_network_configuration.get();
if (!staticIP_address?.value) {
staticIP_address!.value =
cfg.Static_Cfg_IP_Address_Station ||
cfg.IP_Address_Station ||
"";
}
if (!staticIP_netmask?.value) {
staticIP_netmask!.value =
cfg.Static_Cfg_Netmask_Station ||
cfg.Netmask_Station ||
"";
}
if (!staticIP_gateway?.value) {
staticIP_gateway!.value =
cfg.Static_Cfg_Gateway_IP_Address_Station ||
cfg.Gateway_IP_Address_Station ||
"";
}
} else {
staticIpFields?.classList.add("hidden");
}
});
function showConfirmationView(config: NetworkConfigNVS) {
if (!confirmationView || !configFormView || !summaryIPMode) return;
// Hide form view and show confirmation view
configFormView.classList.add("hidden");
confirmationView.classList.remove("hidden");
confirmationView.classList.add("flex");
// Update button visibility
saveStationBtn?.classList.add("hidden");
applyBtn?.classList.remove("hidden");
backBtn?.classList.remove("hidden");
// Populate network entry card
if (confirmNetworkEntry) {
if (selectedNetworkForConfirm) {
const { ssid, signal, security, channel } = selectedNetworkForConfirm;
const si = getSignalStrengthInfo(signal);
confirmNetworkEntry.innerHTML = `
<div class="flex items-center gap-3 px-4 py-4 bg-base-200 rounded-lg border border-primary/30">
<div class="flex-1 min-w-0 flex items-center gap-1.5">
<span class="text-xs text-base-content/40 shrink-0">SSID</span>
<span class="text-base font-bold text-primary truncate">${ssid}</span>
</div>
<div class="hidden sm:block w-20 shrink-0 text-sm text-base-content/50 text-center">Channel&nbsp;${channel}</div>
<div class="w-5 shrink-0 flex items-center justify-center">${security !== "Open" ? lockIconHtml() : ""}</div>
<div class="shrink-0">${wifiIconHtml(si.bars)}</div>
<div class="hidden sm:block w-44 shrink-0 ml-2"><span class="badge badge-sm ${rssiBadgeClass(si.bars)}">${signal}&nbsp;dBm&nbsp;·&nbsp;${si.quality}</span></div>
</div>`;
} else {
confirmNetworkEntry.innerHTML = `
<div class="flex items-center gap-3 px-4 py-4 bg-base-200 rounded-lg border border-primary/30">
<div class="flex-1 min-w-0 flex items-center gap-1.5">
<span class="text-xs text-base-content/40 shrink-0">SSID</span>
<span class="text-base font-bold text-primary truncate">${config.SSID_Station}</span>
</div>
</div>`;
}
}
summaryIPMode.textContent = config.DHCP_Mode_Station ? "DHCP (Automatic)" : "Static IP";
if (
!config.DHCP_Mode_Station &&
summaryIPAddressRow &&
summaryNetmaskRow &&
summaryGatewayRow &&
summaryIPAddress &&
summaryNetmask &&
summaryGateway
) {
summaryIPAddressRow.classList.remove("hidden");
summaryNetmaskRow.classList.remove("hidden");
summaryGatewayRow.classList.remove("hidden");
summaryIPAddress.textContent = config.Static_Cfg_IP_Address_Station;
summaryNetmask.textContent = config.Static_Cfg_Netmask_Station;
summaryGateway.textContent = config.Static_Cfg_Gateway_IP_Address_Station;
} else {
if (summaryIPAddressRow) summaryIPAddressRow.classList.add("hidden");
if (summaryNetmaskRow) summaryNetmaskRow.classList.add("hidden");
if (summaryGatewayRow) summaryGatewayRow.classList.add("hidden");
}
if (ethernetWarning) {
ethernetWarning.classList.remove("hidden");
}
}
function showFormView() {
if (!confirmationView || !configFormView) return;
// Hide confirmation view and show form view
confirmationView.classList.add("hidden");
confirmationView.classList.remove("flex");
configFormView.classList.remove("hidden");
// Update button visibility
saveStationBtn?.classList.remove("hidden");
applyBtn?.classList.add("hidden");
backBtn?.classList.add("hidden");
}
let pendingConfig: NetworkConfigNVS | null = null;
saveStationBtn?.addEventListener("click", () => {
const _currCfg = $current_network_configuration.get();
const _newStaDHCP = !staticIpToggle?.checked; // toggle checked = static IP, unchecked = DHCP
const _staMode = _currCfg.DHCP_Mode_Ethernet
? (_newStaDHCP ? ConnectionMode.CONNECTION_MODE_ETH_DHCP_STA_DHCP : ConnectionMode.CONNECTION_MODE_ETH_DHCP_STA_STATIC)
: (_newStaDHCP ? ConnectionMode.CONNECTION_MODE_ETH_STATIC_STA_DHCP : ConnectionMode.CONNECTION_MODE_ETH_STATIC_STA_STATIC);
const _cfg = $current_network_configuration.get();
let temp_station_network_config: NetworkConfigNVS = {
Connection_Mode: _staMode,
Antenna_Power: _cfg.Antenna_Power,
Antenna_Selection: _cfg.Antenna_Selection,
DHCP_Mode_Station: _cfg.DHCP_Mode_Station,
DHCP_Mode_Ethernet: _cfg.DHCP_Mode_Ethernet,
AP_Channel: _cfg.AP_Channel,
WiFi_Enabled: _cfg.WiFi_Enabled,
IP_Address_Ethernet: _cfg.IP_Address_Ethernet,
IP_Address_Station: _cfg.IP_Address_Station,
Gateway_IP_Address_Ethernet: _cfg.Gateway_IP_Address_Ethernet,
Gateway_IP_Address_Station: _cfg.Gateway_IP_Address_Station,
Netmask_Ethernet: _cfg.Netmask_Ethernet,
Netmask_Station: _cfg.Netmask_Station,
Static_Cfg_IP_Address_Ethernet: _cfg.Static_Cfg_IP_Address_Ethernet,
Static_Cfg_IP_Address_Station: _cfg.Static_Cfg_IP_Address_Station,
Static_Cfg_Gateway_IP_Address_Station: "",
Static_Cfg_Gateway_IP_Address_Ethernet: _cfg.Static_Cfg_Gateway_IP_Address_Ethernet,
Static_Cfg_Netmask_Station: "",
Static_Cfg_Netmask_Ethernet: _cfg.Static_Cfg_Netmask_Ethernet,
SSID_Station: "",
Password_Station: "",
SSID_AP: _cfg.SSID_AP,
Password_AP: _cfg.Password_AP,
MDNS_Hostname: _cfg.MDNS_Hostname,
};
// reset the error messages
stationSsidError?.classList.add("hidden");
stationPasswordError?.classList.add("hidden");
staticIpError?.classList.add("hidden");
staticNetmaskError?.classList.add("hidden");
staticGatewayError?.classList.add("hidden");
// first validate the configuration!
if (!stationSSID.value) {
stationSSID.classList.add("input-error");
if (stationSsidError) { stationSsidError.textContent = "Select a network first."; stationSsidError.classList.remove("hidden"); }
return;
} else if (stationSSID.value.length > 31) {
stationSSID.classList.add("input-error");
if (stationSsidError) { stationSsidError.textContent = "SSID must be shorter than 32 characters."; stationSsidError.classList.remove("hidden"); }
return;
} else {
stationSSID.classList.remove("input-error");
stationSsidError?.classList.add("hidden");
}
const passwordRequired = passwordFieldWrapper && !passwordFieldWrapper.classList.contains("hidden");
if (passwordRequired && !stationPassword.value) {
stationPassword?.classList.add("input-error");
if (stationPasswordError) { stationPasswordError.textContent = "Password is required for secured networks."; stationPasswordError.classList.remove("hidden"); }
return;
} else if (stationPassword.value.length > 31) {
stationPassword?.classList.add("input-error");
if (stationPasswordError) { stationPasswordError.textContent = "Password must be shorter than 32 characters."; stationPasswordError.classList.remove("hidden"); }
return;
} else {
stationPassword?.classList.remove("input-error");
stationPasswordError?.classList.add("hidden");
}
if (staticIpToggle.checked) {
// validate static IP configuration
let isValid = true;
// FIRST: Validate netmask format and structure (must be checked before IP/Gateway)
if (!staticIP_netmask?.value || !is_valid_ip_format(staticIP_netmask?.value)) {
staticIP_netmask_wrap?.classList.add("input-error");
if (staticNetmaskError) {
staticNetmaskError.classList.remove("hidden");
staticNetmaskError.textContent = "Invalid Netmask format";
}
isValid = false;
// Scroll to error field to make it visible
staticIP_netmask.scrollIntoView({ behavior: "smooth", block: "center" });
} else if (!isValidNetmask(staticIP_netmask?.value)) {
staticIP_netmask_wrap?.classList.add("input-error");
if (staticNetmaskError) {
staticNetmaskError.classList.remove("hidden");
staticNetmaskError.textContent = "Invalid Netmask";
}
isValid = false;
// Scroll to error field to make it visible
staticIP_netmask.scrollIntoView({ behavior: "smooth", block: "center" });
} else if (!isPracticalNetmask(staticIP_netmask?.value)) {
staticIP_netmask_wrap?.classList.add("input-error");
if (staticNetmaskError) {
staticNetmaskError.classList.remove("hidden");
staticNetmaskError.textContent = "Netmask too restrictive (needs space for IP and Gateway)";
}
isValid = false;
// Scroll to error field to make it visible
staticIP_netmask.scrollIntoView({ behavior: "smooth", block: "center" });
} else {
staticNetmaskError?.classList.add("hidden");
staticIP_netmask_wrap?.classList.remove("input-error");
}
// Validate IP address format
if (!staticIP_address.value || !is_valid_ip_format(staticIP_address.value)) {
staticIP_address_wrap?.classList.add("input-error");
if (staticIpError) {
staticIpError.classList.remove("hidden");
staticIpError.textContent = "Invalid IP address format";
}
isValid = false;
// Scroll to error field to make it visible
staticIP_address.scrollIntoView({ behavior: "smooth", block: "center" });
} else {
staticIpError?.classList.add("hidden");
staticIP_address_wrap?.classList.remove("input-error");
}
// Validate gateway format
if (!staticIP_gateway.value || !is_valid_ip_format(staticIP_gateway.value)) {
staticIP_gateway_wrap?.classList.add("input-error");
if (staticGatewayError) {
staticGatewayError?.classList.remove("hidden");
staticGatewayError!.textContent = "Invalid Gateway IP format";
}
isValid = false;
// Scroll to error field to make it visible
staticIP_gateway.scrollIntoView({ behavior: "smooth", block: "center" });
} else {
staticGatewayError?.classList.add("hidden");
staticIP_gateway_wrap?.classList.remove("input-error");
}
// If all formats are valid, check IP range and subnet consistency
if (isValid) {
const ip = staticIP_address.value;
const netmask = staticIP_netmask.value;
const gateway = staticIP_gateway.value;
// Check if IP is in valid host range (not network or broadcast address)
if (!isInValidHostRange(ip, netmask)) {
staticIP_address_wrap?.classList.add("input-error");
staticIpError?.classList.remove("hidden");
staticIpError!.textContent = "IP address cannot be network or broadcast address";
isValid = false;
staticIP_address.scrollIntoView({ behavior: "smooth", block: "center" });
}
// Check if gateway is in the same subnet as the IP
if (!isInSameSubnet(ip, gateway, netmask)) {
// Show error on both IP and Gateway fields for better UX
staticIP_address_wrap?.classList.add("input-error");
staticIpError?.classList.remove("hidden");
staticIpError!.textContent = "IP and Gateway must be in the same subnet";
staticIP_gateway_wrap?.classList.add("input-error");
staticGatewayError?.classList.remove("hidden");
staticGatewayError!.textContent = "IP and Gateway must be in the same subnet";
isValid = false;
// Scroll to first error field
staticIP_address.scrollIntoView({ behavior: "smooth", block: "center" });
}
// Check if gateway is in valid host range
if (isInSameSubnet(ip, gateway, netmask) && !isInValidHostRange(gateway, netmask)) {
staticIP_gateway_wrap?.classList.add("input-error");
staticGatewayError?.classList.remove("hidden");
staticGatewayError!.textContent = "Gateway cannot be network or broadcast address";
isValid = false;
staticIP_gateway.scrollIntoView({ behavior: "smooth", block: "center" });
}
// check if gateway is same as host IP
if (ip === gateway) {
staticIP_gateway_wrap?.classList.add("input-error");
staticGatewayError?.classList.remove("hidden");
staticGatewayError!.textContent = "Gateway cannot be the same as host IP";
isValid = false;
staticIP_gateway.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
if (!isValid) {
return;
} else {
// Static IP configuration is valid --> set network config!
temp_station_network_config.Static_Cfg_IP_Address_Station = staticIP_address.value;
temp_station_network_config.Static_Cfg_Netmask_Station = staticIP_netmask.value;
temp_station_network_config.Static_Cfg_Gateway_IP_Address_Station = staticIP_gateway.value;
temp_station_network_config.SSID_Station = stationSSID.value;
temp_station_network_config.Password_Station = stationPassword.value;
temp_station_network_config.DHCP_Mode_Station = false;
temp_station_network_config.WiFi_Enabled = true;
}
} else {
// enable DHCP mode
temp_station_network_config.Static_Cfg_IP_Address_Station = "";
temp_station_network_config.Static_Cfg_Netmask_Station = "";
temp_station_network_config.Static_Cfg_Gateway_IP_Address_Station = "";
temp_station_network_config.SSID_Station = stationSSID.value;
temp_station_network_config.Password_Station = stationPassword.value;
temp_station_network_config.DHCP_Mode_Station = true;
temp_station_network_config.WiFi_Enabled = true;
}
// Store the config and show confirmation view
pendingConfig = temp_station_network_config;
showConfirmationView(temp_station_network_config);
});
// Confirm and apply button handler
applyBtn?.addEventListener("click", () => {
if (!pendingConfig) return;
// send configuration to Alpha!
if (send_new_network_configuration_to_alpha(pendingConfig)) {
window.dispatchEvent(new CustomEvent("network-config-applying"));
modal?.close();
pendingConfig = null;
} else {
// Show error or keep modal open
return;
}
});
// Back button handler
backBtn?.addEventListener("click", () => {
showFormView();
pendingConfig = null;
});
</script>

View file

@ -1,45 +0,0 @@
import { useStore } from '@nanostores/preact';
import { $testbenchWebsocketConnectionStatus } from './TestbenchStore';
export default function TestbenchConnectionStatusBadge() {
const status = useStore($testbenchWebsocketConnectionStatus);
let colorClass = 'bg-gray-400 text-white';
if (status.startsWith('Connected')) colorClass = 'bg-green-500 text-white';
else if (status.startsWith('Connecting')) colorClass = 'bg-yellow-400 text-black';
else if (
status.startsWith('Error') ||
status.startsWith('Failed') ||
status.startsWith('Disconnected')
)
colorClass = 'bg-red-500 text-white';
// Passe die Breite ggf. an den längsten Status an!
const maxText = 'Connecting ...';
return (
<span
class={`badge ${colorClass} font-mono relative inline-block transition-all duration-200`}
style={{ width: '120px', minWidth: '120px', maxWidth: '120px', padding: 0 }}
>
{/* Unsichtbarer Platzhalter für feste Breite und Position */}
<span
class="opacity-0 select-none pointer-events-none"
style={{
display: 'inline-block',
width: '100%',
textAlign: 'left',
}}
>
{maxText}
</span>
{/* Sichtbarer Status absolut darüber */}
<span
class="absolute left-0 top-0 w-full h-full flex items-center"
style={{ justifyContent: 'left', paddingLeft: '0.75rem' /* ggf. anpassen */ }}
>
{status}
</span>
</span>
);
}

View file

@ -1,692 +0,0 @@
---
import TestbenchConnectionStatusBadge from "./TestbenchConnectionStatusBadge.tsx";
const pageTitle = "Test Bench Dashboard";
---
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">{pageTitle}</h1>
<!-- Board Connection Status -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title">Board Connection Status</h2>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="badge badge-lg" id="board-status-badge">
<span class="mr-2">●</span>
<span id="board-status-text">Disconnected</span>
</div>
<div class="badge badge-lg badge-neutral" id="vcc-display">
VCC: 12V
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">Testbench:</span>
<TestbenchConnectionStatusBadge client:load />
</div>
</div>
<div class="flex items-center gap-2"></div>
</div>
<!-- Control Panel -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<!-- Board Power Relay -->
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg p-4">
<h2 class="card-title text-lg mb-3">Power Control</h2>
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Board Relay:</span>
<input
type="checkbox"
class="toggle border-red-600 bg-red-500 checked:border-orange-500 checked:bg-orange-400"
id="power-relay-toggle"
/>
</div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium">GND Sense:</span>
<div class="badge badge-lg" id="gnd-sense-status">
<span class="mr-2">●</span>
<span id="gnd-sense-text">No Board Connected</span>
</div>
</div>
</div>
</div>
<!-- UART Routing -->
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg p-4">
<h2 class="card-title text-lg mb-3">UART Routing</h2>
<div class="join w-full">
<input
class="join-item btn flex-1"
type="radio"
name="uart_routing"
aria-label="ST"
id="uart-st"
checked
/>
<input
class="join-item btn flex-1"
type="radio"
name="uart_routing"
aria-label="P4"
id="uart-p4"
/>
<input
class="join-item btn flex-1"
type="radio"
name="uart_routing"
aria-label="C6"
id="uart-c6"
/>
</div>
</div>
</div>
<!-- Bootloader Control -->
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg p-4">
<h2 class="card-title text-lg mb-3">Bootloader Control</h2>
<div class="space-y-2">
<button
class="btn btn-error btn-sm w-full"
id="p4-bootloader-btn"
>
P4 Bootloader OFF
</button>
<button
class="btn btn-error btn-sm w-full"
id="c6-bootloader-btn"
>
C6 Bootloader OFF
</button>
</div>
</div>
</div>
<!-- Enable Control -->
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg p-4">
<h2 class="card-title text-lg mb-3">Enable Control</h2>
<div class="space-y-2">
<button class="btn btn-error btn-sm w-full" id="p4-enable-btn">
P4 Disabled
</button>
<button class="btn btn-error btn-sm w-full" id="c6-enable-btn">
C6 Disabled
</button>
<button class="btn btn-error btn-sm w-full" id="st-enable-btn">
ST Disabled
</button>
</div>
</div>
</div>
</div>
<!-- UART Console -->
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title text-lg mb-3">UART Console</h2>
<!-- Console Output -->
<div
class="mockup-code w-full h-64 overflow-y-auto mb-4"
id="uart-console"
>
<pre
data-prefix=">"
class="text-info"><code>UART Console Ready</code></pre>
<pre
data-prefix="RX"
class="text-success"><code>Waiting for data...</code></pre>
</div>
<!-- Input Controls -->
<div class="flex gap-2">
<input
type="text"
placeholder="Enter UART message"
class="input input-bordered flex-1"
id="uart-input"
/>
<button class="btn btn-primary" id="send-uart">Send</button>
<button class="btn btn-outline btn-sm" id="clear-console"
>Clear</button
>
</div>
</div>
</div>
<!-- Number Conversion Utility -->
<div class="card bg-base-100 shadow">
<div class="card-body bg-base-200 rounded-lg">
<h2 class="card-title text-lg mb-3">Number Conversion Utility</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Decimal Input -->
<div class="form-control">
<label class="label">
<span class="label-text">Decimal</span>
</label>
<input
type="number"
placeholder="255"
class="input input-bordered"
id="decimal-input"
/>
</div>
<!-- Hex Output -->
<div class="form-control">
<label class="label">
<span class="label-text">Hexadecimal</span>
</label>
<input
type="text"
placeholder="0xFF"
class="input input-bordered"
id="hex-output"
readonly
/>
</div>
<!-- Binary Output -->
<div class="form-control">
<label class="label">
<span class="label-text">Binary</span>
</label>
<input
type="text"
placeholder="11111111"
class="input input-bordered"
id="binary-output"
readonly
/>
</div>
</div>
<div class="flex gap-2 mt-4">
<button class="btn btn-outline btn-sm" id="copy-hex"
>Copy Hex</button
>
<button class="btn btn-outline btn-sm" id="copy-binary"
>Copy Binary</button
>
<button class="btn btn-outline btn-sm" id="clear-converter"
>Clear</button
>
</div>
</div>
</div>
</div>
<script>
import {
$testbenchStates,
sendCommandToBackend,
TESTBENCH_COMMANDS,
} from "./TestbenchStore.tsx";
import { $testbenchWebsocketConnectionStatus } from "./TestbenchStore.tsx";
// Extend Window interface for custom properties
declare global {
interface Window {
updateTestBenchStatus: (data: any) => void;
}
}
// State management
let currentUartRouting = "st"; // 'st', 'p4', 'c6' - default to ST
let p4BootloaderActive = false;
let c6BootloaderActive = false;
let p4Enabled = false;
let c6Enabled = false;
let stEnabled = false;
let boardConnected = false;
let powerRelayEnabled = false;
let gndSense = false;
let vccVoltage = 12; // Default VCC voltage
// Update board connection status
// Global function to update WebSocket connection status (for HydroBuddy address)
function updateWebSocketStatus(connected: boolean) {
const badge = document.getElementById("board-status-badge");
const text = document.getElementById("board-status-text");
if (badge && text) {
if (connected) {
badge.className = "badge badge-lg badge-success";
text.textContent = "Connected";
} else {
badge.className = "badge badge-lg badge-error";
text.textContent = "Disconnected";
}
}
}
// Global function to update board connection status (for GND Sense)
function updateBoardConnectionStatus(connected: boolean) {
boardConnected = connected;
const badge = document.getElementById("board-connection-badge");
const text = document.getElementById("board-connection-text");
if (badge && text) {
if (connected) {
badge.className = "badge badge-lg badge-success";
text.textContent = "Board Connected";
} else {
badge.className = "badge badge-lg badge-error";
text.textContent = "Board Disconnected";
}
}
}
// Update VCC voltage display
function updateVccDisplay(voltage: number) {
vccVoltage = voltage;
const vccDisplay = document.getElementById("vcc-display");
if (vccDisplay) {
vccDisplay.textContent = `VCC: ${voltage}V`;
}
}
// Update GND Sense status display (based on boardConnected status)
function updateGndSenseStatus(boardConnected: boolean) {
gndSense = !boardConnected; // GND Sense is LOW when board is connected
// Update both GND Sense display and Board Connection display
const gndBadge = document.getElementById("gnd-sense-status");
const gndText = document.getElementById("gnd-sense-text");
if (gndBadge && gndText) {
if (boardConnected) {
gndBadge.className = "badge badge-lg badge-success";
gndText.textContent = "Connected";
} else {
gndBadge.className = "badge badge-lg badge-error";
gndText.textContent = "Not Connected";
}
}
// Also update the board connection status
updateBoardConnectionStatus(boardConnected);
}
// Toggle power relay
function togglePowerRelay() {
if ($testbenchStates.get().powerRelayEnabled) {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_POWER_RELAY_OFF);
} else {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_POWER_RELAY_ON);
}
}
// Update UART routing display
function updateUartRoutingDisplay() {
const buttons = document.querySelectorAll('input[name="uart_routing"]');
const currentState = $testbenchStates.get();
// Update button selection
buttons.forEach((button) => {
const inputButton = button as HTMLInputElement;
if (inputButton) {
const label = inputButton.getAttribute("aria-label")?.toLowerCase();
if (label) {
inputButton.checked = label === currentState.uartRouting;
}
}
});
}
// Set UART routing
function setUartRouting(target: string) {
if (target === "st") {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_UART_ROUTING_ST);
} else if (target === "c6") {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_UART_ROUTING_C6);
} else if (target === "p4") {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_UART_ROUTING_P4);
}
console.log(`Clicked UART routing button: ${target}`);
}
// Update bootloader status
function updateBootloaderStatus(target: string, active: boolean) {
const button = document.getElementById(`${target}-bootloader-btn`);
if (!button) return;
if (active) {
button.textContent = `${target.toUpperCase()} Bootloader ON`;
button.className = "btn btn-success btn-sm w-full";
} else {
button.textContent = `${target.toUpperCase()} Bootloader OFF`;
button.className = "btn btn-error btn-sm w-full";
}
}
// Toggle bootloader
function toggleBootloader(target: string) {
if (target === "p4") {
if ($testbenchStates.get().p4BootloaderActive) {
// Turn off P4 bootloader
sendCommandToBackend(TESTBENCH_COMMANDS.SET_P4_BOOTLOADER_OFF);
console.log("Turned off P4 bootloader");
} else {
// Turn on P4 bootloader, turn off C6 bootloader and enable P4
sendCommandToBackend(TESTBENCH_COMMANDS.SET_P4_BOOTLOADER_ON);
console.log("Turned on P4 bootloader");
}
} else if (target === "c6") {
if ($testbenchStates.get().c6BootloaderActive) {
// Turn off C6 bootloader
sendCommandToBackend(TESTBENCH_COMMANDS.SET_C6_BOOTLOADER_OFF);
console.log("Turned off C6 bootloader");
} else {
// Turn on C6 bootloader, turn off P4 bootloader and enable C6
sendCommandToBackend(TESTBENCH_COMMANDS.SET_C6_BOOTLOADER_ON);
console.log("Turned on C6 bootloader");
}
}
}
// Update enable status based on testbench states
function updateEnableStatus(target: string, enabled: boolean) {
const button = document.getElementById(`${target}-enable-btn`);
if (!button) return;
// Get current testbench states
const states = $testbenchStates.get();
// Check if target is actually enabled in the store
const isActuallyEnabled = states[
`${target}Enabled` as keyof typeof states
] as boolean;
if (isActuallyEnabled) {
button.textContent = `${target.toUpperCase()} Enabled`;
button.className = "btn btn-success btn-sm w-full";
} else {
button.textContent = `${target.toUpperCase()} Disabled`;
button.className = "btn btn-error btn-sm w-full";
}
}
// Toggle enable
function toggleEnable(target: string) {
const currentState = $testbenchStates.get();
switch (target) {
case "p4":
if (!currentState.c6BootloaderActive) {
// Only allow if not in bootloader mode
if (currentState.p4Enabled) {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_P4_DISABLED);
} else {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_P4_ENABLED);
}
}
break;
case "c6":
if (!currentState.p4BootloaderActive) {
// Only allow if not in bootloader mode
if (currentState.c6Enabled) {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_C6_DISABLED);
} else {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_C6_ENABLED);
}
}
break;
case "st":
if (currentState.stEnabled) {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_ST_DISABLED);
} else {
sendCommandToBackend(TESTBENCH_COMMANDS.SET_ST_ENABLED);
}
break;
}
// Update UI based on current state
const state = $testbenchStates.get();
updateEnableStatus("p4", state.p4Enabled);
updateEnableStatus("c6", state.c6Enabled);
updateEnableStatus("st", state.stEnabled);
console.log(
`P4 Enable: ${state.p4Enabled}, C6 Enable: ${state.c6Enabled}, ST Enable: ${state.stEnabled}`
);
}
// Add message to console (global function)
function addConsoleMessage(
prefix: string,
message: string,
className = ""
) {
const uartConsole = document.getElementById("uart-console");
if (uartConsole) {
const pre = document.createElement("pre");
pre.setAttribute("data-prefix", prefix);
if (className) pre.className = className;
const code = document.createElement("code");
code.textContent = message;
pre.appendChild(code);
uartConsole.appendChild(pre);
uartConsole.scrollTop = uartConsole.scrollHeight;
}
}
// Event listeners
document.addEventListener("DOMContentLoaded", function () {
// UART Console functionality
const uartConsole = document.getElementById("uart-console");
const uartInput = document.getElementById("uart-input");
const sendButton = document.getElementById("send-uart");
const clearButton = document.getElementById("clear-console");
// Number conversion functionality
const decimalInput = document.getElementById("decimal-input");
const hexOutput = document.getElementById("hex-output");
const binaryOutput = document.getElementById("binary-output");
const copyHexBtn = document.getElementById("copy-hex");
const copyBinaryBtn = document.getElementById("copy-binary");
const clearConverterBtn = document.getElementById("clear-converter");
// Send UART message
function sendUartMessage() {
const input = uartInput as HTMLInputElement;
if (!input.value.trim()) return;
const message = input.value.trim();
// Display sent message
addConsoleMessage("TX", message, "text-warning");
// TODO: Send via WebSocket as text
console.log("Sending UART text:", message);
input.value = "";
}
// Clear console
function clearConsole() {
if (uartConsole) {
uartConsole.innerHTML =
'<pre data-prefix=">" class="text-info"><code>Console cleared</code></pre>';
}
}
// Event listeners for UART console
if (sendButton) {
sendButton.addEventListener("click", sendUartMessage);
}
if (clearButton) {
clearButton.addEventListener("click", clearConsole);
}
if (uartInput) {
uartInput.addEventListener("keypress", function (e) {
if (e.key === "Enter") {
sendUartMessage();
}
});
}
// Event listeners for number conversion
if (decimalInput) {
decimalInput.addEventListener("input", updateConversion);
}
if (copyHexBtn) {
copyHexBtn.addEventListener("click", () => {
const hexOut = hexOutput as HTMLInputElement;
if (hexOut.value) copyToClipboard(hexOut.value);
});
}
if (copyBinaryBtn) {
copyBinaryBtn.addEventListener("click", () => {
const binOut = binaryOutput as HTMLInputElement;
if (binOut.value) copyToClipboard(binOut.value);
});
}
if (clearConverterBtn) {
clearConverterBtn.addEventListener("click", clearConverter);
}
// Number conversion functions
function updateConversion() {
const decInput = decimalInput as HTMLInputElement;
const hexOut = hexOutput as HTMLInputElement;
const binOut = binaryOutput as HTMLInputElement;
const decimal = parseInt(decInput.value);
if (isNaN(decimal) || decInput.value === "") {
hexOut.value = "";
binOut.value = "";
return;
}
// Convert to hex (with 0x prefix)
hexOut.value =
"0x" + decimal.toString(16).toUpperCase().padStart(2, "0");
// Convert to binary (8-bit)
binOut.value = decimal.toString(2).padStart(8, "0");
}
function copyToClipboard(text: string) {
navigator.clipboard
.writeText(text)
.then(() => {
console.log("Copied to clipboard:", text);
})
.catch((err) => {
console.error("Failed to copy:", err);
});
}
function clearConverter() {
const decInput = decimalInput as HTMLInputElement;
const hexOut = hexOutput as HTMLInputElement;
const binOut = binaryOutput as HTMLInputElement;
decInput.value = "";
hexOut.value = "";
binOut.value = "";
}
// Simulate incoming UART data (remove this when connecting to real WebSocket)
setTimeout(() => {
addConsoleMessage("RX", "Hello from device!", "text-success");
}, 2000);
// UART routing buttons
document
.querySelectorAll('input[name="uart_routing"]')
.forEach((tab) => {
const inputTab = tab as HTMLInputElement;
inputTab.addEventListener("change", function () {
if (this.checked) {
const target = this.getAttribute("aria-label")?.toLowerCase();
if (target) {
setUartRouting(target);
}
}
});
});
const p4BootloaderBtn = document.getElementById("p4-bootloader-btn");
const c6BootloaderBtn = document.getElementById("c6-bootloader-btn");
const p4EnableBtn = document.getElementById("p4-enable-btn");
const c6EnableBtn = document.getElementById("c6-enable-btn");
const stEnableBtn = document.getElementById("st-enable-btn");
const powerRelayToggle = document.getElementById("power-relay-toggle");
// Bootloader buttons
if (p4BootloaderBtn) {
p4BootloaderBtn.addEventListener("click", () =>
toggleBootloader("p4")
);
}
if (c6BootloaderBtn) {
c6BootloaderBtn.addEventListener("click", () =>
toggleBootloader("c6")
);
}
// Enable buttons
if (p4EnableBtn) {
p4EnableBtn.addEventListener("click", () => toggleEnable("p4"));
}
if (c6EnableBtn) {
c6EnableBtn.addEventListener("click", () => toggleEnable("c6"));
}
if (stEnableBtn) {
stEnableBtn.addEventListener("click", () => toggleEnable("st"));
}
// Power relay toggle
if (powerRelayToggle) {
powerRelayToggle.addEventListener("change", togglePowerRelay);
}
// Initialize display
updateWebSocketStatus(false);
updateVccDisplay(12);
updateUartRoutingDisplay();
updateBootloaderStatus("p4", false);
updateBootloaderStatus("c6", false);
updateEnableStatus("p4", false);
updateEnableStatus("c6", false);
updateEnableStatus("st", false);
});
// Subscribe to testbench state changes for automatic UI updates
$testbenchStates.subscribe((newStates) => {
// Update enable status buttons based on store state
updateEnableStatus("p4", newStates.p4Enabled);
updateEnableStatus("c6", newStates.c6Enabled);
updateEnableStatus("st", newStates.stEnabled);
// Update bootloader status
updateBootloaderStatus("p4", newStates.p4BootloaderActive);
updateBootloaderStatus("c6", newStates.c6BootloaderActive);
// Update UART routing
currentUartRouting = newStates.uartRouting;
updateUartRoutingDisplay();
// Update power relay
powerRelayEnabled = newStates.powerRelayEnabled;
const toggle = document.getElementById(
"power-relay-toggle"
) as HTMLInputElement;
if (toggle) {
toggle.checked = powerRelayEnabled;
}
updateGndSenseStatus(newStates.boardConnected);
});
</script>
</div>
</div>

View file

@ -1,143 +0,0 @@
import { atom } from 'nanostores';
import pkg from "websocket";
const { w3cwebsocket } = pkg;
export const $testbenchWebsocket = atom<WebSocket | null>(null);
export const $testbenchWebsocketConnectionStatus = atom<string>('Disconnected');
export function setTestbenchWebsocket(websocket: WebSocket) {
$testbenchWebsocket.set(websocket);
}
export function setTestbenchWebsocketConnectionStatus(status: string) {
$testbenchWebsocketConnectionStatus.set(status);
}
export const TESTBENCH_COMMANDS = {
SET_UART_ROUTING_ST: 0,
SET_UART_ROUTING_C6: 1,
SET_UART_ROUTING_P4: 2,
SET_P4_BOOTLOADER_ON: 3,
SET_P4_BOOTLOADER_OFF: 4,
SET_C6_BOOTLOADER_ON: 5,
SET_C6_BOOTLOADER_OFF: 6,
SET_P4_ENABLED: 7,
SET_P4_DISABLED: 8,
SET_C6_ENABLED: 9,
SET_C6_DISABLED: 10,
SET_ST_ENABLED: 11,
SET_ST_DISABLED: 12,
SET_POWER_RELAY_ON: 13,
SET_POWER_RELAY_OFF: 14,
};
// GPIO Pin Mapping Constants
export const GPIO_PINS = {
// Communication
SDA: 1,
SCL: 2,
DTR: 3,
RTS: 14,
FTDI_UART_TX: 17,
FTDI_UART_RX: 18,
// Switches
SW_A: 4,
SW_B: 5,
SW_C: 6,
SW_D: 7,
// Route switches
SW_ST_C6: 12,
SW_C6_P4: 13,
// Power & Sensing
BOARD_POWER: 15,
GND_SENSE: 16,
// P4 Bootloader Pins
P4_35: 47,
P4_36: 21,
P4_ENABLE: 9,
// STM32
STM32_RESET: 38,
// C6 Control
C6_ENABLE: 10,
C6_BOOT: 8,
} as const;
// GPIO State Store
export const $gpioStates = atom<Record<number, boolean>>({
[GPIO_PINS.SW_A]: false,
[GPIO_PINS.SW_B]: false,
[GPIO_PINS.SW_C]: false,
[GPIO_PINS.SW_D]: false,
[GPIO_PINS.BOARD_POWER]: false,
[GPIO_PINS.GND_SENSE]: true,
[GPIO_PINS.P4_35]: true,
[GPIO_PINS.P4_36]: false,
[GPIO_PINS.STM32_RESET]: false,
[GPIO_PINS.C6_ENABLE]: false,
[GPIO_PINS.C6_BOOT]: true,
});
// Testbench State Type
export type TestbenchState = {
boardConnected: boolean;
vccVoltage: number;
powerRelayEnabled: boolean;
uartRouting: 'st' | 'p4' | 'c6';
p4BootloaderActive: boolean;
c6BootloaderActive: boolean;
p4Enabled: boolean;
c6Enabled: boolean;
stEnabled: boolean;
gndSense: boolean;
};
// Testbench Control States
export const $testbenchStates = atom<TestbenchState>({
// Board Connection
boardConnected: false,
vccVoltage: 12,
// Power Control
powerRelayEnabled: false,
// UART Routing
uartRouting: 'p4',
// Bootloader States
p4BootloaderActive: false,
c6BootloaderActive: false,
// Enable States
p4Enabled: false,
c6Enabled: false,
stEnabled: false,
gndSense: true,
});
export function getGpioState(pin: number): boolean {
return $gpioStates.get()[pin] || false;
}
export function sendCommandToBackend(command: number) {
const websocket = $testbenchWebsocket.get();
if (websocket && websocket.readyState === WebSocket.OPEN) {
const message = {
message_type: "testbench_command",
command: command
};
try {
websocket.send(JSON.stringify(message));
console.log("Testbench command sent:", command);
} catch (error) {
console.error("Failed to send Testbench command:", error);
}
}
}

View file

@ -1,227 +0,0 @@
import { useEffect } from 'preact/hooks';
import { $gpioStates, $testbenchWebsocket, $testbenchWebsocketConnectionStatus, GPIO_PINS,
setTestbenchWebsocketConnectionStatus, $testbenchStates } from './TestbenchStore';
// Heartbeat configuration
const HEARTBEAT_INTERVAL = 1500; // Send ping every 1 second
const HEARTBEAT_TIMEOUT = 700; // Wait 1 second for pong response
const MAX_MISSED_HEARTBEATS = 5; // Close connection after 5 missed pongs
// Heartbeat state
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;
let missedHeartbeats = 0;
// Reconnect state
let isReconnecting = false;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
// Restart detection state
let disconnectDetected = false;
function onMessage(message: MessageEvent) {
//console.log("WebSocket message received:", message.data.toString());
// Verarbeite JSON Nachrichten
const dataFromServer = JSON.parse(message.data.toString());
if (dataFromServer.message_type === "pong") {
// reset missed heartbeats and clear pending timeout
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
missedHeartbeats = 0;
console.log("Heartbeat pong received");
}
else if (dataFromServer.message_type === "initial_testbench_state_response" || dataFromServer.message_type === "testbench_state_response") {
console.log("Testbench state response received");
console.log("Testbench state:", dataFromServer);
if (dataFromServer.gpio_states) {
$gpioStates.set({
[GPIO_PINS.SW_A]: dataFromServer.gpio_states["4"],
[GPIO_PINS.SW_B]: dataFromServer.gpio_states["5"],
[GPIO_PINS.SW_C]: dataFromServer.gpio_states["6"],
[GPIO_PINS.SW_D]: dataFromServer.gpio_states["7"],
[GPIO_PINS.BOARD_POWER]: dataFromServer.gpio_states["15"],
[GPIO_PINS.GND_SENSE]: dataFromServer.gpio_states["16"],
[GPIO_PINS.P4_35]: dataFromServer.gpio_states["47"],
[GPIO_PINS.P4_36]: dataFromServer.gpio_states["21"],
[GPIO_PINS.STM32_RESET]: dataFromServer.gpio_states["38"],
[GPIO_PINS.C6_ENABLE]: dataFromServer.gpio_states["10"],
[GPIO_PINS.C6_BOOT]: dataFromServer.gpio_states["8"],
});
$testbenchStates.set({
boardConnected: dataFromServer.testbenchStates.boardConnected,
vccVoltage: dataFromServer.testbenchStates.vccVoltage,
powerRelayEnabled: dataFromServer.testbenchStates.powerRelayEnabled,
uartRouting: dataFromServer.testbenchStates.uartRouting,
p4BootloaderActive: dataFromServer.testbenchStates.p4BootloaderActive,
c6BootloaderActive: dataFromServer.testbenchStates.c6BootloaderActive,
p4Enabled: dataFromServer.testbenchStates.p4Enabled,
c6Enabled: dataFromServer.testbenchStates.c6Enabled,
stEnabled: dataFromServer.testbenchStates.stEnabled,
gndSense: dataFromServer.testbenchStates.gndSense,
});
}
}
}
const connectWebSocket = () => {
// Clear any existing reconnect attempts
clearReconnect();
const testbenchWSaddress = "ws://testbench.local/ws";
console.log(`Verbinde mit: ${testbenchWSaddress}`);
if ($testbenchWebsocket.get() !== null) {
($testbenchWebsocket.get() as WebSocket).close(1000);
$testbenchWebsocket.set(null);
}
$testbenchWebsocket.set(new WebSocket(testbenchWSaddress));
if ($testbenchWebsocket.get() !== null) {
($testbenchWebsocket.get() as WebSocket).onopen = onOpen;
($testbenchWebsocket.get() as WebSocket).onmessage = onMessage;
($testbenchWebsocket.get() as WebSocket).onerror = onError;
($testbenchWebsocket.get() as WebSocket).onclose = onClose;
}
}
function clearReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
isReconnecting = false;
}
function onOpen() {
setTestbenchWebsocketConnectionStatus("Connected");
console.log("WebSocket erfolgreich verbunden");
missedHeartbeats = 0;
// Start heartbeat mechanism
startHeartbeat();
// Sende initiale Statusanfrage
if ($testbenchWebsocket.get() !== null) {
const initial_state_request = JSON.stringify({ message_type: "initial_testbench_state_request" });
try {
($testbenchWebsocket.get() as WebSocket).send(initial_state_request);
} catch (error) {
console.error("Failed to send initial testbench state request:", error);
}
}
}
function onClose(event: CloseEvent) {
$testbenchWebsocketConnectionStatus.set("Disconnected");
console.log("Testbench WebSocket closed with code:", event.code);
console.log("Testbench WebSocket close reason:", event.reason);
console.log("Testbench WebSocket clean close:", event.wasClean);
clearHeartbeat();
if (event.code === 1000) {
console.log("Normal closure");
}
scheduleReconnect();
}
function startHeartbeat() {
clearHeartbeat(); // Clear any existing heartbeat
heartbeatInterval = setInterval(() => {
sendHeartbeat();
}, HEARTBEAT_INTERVAL);
}
function clearHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
missedHeartbeats = 0;
}
function sendHeartbeat() {
if ($testbenchWebsocket.get() && ($testbenchWebsocket.get() as WebSocket).readyState === ($testbenchWebsocket.get() as WebSocket).OPEN) {
// Send ping message
const pingMessage = JSON.stringify({ message_type: "ping" });
try {
($testbenchWebsocket.get() as WebSocket).send(pingMessage);
} catch (error) {
console.error("Failed to send heartbeat ping:", error);
missedHeartbeats++;
return;
}
// Set timeout for pong response
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
heartbeatTimeout = setTimeout(() => {
missedHeartbeats++;
if (missedHeartbeats >= MAX_MISSED_HEARTBEATS) {
console.error("Too many missed heartbeats, closing connection");
// Wichtig: Erst Heartbeat stoppen
clearHeartbeat();
// Dann WebSocket schließen und null setzen
if ($testbenchWebsocket.get()) {
($testbenchWebsocket.get() as WebSocket).close();
setTestbenchWebsocketConnectionStatus("Disconnected");
$testbenchWebsocket.set(null);
}
}
}, HEARTBEAT_TIMEOUT);
}
}
function onError(error: Event) {
$testbenchWebsocketConnectionStatus.set("Disconnected");
console.log("WebSocket error:", error);
clearHeartbeat();
scheduleReconnect();
}
function scheduleReconnect() {
if (isReconnecting) {
console.log("Reconnect already scheduled, skipping");
return;
}
isReconnecting = true;
console.log("Scheduling reconnect in 1 second");
reconnectTimeout = setTimeout(() => {
connectWebSocket();
isReconnecting = false;
}, 1000);
}
export const TestbenchWebSocketProvider = () => {
// Auto-connect WebSocket at first render
useEffect(() => {
console.log("Component mounted, starting WebSocket connection...");
connectWebSocket();
// Cleanup beim Unmounting
return () => {
clearHeartbeat();
clearReconnect();
($testbenchWebsocket.get() as WebSocket).close();
$testbenchWebsocket.set(null);
$testbenchWebsocketConnectionStatus.set("Disconnected");
};
}, []);
};

View file

@ -1,186 +0,0 @@
import { useStore } from '@nanostores/preact';
import { IconCpu2, IconWifi, IconCpu, IconFileTypeJs } from '@tabler/icons-preact';
import {
$availableUpdates,
$updateState,
$updateErrorMessages,
$p4FirmwareProgress,
$p4WebinterfaceProgress,
$stm32FirmwareProgress,
$c6FirmwareProgress,
stateSetters,
start_firmware_update_p4_fw,
start_firmware_update_p4_web,
start_firmware_update_stm32_fw,
start_firmware_update_c6_fw,
type UpdateInfo,
request_available_updates
} from './AppProviderStore';
interface UpdateErrorPopupProps {
isOpen: boolean;
onClose: () => void;
}
// Function to get the appropriate icon for each update type
const getUpdateIcon = (core: string) => {
switch (core.toLowerCase()) {
case 'stm32':
return (
<IconCpu2 strokeWidth={2} />
);
case 'p4 firmware':
return (
<IconCpu strokeWidth={2} />
);
case 'c6':
return (
<IconWifi strokeWidth={2} />
);
default:
return (
<IconFileTypeJs strokeWidth={2} />
);
}
};
// Helper function to get progress for a specific update type
const getProgressForUpdate = (core: string) => {
switch (core.toLowerCase()) {
case 'p4 firmware': return $p4FirmwareProgress.get();
case 'p4 webinterface': return $p4WebinterfaceProgress.get();
case 'stm32': return $stm32FirmwareProgress.get();
case 'c6': return $c6FirmwareProgress.get();
default: return [0, 0];
}
};
// Map core types to their target indices
const getTargetIndex = (core: string) => {
switch (core.toLowerCase()) {
case 'p4 webinterface': return 0; // P4 webinterface (first)
case 'p4 firmware': return 1; // P4 firmware (second)
case 'stm32': return 2; // STM32 firmware
case 'c6': return 3; // C6 firmware (last)
default: return -1;
}
};
export default function UpdateErrorPopup({ isOpen, onClose }: UpdateErrorPopupProps) {
const availableUpdates = useStore($availableUpdates);
const updateState = useStore($updateState);
const updateErrorMessages = useStore($updateErrorMessages);
// Only show if update state is error
if (!isOpen || updateState !== 'error') return null;
const handleClose = () => {
// Don't reset update state here
onClose();
// reset update status:
stateSetters.resetUpdateStatus();
// query available updates again!
request_available_updates();
};
const retryUpdate = () => {
// Reset state and start update again
stateSetters.setUpdateState('updating');
stateSetters.setUpdateErrorMessages({});
stateSetters.setUpdateInProgress(false);
stateSetters.setCurrentUpdateTargetIndex(0);
stateSetters.setP4FirmwareProgress(0, 0);
stateSetters.setP4WebinterfaceProgress(0, 0);
stateSetters.setStm32FirmwareProgress(0, 0);
stateSetters.setC6FirmwareProgress(0, 0);
// Start the update process again
const updatesToProcess = availableUpdates.filter(update => update.updateAvailable);
if (updatesToProcess.length > 0) {
const firstUpdate = updatesToProcess[0];
switch (firstUpdate.core.toLowerCase()) {
case 'stm32':
start_firmware_update_stm32_fw();
break;
case 'p4 webinterface':
start_firmware_update_p4_web();
break;
case 'p4 firmware':
start_firmware_update_p4_fw();
break;
case 'c6':
start_firmware_update_c6_fw();
break;
}
}
// close the popup
onClose();
};
return (
<dialog class="modal modal-open">
<div class="modal-box max-w-2xl">
<div class="flex flex-col items-center justify-center py-8">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-alert-circle text-error mb-4">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
<h3 class="font-bold text-lg text-error mb-2">Update Failed</h3>
<p class="text-center mb-6">One or more firmware updates failed to complete successfully.</p>
</div>
<div class="space-y-2 mb-6">
{availableUpdates.map((update, index) => {
const [currentProgress, status] = getProgressForUpdate(update.core);
const updateTargetIndex = getTargetIndex(update.core);
const errorMessage = updateErrorMessages[updateTargetIndex] || '';
const isFailed = status === -1;
return (
<div key={index} class={`flex items-center justify-between p-3 rounded-lg ${isFailed ? 'bg-error/10' : 'bg-success/10'}`}>
<div class="flex items-center gap-3">
{getUpdateIcon(update.core)}
<div class="flex-1">
<span class="font-medium">{update.core}</span>
{isFailed && errorMessage && (
<div class="text-sm text-error mt-1">{errorMessage}</div>
)}
</div>
</div>
<div class="flex items-center gap-2">
{isFailed ? (
<>
<span class="text-sm text-error">Failed</span>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-x text-error">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</>
) : (
<>
<span class="text-sm">v{update.newVersion}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-circle-check text-success">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M9 12l2 2l4 -4" />
</svg>
</>
)}
</div>
</div>
);
})}
</div>
<div class="modal-action">
<button class="btn btn-outline" onClick={handleClose}>Close</button>
<button class="btn btn-primary" onClick={retryUpdate}>Retry Update</button>
</div>
</div>
<div class="modal-backdrop" onClick={handleClose}></div>
</dialog>
);
}

View file

@ -1,135 +0,0 @@
import { useStore } from '@nanostores/preact';
import { useEffect, useState, useRef } from 'preact/hooks';
import { IconCpu2, IconWifi, IconCpu, IconFileTypeJs, IconRefresh } from '@tabler/icons-preact';
import {
$availableUpdates,
$updateState,
stateSetters,
type UpdateInfo
} from './AppProviderStore';
interface UpdateSuccessPopupProps {
isOpen: boolean;
onClose: () => void;
}
// Function to get the appropriate icon for each update type
const getUpdateIcon = (core: string) => {
switch (core.toLowerCase()) {
case 'stm32':
return (
<IconCpu2 strokeWidth={2} />
);
case 'p4 firmware':
return (
<IconCpu strokeWidth={2} />
);
case 'c6':
return (
<IconWifi strokeWidth={2} />
);
default:
return (
<IconFileTypeJs strokeWidth={2} />
);
}
};
export default function UpdateSuccessPopup({ isOpen, onClose }: UpdateSuccessPopupProps) {
const availableUpdates = useStore($availableUpdates);
const updateState = useStore($updateState);
const [showRestarting, setShowRestarting] = useState(false);
// Only show if explicitly opened via isOpen prop (don't auto-close based on updateState)
if (!isOpen) return null;
// Handle restarting state - just show animation, no timer
useEffect(() => {
console.log("🎉 UpdateSuccessPopup useEffect triggered:", {
updateState,
isOpen,
showRestarting
});
if (updateState === 'restarting') {
console.log("🔄 RESTARTING state detected - showing restart animation");
setShowRestarting(true);
} else {
console.log("🎉 Non-restarting state - hiding restart animation");
setShowRestarting(false);
}
}, [updateState]);
const handleClose = () => {
// Don't reset update state here if we're restarting
if (updateState !== 'restarting') {
onClose();
}
// reset update status:
stateSetters.resetUpdateStatus();
};
return (
<dialog class="modal modal-open">
<div class="modal-box max-w-2xl">
<div class="flex flex-col items-center justify-center py-8">
{/* Always show success checkmark first */}
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-circle-check text-success mb-4">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M9 12l2 2l4 -4" />
</svg>
<h3 class="font-bold text-lg text-success mb-2">Update Completed Successfully!</h3>
<p class="text-center mb-6">All listed components have been updated successfully.</p>
{/* Show restarting section below if in restarting state */}
{updateState === 'restarting' && (
<div class="border-t border-base-300 pt-6 mt-2 w-full">
<div class="flex flex-col items-center">
<IconRefresh
size={48}
class="text-primary animate-spin mb-3"
strokeWidth={1.5}
/>
<h4 class="font-semibold text-md text-primary mb-2">Restarting Device...</h4>
<p class="text-center text-sm text-base-content/70">
The device is restarting to apply the firmware update. Please wait for reconnection.
</p>
</div>
</div>
)}
</div>
{/* Show update list in both success and restarting state */}
{(updateState === 'success' || updateState === 'restarting') && (
<div class="space-y-2 mb-6">
{availableUpdates.map((update, index) => (
<div key={index} class="flex items-center justify-between p-3 bg-success/10 rounded-lg">
<div class="flex items-center gap-3">
{getUpdateIcon(update.core)}
<span class="font-medium">{update.core}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">v{update.newVersion}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-circle-check text-success">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M9 12l2 2l4 -4" />
</svg>
</div>
</div>
))}
</div>
)}
{/* Button only in success state */}
{updateState === 'success' && (
<div class="modal-action">
<button class="btn btn-primary" onClick={handleClose}>OK</button>
</div>
)}
</div>
<div class="modal-backdrop" onClick={handleClose}></div>
</dialog>
);
}

View file

@ -1,472 +0,0 @@
import { useState, useEffect } from 'preact/hooks';
import { marked } from 'marked';
import { useStore } from '@nanostores/preact';
import { IconCpu2, IconWifi, IconCpu, IconFileTypeJs } from '@tabler/icons-preact';
import {
$availableUpdates,
$updateChangelog,
$updateInProgress,
$p4FirmwareProgress,
$p4WebinterfaceProgress,
$stm32FirmwareProgress,
$c6FirmwareProgress,
$currentUpdateTargetIndex,
$updateErrorMessages,
$updateState,
$updatedTargets,
stateSetters,
request_update_progress,
start_firmware_update_p4_fw,
start_firmware_update_p4_web,
start_firmware_update_stm32_fw,
start_firmware_update_c6_fw,
type UpdateInfo,
UpdateStatus,
TargetUpdateStatus
} from './AppProviderStore';
// Configure marked with basic options
marked.setOptions({
breaks: true,
gfm: true,
});
interface UpdaterModalProps {
isOpen: boolean;
onClose: () => void;
}
// Function to check if any updates are available
const hasAvailableUpdates = (updates: UpdateInfo[]) => updates.some(update => update.updateAvailable);
// Function to get the appropriate icon for each update type
const getUpdateIcon = (core: string) => {
switch (core.toLowerCase()) {
case 'stm32':
return (
<IconCpu2 strokeWidth={2} />
);
case 'p4 firmware':
return (
<IconCpu strokeWidth={2} />
);
case 'c6':
return (
<IconWifi strokeWidth={2} />
);
default:
return (
<IconFileTypeJs strokeWidth={2} />
);
}
};
export default function UpdaterModal({ isOpen, onClose }: UpdaterModalProps) {
const updateState = useStore($updateState);
const updateInProgress = useStore($updateInProgress);
const [currentUpdateIndex, setCurrentUpdateIndex] = useState(0);
const [progress, setProgress] = useState(0);
// Auto-close modal when updates are completed or failed
useEffect(() => {
console.log("🚀 UpdaterModal useEffect triggered:", {
updateState,
isOpen
});
if (updateState === 'success' || updateState === 'error' || updateState === 'restarting') {
if (isOpen) {
console.log(`🚪 Closing updater modal due to state: '${updateState}'`);
onClose();
} else {
console.log(`🚪 Updater modal already closed for state: '${updateState}'`);
}
} else {
console.log("🚀 Updater modal stays open - state:", updateState);
}
}, [updateState, onClose]); // Removed updateInProgress to prevent loop
// Use useStore for reactive updates
const availableUpdates = useStore($availableUpdates);
const updateChangelog = useStore($updateChangelog);
const currentUpdateTargetIndex = useStore($currentUpdateTargetIndex);
const updateErrorMessages = useStore($updateErrorMessages);
// Enhanced: Use updated_targets from server response via global state
const updatedTargetsFromServer = useStore($updatedTargets);
// Use reactive stores for all progress values
const p4FirmwareProgress = useStore($p4FirmwareProgress);
const p4WebinterfaceProgress = useStore($p4WebinterfaceProgress);
const stm32FirmwareProgress = useStore($stm32FirmwareProgress);
const c6FirmwareProgress = useStore($c6FirmwareProgress);
// Helper function to get progress for a specific update type - now using reactive stores
const getProgressForUpdate = (core: string) => {
switch (core.toLowerCase()) {
case 'p4 firmware': return p4FirmwareProgress;
case 'p4 webinterface': return p4WebinterfaceProgress;
case 'stm32': return stm32FirmwareProgress;
case 'c6': return c6FirmwareProgress;
default: return [0, 0];
}
};
// Enhanced: Check if a target is completed using updated_targets array from server
const isTargetCompletedFromServer = (core: string) => {
const targetIndex = getTargetIndex(core);
return targetIndex >= 0 && targetIndex < updatedTargetsFromServer.length
? updatedTargetsFromServer[targetIndex]
: false;
};
// Map core types to their target indices
const getTargetIndex = (core: string) => {
const coreLower = core.toLowerCase();
console.log(`🔍 getTargetIndex called with: "${core}" -> "${coreLower}"`);
switch (coreLower) {
case 'p4 webinterface':
console.log(`✅ P4 webinterface -> index 1`);
return 1; // P4 webinterface (second)
case 'p4 firmware':
console.log(`✅ P4 firmware -> index 2`);
return 2; // P4 firmware (third)
case 'stm32':
console.log(`✅ STM32 -> index 0`);
return 0; // STM32 firmware (first)
case 'c6':
console.log(`✅ C6 -> index 3`);
return 3; // C6 firmware (last)
default:
console.warn(`❌ Unknown core type: "${core}" -> "${coreLower}" -> index -1`);
return -1;
}
};
const startUpdate = async () => {
stateSetters.setUpdateState('updating');
const updatesToProcess = availableUpdates.filter(update => update.updateAvailable);
for (let i = 0; i < updatesToProcess.length; i++) {
setCurrentUpdateIndex(i);
const currentUpdate = updatesToProcess[i];
const updateTargetIndex = getTargetIndex(currentUpdate.core);
// Start the appropriate update based on the core type
switch (currentUpdate.core.toLowerCase()) {
case 'stm32':
start_firmware_update_stm32_fw();
break;
case 'p4 webinterface':
start_firmware_update_p4_web();
break;
case 'p4 firmware':
start_firmware_update_p4_fw();
break;
case 'c6':
start_firmware_update_c6_fw();
break;
default:
console.warn(`Unknown update type: ${currentUpdate.core}`);
continue;
}
// Wait for the update to complete by monitoring the progress
if (currentUpdate.hasProgressBar) {
// For updates with progress bar, wait for completion
let updateCompleted = false;
let updateFailed = false;
while (!updateCompleted && !updateFailed) {
// Get progress for this specific update type - need to use .get() here for current values in async function
const [currentProgress, status] = (() => {
switch (currentUpdate.core.toLowerCase()) {
case 'p4 firmware': return $p4FirmwareProgress.get();
case 'p4 webinterface': return $p4WebinterfaceProgress.get();
case 'stm32': return $stm32FirmwareProgress.get();
case 'c6': return $c6FirmwareProgress.get();
default: return [0, 0];
}
})();
setProgress(currentProgress);
// Check error status first
if (status === UpdateStatus.ERROR) {
updateFailed = true;
console.error(`Update failed for ${currentUpdate.core}`);
stateSetters.setUpdateState('error');
return;
} else if (status === UpdateStatus.SUCCESS) {
updateCompleted = true;
break;
}
// Fallback checks
if (currentProgress === 100) {
updateCompleted = true;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
} else {
// For updates without progress bar, show indeterminate progress for 3 seconds
setProgress(0);
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
// Only show success if we reached here (all updates completed)
stateSetters.setUpdateState('success');
// Clear all error messages on successful completion
stateSetters.setUpdateErrorMessages({});
};
const resetModal = () => {
stateSetters.setUpdateState('initial');
setCurrentUpdateIndex(0);
setProgress(0);
stateSetters.setUpdateErrorMessages({});
stateSetters.setUpdateInProgress(false);
stateSetters.setCurrentUpdateTargetIndex(0);
stateSetters.setP4FirmwareProgress(0, TargetUpdateStatus.NO_UPDATE);
stateSetters.setP4WebinterfaceProgress(0, TargetUpdateStatus.NO_UPDATE);
stateSetters.setStm32FirmwareProgress(0, TargetUpdateStatus.NO_UPDATE);
stateSetters.setC6FirmwareProgress(0, TargetUpdateStatus.NO_UPDATE);
stateSetters.setUpdaterModalOpen(false);
onClose();
};
if (!isOpen) return null;
return (
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
{updateState === 'initial' && (
<>
<h3 class="font-bold text-lg mb-4 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-mobiledata">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M16 12v-8" />
<path d="M8 20v-8" />
<path d="M13 7l3 -3l3 3" />
<path d="M5 17l3 3l3 -3" />
</svg>
Over The Air Update Available
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Available Updates */}
<div>
<h4 class="font-semibold mb-3">Available Updates:</h4>
<div class="space-y-3">
{availableUpdates
.filter(update => update.updateAvailable)
.sort((a, b) => {
const indexA = getTargetIndex(a.core);
const indexB = getTargetIndex(b.core);
console.log(`🔄 Sorting: "${a.core}" (${indexA}) vs "${b.core}" (${indexB}) -> ${indexA - indexB}`);
return indexA - indexB;
})
.map((update, index) => (
<div key={index} class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
<div class="flex items-center gap-3">
{getUpdateIcon(update.core)}
<div>
<div class="font-medium">{update.core}</div>
<div class="text-sm opacity-70">
{update.currentVersion} → {update.newVersion}
</div>
</div>
</div>
<div class="badge badge-success">Update</div>
</div>
))}
</div>
</div>
{/* Changelog */}
<div>
<h4 class="font-semibold mb-3 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-exchange">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M19 3a3 3 0 0 1 1 5.83v4.17a6 6 0 0 1 -6 6h-.585l1.292 1.293a1 1 0 0 1 .083 1.32l-.083 .094a1 1 0 0 1 -1.414 0l-2.959 -2.959a1 1 0 0 1 -.238 -.32l-.038 -.091l-.028 -.094a.98 .98 0 0 1 .187 -.866l.076 -.084l3 -3a1 1 0 0 1 1.414 1.414l-1.293 1.293h.586a4 4 0 0 0 3.995 -3.8l.005 -.2v-4.171a3 3 0 0 1 -2 -2.829l.005 -.176a3 3 0 0 1 2.995 -2.824m-8.293 -.707l3 3a.98 .98 0 0 1 .263 .95l-.01 .031l-.003 .018l-.008 .018l-.007 .027l-.016 .035l-.01 .032l-.007 .01l-.005 .014a1 1 0 0 1 -.232 .316l-2.965 2.963a1 1 0 0 1 -1.32 .083l-.094 -.083a1 1 0 0 1 0 -1.414l1.291 -1.293h-.584a4 4 0 0 0 -3.995 3.8l-.005 .2v4.171a3.001 3.001 0 1 1 -4 2.829l.005 -.176a3 3 0 0 1 1.995 -2.654v-4.17a6 6 0 0 1 6 -6h.585l-1.292 -1.293a1 1 0 0 1 -.083 -1.32l.083 -.094a1 1 0 0 1 1.414 0" />
</svg>
Changelog
</h4>
<div class="bg-base-200 rounded-lg p-4 h-80 overflow-y-auto">
{updateChangelog ? (
<div class="prose prose-sm max-w-none [&>h2]:text-xl [&>h2]:font-semibold [&>h2]:mb-3 [&>h2]:mt-4 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:mb-2 [&>h3]:mt-3 [&>ul]:list-disc [&>ul]:ml-6 [&>ul]:mb-3 [&>li]:mb-1 [&>p]:mb-3 [&>strong]:font-semibold"
dangerouslySetInnerHTML={{ __html: marked.parse(updateChangelog) as string }} />
) : (
<div class="text-center text-gray-500">
<p>No changelog available</p>
</div>
)}
</div>
</div>
</div>
{!hasAvailableUpdates(availableUpdates) && (
<div class="alert alert-info mt-4">
<span>No updates available.</span>
</div>
)}
<div class="alert alert-warning mt-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>The device will restart automatically after the update. This process takes approximately 2-3 minutes.</span>
</div>
<div class="modal-action">
<button class="btn btn-outline" onClick={resetModal}>Cancel</button>
<button class="btn btn-primary" onClick={startUpdate} disabled={!hasAvailableUpdates(availableUpdates)}>Start Update</button>
</div>
</>
)}
{updateState === 'updating' && (
<>
<h3 class="font-bold text-lg mb-4 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-mobiledata">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M16 12v-8" />
<path d="M8 20v-8" />
<path d="M13 7l3 -3l3 3" />
<path d="M5 17l3 3l3 -3" />
</svg>
Updating Over The Air...
</h3>
<div class="space-y-4">
{availableUpdates
.filter(update => update.updateAvailable)
.sort((a, b) => {
const indexA = getTargetIndex(a.core);
const indexB = getTargetIndex(b.core);
console.log(`🔄 Sorting (updating): "${a.core}" (${indexA}) vs "${b.core}" (${indexB}) -> ${indexA - indexB}`);
return indexA - indexB;
})
.map((update, index) => {
// Enhanced: Get real progress and status for each target using updated_targets
const [currentProgress, status] = getProgressForUpdate(update.core);
const targetIndex = getTargetIndex(update.core);
const isCompletedFromServer = isTargetCompletedFromServer(update.core);
const isCurrentTarget = targetIndex === currentUpdateTargetIndex;
// Determine the actual state of this target
let targetState: 'completed' | 'current' | 'waiting' | 'error';
let displayProgress = 0;
let statusText = '';
if (status === UpdateStatus.ERROR) {
targetState = 'error';
statusText = 'Failed';
} else if (isCompletedFromServer || status === UpdateStatus.SUCCESS || currentProgress === 100) {
// Target is marked as completed in updated_targets array from server OR has success status
targetState = 'completed';
statusText = 'Complete';
displayProgress = 100;
} else if (status === UpdateStatus.STARTED || status === UpdateStatus.IN_PROGRESS) {
// Show progress for any target that has started or is in progress, regardless of currentUpdateTargetIndex
targetState = 'current';
displayProgress = currentProgress;
statusText = update.hasProgressBar ? `${currentProgress}%` : 'Updating...';
} else {
targetState = 'waiting';
statusText = 'Waiting';
displayProgress = 0;
}
return (
<div key={index} class="flex items-center gap-4 p-4 bg-base-200 rounded-lg">
{getUpdateIcon(update.core)}
<div class="flex-1">
<div class="flex justify-between items-center mb-2">
<span class="font-medium">{update.core}</span>
<span class="text-sm">
{targetState === 'completed' ? (
<span class="flex items-center gap-1 text-success">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-circle-check">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M9 12l2 2l4 -4" />
</svg>
{statusText}
</span>
) : targetState === 'error' ? (
<span class="flex items-center gap-1 text-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{statusText}
</span>
) : targetState === 'current' ? (
<span class="flex items-center gap-1 text-primary">
{statusText}
</span>
) : (
<span class="flex items-center gap-1 text-base-content/50">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-clock-pause">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M20.942 13.018a9 9 0 1 0 -7.909 7.922" />
<path d="M12 7v5l2 2" />
<path d="M17 17v5" />
<path d="M21 17v5" />
</svg>
{statusText}
</span>
)}
</span>
</div>
{update.hasProgressBar ? (
<progress
class={`progress w-full ${
targetState === 'completed' ? 'progress-success' :
targetState === 'current' ? 'progress-primary' :
targetState === 'error' ? 'progress-error' :
'progress-base-300'
}`}
value={displayProgress}
max="100"
></progress>
) : (
<progress
class={`progress w-full ${
targetState === 'completed' ? 'progress-success' :
targetState === 'current' ? 'progress-primary' :
targetState === 'error' ? 'progress-error' :
'progress-base-300'
}`}
value={targetState === 'current' ? undefined : displayProgress}
max="100"
></progress>
)}
</div>
</div>
);
})}
</div>
<div class="alert alert-info mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Please do not power off the device during the update process.</span>
</div>
</>
)}
</div>
{(updateState === 'initial') && (
<div class="modal-backdrop" onClick={resetModal}></div>
)}
</dialog>
);
}

View file

@ -0,0 +1,3 @@
import { atom } from "nanostores";
export const $websocket = atom<WebSocket | null>(null);

View file

@ -1,38 +1,33 @@
import { useCallback, useEffect } from 'preact/hooks';
import { $isUpdaterModalOpen, $isSuccessPopupOpen, $isErrorPopupOpen, $websocket, $wsConnectionStatus, $updateErrorMessages, $p4FirmwareProgress, $p4WebinterfaceProgress, $stm32FirmwareProgress, $c6FirmwareProgress, $updateState, $updatedTargets, $availableUpdates, type UpdateInfo, UpdateStatus, TargetUpdateStatus, TotalUpdateStatus } from './AppProviderStore';
import { stateSetters, request_available_updates } from './AppProviderStore';
import UpdaterModal from './UpdaterModal';
import { useEffect } from "preact/hooks";
import { $websocket } from "./WebSocketAtom";
import { stateSetters } from "./AppProviderStore";
import {
$scanned_wifi_networks,
$current_network_configuration,
$network_status_display,
$network_config_error,
request_network_config,
type WiFiNetwork,
authmodeToSecurity,
ConnectionMode,
} from "./NetworkManagerStore";
// Helper function to get target index for update types
const getTargetIndex = (core: string) => {
switch (core.toLowerCase()) {
case 'p4 webinterface': return 1; // P4 webinterface (first)
case 'p4 firmware': return 2; // P4 firmware (second)
case 'stm32': return 0; // STM32 firmware
case 'c6': return 3; // C6 firmware (last)
default: return -1;
}
};
export { $websocket } from "./WebSocketAtom";
// Heartbeat configuration
const HEARTBEAT_INTERVAL = 1500; // Send ping every 1 second
const HEARTBEAT_TIMEOUT = 700; // Wait 1 second for pong response
const HEARTBEAT_INTERVAL = 1500; // Send ping every 1.5 seconds
const HEARTBEAT_TIMEOUT = 1000; // Wait 1 second for pong response
const MAX_MISSED_HEARTBEATS = 5; // Close connection after 5 missed pongs
// Heartbeat state
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;
let missedHeartbeats = 0;
let _lastPingSentAt = 0;
// Reconnect state
let isReconnecting = false;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let reload_site_after_webinterface_update = false;
// Restart detection state
let disconnectDetected = false;
let heartbeatsAfterReconnect = 0;
const HEARTBEATS_NEEDED_AFTER_RESTART = 3;
function clearHeartbeat() {
if (heartbeatInterval) {
@ -56,12 +51,9 @@ function clearReconnect() {
function scheduleReconnect() {
if (isReconnecting) {
////console.log("Reconnect already scheduled, skipping");
return;
}
isReconnecting = true;
////console.log("Scheduling reconnect in 1 second");
reconnectTimeout = setTimeout(() => {
connectWebSocket();
isReconnecting = false;
@ -69,13 +61,14 @@ function scheduleReconnect() {
}
function sendHeartbeat() {
if ($websocket.get() && ($websocket.get() as WebSocket).readyState === ($websocket.get() as WebSocket).OPEN) {
// Send ping message
const ws = $websocket.get();
if (ws && (ws as WebSocket).readyState === (ws as WebSocket).OPEN) {
// Send ping message via WebSocket
const pingMessage = JSON.stringify({ message_type: "ping" });
try {
($websocket.get() as WebSocket).send(pingMessage);
(ws as WebSocket).send(pingMessage);
_lastPingSentAt = performance.now();
} catch (error) {
console.error("Failed to send heartbeat ping:", error);
missedHeartbeats++;
return;
}
@ -88,23 +81,11 @@ function sendHeartbeat() {
heartbeatTimeout = setTimeout(() => {
missedHeartbeats++;
if (missedHeartbeats >= MAX_MISSED_HEARTBEATS) {
console.error("Too many missed heartbeats, closing connection");
// Check if we're in restarting state - this indicates device restart
const currentUpdateState = $updateState.get();
if (currentUpdateState === 'restarting') {
//console.log("🔄 Disconnect detected during restart - marking for restart detection");
disconnectDetected = true;
heartbeatsAfterReconnect = 0;
}
// Wichtig: Erst Heartbeat stoppen
// First stop the heartbeat, then close + null the socket
clearHeartbeat();
// Dann WebSocket schließen und null setzen
if ($websocket.get()) {
($websocket.get() as WebSocket).close();
const cur = $websocket.get();
if (cur) {
(cur as WebSocket).close();
stateSetters.setWebSocketConnectionStatus("Disconnected");
$websocket.set(null);
}
@ -115,7 +96,6 @@ function sendHeartbeat() {
function startHeartbeat() {
clearHeartbeat(); // Clear any existing heartbeat
heartbeatInterval = setInterval(() => {
sendHeartbeat();
}, HEARTBEAT_INTERVAL);
@ -123,52 +103,22 @@ function startHeartbeat() {
function onOpen() {
stateSetters.setWebSocketConnectionStatus("Connected");
//console.log("WebSocket erfolgreich verbunden");
missedHeartbeats = 0;
// If we reconnected after detecting a restart, start counting heartbeats
if (disconnectDetected) {
//console.log("🔄 Reconnected after restart - starting heartbeat counter");
heartbeatsAfterReconnect = 0;
}
// Start heartbeat mechanism
startHeartbeat();
// Sende initiale Statusanfrage
if ($websocket.get() !== null) {
const initial_state_request = JSON.stringify({ message_type: "initial_state_request" });
try {
($websocket.get() as WebSocket).send(initial_state_request);
} catch (error) {
console.error("Failed to send initial state request:", error);
}
// Request update status immediately to catch any missed completion messages
// Ask the device for its current network config so the UI knows whether to show
// the first-time wizard or the configured overview.
setTimeout(() => {
if ($websocket.get() && ($websocket.get() as WebSocket).readyState === ($websocket.get() as WebSocket).OPEN) {
const update_status_request = JSON.stringify({ message_type: "request_update_status" });
try {
($websocket.get() as WebSocket).send(update_status_request);
//console.log("Requested update status on reconnection");
} catch (error) {
console.error("Failed to send update status request:", error);
}
}
}, 500); // Reduced from 1000ms to 500ms for faster response
// send request for available updates
setTimeout(() => {
if ($websocket.get() && ($websocket.get() as WebSocket).readyState === ($websocket.get() as WebSocket).OPEN) {
request_available_updates();
}
}, 1500);
}
request_network_config();
}, 100);
}
function onMessage(message: MessageEvent) {
////console.log("WebSocket message received:", message.data.toString());
// Verarbeite JSON Nachrichten
const dataFromServer = JSON.parse(message.data.toString());
if (dataFromServer.message_type === "pong") {
// reset missed heartbeats and clear pending timeout
if (heartbeatTimeout) {
@ -176,517 +126,130 @@ function onMessage(message: MessageEvent) {
heartbeatTimeout = null;
}
missedHeartbeats = 0;
//console.log("Heartbeat pong received");
// Count heartbeats after reconnect for restart detection
if (disconnectDetected) {
heartbeatsAfterReconnect++;
//console.log(`🔄 Heartbeat ${heartbeatsAfterReconnect}/${HEARTBEATS_NEEDED_AFTER_RESTART} after restart`);
if (heartbeatsAfterReconnect >= HEARTBEATS_NEEDED_AFTER_RESTART) {
//console.log("🎉 Device restart completed - closing success popup and resetting state");
disconnectDetected = false;
disconnectDetected = false;
heartbeatsAfterReconnect = 0;
// Close success popup and reset state
stateSetters.setSuccessPopupOpen(false);
stateSetters.setUpdateState('initial');
if (reload_site_after_webinterface_update) {
// Hard reload ohne Cache
//window.location.reload();
}
}
}
}
else if (dataFromServer.message_type === "state_update" || dataFromServer.message_type === "initial_state_response") {
// If this is the initial state response, also request update status
if (dataFromServer.message_type === "initial_state_response") {
//console.log("📊 Initial state received, requesting update status...");
setTimeout(() => {
if ($websocket.get() && ($websocket.get() as WebSocket).readyState === ($websocket.get() as WebSocket).OPEN) {
const update_status_request = JSON.stringify({ message_type: "request_update_status" });
try {
($websocket.get() as WebSocket).send(update_status_request);
//console.log("Requested update status after initial state");
} catch (error) {
console.error("Failed to send update status request:", error);
}
}
}, 200); // Short delay to ensure connection is stable
}
if (dataFromServer.free_internal_heap) {
stateSetters.setFreeInternalHeap(dataFromServer.free_internal_heap);
}
// DCDC State aktualisieren
if (dataFromServer.dcdcStateMachine) {
stateSetters.setDCDCState(dataFromServer.dcdcStateMachine.dcdc_state);
stateSetters.setPOWERGOOD(dataFromServer.dcdcStateMachine.POWERGOOD);
stateSetters.setDcdcErrorStatus(dataFromServer.dcdcStateMachine.error_flag);
stateSetters.setREF_value(dataFromServer.dcdcStateMachine.ref_value);
}
// Laser State aktualisieren
if (dataFromServer.laserStateMachine) {
// Laser state update
stateSetters.setLaserState(dataFromServer.laserStateMachine.laser_state);
stateSetters.setModulationInputMethod(dataFromServer.laserStateMachine.laser_state);
stateSetters.setREF_calibrated(dataFromServer.laserStateMachine.is_REF_calibrated);
stateSetters.setHAS_DUTY_LOOKUP_TABLE(dataFromServer.laserStateMachine.has_duty_lookup_table);
stateSetters.setLaserErrorStatus(dataFromServer.laserStateMachine.error_flag);
stateSetters.setLASERGOOD(dataFromServer.laserStateMachine.LASERGOOD);
stateSetters.setLaserSetTemp(dataFromServer.laserStateMachine.set_temp);
stateSetters.setLaserMaxTemp(dataFromServer.laserStateMachine.max_temp);
stateSetters.setInterlock(dataFromServer.laserStateMachine.interlock);
stateSetters.setModulationInput(dataFromServer.laserStateMachine.modulationInput);
stateSetters.setSetCurrent(dataFromServer.laserStateMachine.set_current);
stateSetters.setMaxCurrent(dataFromServer.laserStateMachine.max_current);
stateSetters.setThresholdCurrent(dataFromServer.laserStateMachine.threshold_current);
stateSetters.setCurrentReading(dataFromServer.laserStateMachine.current_reading);
stateSetters.setLaserTemp(dataFromServer.laserStateMachine.laser_temp);
}
// Driver State aktualisieren
if (dataFromServer.driverStateMachine) {
stateSetters.setDriverTemp(dataFromServer.driverStateMachine.driver_temp);
stateSetters.setDriverMaxTemp(dataFromServer.driverStateMachine.max_temp);
stateSetters.setVccVoltage(dataFromServer.driverStateMachine.vcc_voltage);
stateSetters.setPdVoltage(dataFromServer.driverStateMachine.pd_voltage);
}
} else if (dataFromServer.message_type === "calibration_table") {
// Log raw data to see the actual format of values
//console.log("Raw calibration message:", message.data.toString().substring(0, 500) + "...");
// Parse values and format to always show with decimal places
const parsedValues = dataFromServer.values ?
dataFromServer.values.map((val: any) => {
// Convert to number and keep as float
const numVal = Number(val);
return numVal;
}) : [];
// Erstelle einen neuen Array mit korrekten Indizes
// Fülle Indizes 0-499 mit Nullen und füge dann die tatsächlichen Werte ein
const minDuty = dataFromServer.min_duty || 500;
const completedValues = [];
// Fülle Nullen für Indizes von 0 bis (minDuty-1)
for (let i = 0; i < minDuty; i++) {
completedValues.push(0);
}
for (let i = 0; i < parsedValues.length; i++) {
completedValues.push(parsedValues[i]);
}
// Store values in state - use the completed values with correct indices
stateSetters.setCalibrationTableData(
completedValues,
minDuty,
dataFromServer.max_duty || 3500, (dataFromServer.points_count || parsedValues.length) + minDuty
);
} else if (dataFromServer.message_type === "update_information") {
//console.log("Update information received");
//console.log("Raw update_information data:", dataFromServer);
// Update Information
if (dataFromServer.update_available) {
const infos: UpdateInfo[] = [];
if (dataFromServer.update_available.p4_firmware) {
infos.push({
core: 'p4 firmware',
currentVersion: dataFromServer.update_available.p4_firmware.current_version,
newVersion: dataFromServer.update_available.p4_firmware.new_version,
icon: 'p4 firmware',
hasProgressBar: true,
updateAvailable: true,
if ((window as any).__wsDebug && _lastPingSentAt > 0)
console.log(`[WS] pong RTT=${(performance.now() - _lastPingSentAt).toFixed(1)}ms`);
} else if (dataFromServer.message_type === "wifi_networks_response") {
const networks: WiFiNetwork[] = (dataFromServer.networks ?? []).map((n: any) => ({
SSID: n.ssid ?? "",
RSSI: n.rssi ?? 0,
Channel: n.channel ?? 0,
SecurityType: authmodeToSecurity(n.authmode ?? 0),
}));
$scanned_wifi_networks.set(networks);
} else if (dataFromServer.message_type === "network_status_update") {
$network_status_display.set({
Connection_Mode: dataFromServer.Connection_Mode ?? ConnectionMode.CONNECTION_MODE_UNKNOWN,
AP_Channel: dataFromServer.AP_Channel ?? 0,
IP_Address_Station: dataFromServer.IP_Address_Station ?? "",
Gateway_IP_Address_Station: dataFromServer.Gateway_IP_Address_Station ?? "",
Netmask_Station: dataFromServer.Netmask_Station ?? "",
MDNS_Hostname: dataFromServer.MDNS_Hostname ?? "",
MAC_Address_WiFi: dataFromServer.MAC_Address_WiFi ?? "",
rssi: dataFromServer.rssi ?? null,
});
//console.log("P4 Firmware update available");
}
if (dataFromServer.update_available.p4_webinterface) {
infos.push({
core: "p4 webinterface",
currentVersion: dataFromServer.update_available.p4_webinterface.current_version,
newVersion: dataFromServer.update_available.p4_webinterface.new_version,
icon: "p4 webinterface",
hasProgressBar: true,
updateAvailable: true,
} else if (dataFromServer.message_type === "network_config_response") {
console.log("[WS] network_config_response received:", dataFromServer);
$current_network_configuration.set({
Connection_Mode: dataFromServer.Connection_Mode ?? ConnectionMode.CONNECTION_MODE_UNKNOWN,
DHCP_Mode_Station: dataFromServer.DHCP_Mode_Station ?? true,
AP_Channel: dataFromServer.AP_Channel ?? 0,
IP_Address_Station: dataFromServer.IP_Address_Station ?? "",
Gateway_IP_Address_Station: dataFromServer.Gateway_IP_Address_Station ?? "",
Netmask_Station: dataFromServer.Netmask_Station ?? "",
Static_Cfg_IP_Address_Station: dataFromServer.Static_Cfg_IP_Address_Station ?? "",
Static_Cfg_Gateway_IP_Address_Station: dataFromServer.Static_Cfg_Gateway_IP_Address_Station ?? "",
Static_Cfg_Netmask_Station: dataFromServer.Static_Cfg_Netmask_Station ?? "",
SSID_Station: dataFromServer.SSID_Station ?? "",
Password_Station: dataFromServer.Password_Station ?? "",
SSID_AP: dataFromServer.SSID_AP ?? "",
Password_AP: dataFromServer.Password_AP ?? "",
MDNS_Hostname: dataFromServer.MDNS_Hostname ?? "hydrobuddy",
});
//console.log("P4 Webinterface update available");
}
if (dataFromServer.update_available.stm32_firmware) {
infos.push({
core: "STM32",
currentVersion: dataFromServer.update_available.stm32_firmware.current_version,
newVersion: dataFromServer.update_available.stm32_firmware.new_version,
icon: "stm32",
hasProgressBar: true,
updateAvailable: true,
// Update MAC in status display — config response is the first message that carries it
if (dataFromServer.MAC_Address_WiFi) {
$network_status_display.set({
...$network_status_display.get(),
MAC_Address_WiFi: dataFromServer.MAC_Address_WiFi || $network_status_display.get().MAC_Address_WiFi,
});
//console.log("STM32 Firmware update available");
}
if (dataFromServer.update_available.c6_firmware) {
infos.push({
core: "C6",
currentVersion: dataFromServer.update_available.c6_firmware.current_version,
newVersion: dataFromServer.update_available.c6_firmware.new_version,
icon: "c6",
hasProgressBar: true,
updateAvailable: true,
});
//console.log("C6 Firmware update available");
}
stateSetters.setAvailableUpdates(infos);
stateSetters.setUpdateChangelog(dataFromServer.changelog);
}
// Alte einzelne Progress-Message-Handler wurden durch complete_update_status ersetzt
} else if (dataFromServer.message_type === "complete_update_status") {
//console.log("📊 Complete update status received:", dataFromServer);
// Check if we're currently in restarting state - if so, ignore status updates
const currentUpdateState = $updateState.get() as any;
if (currentUpdateState === 'restarting') {
//console.log("🔄 Ignoring complete update status during RESTARTING state - countdown in progress");
return;
}
// Process the complete status information for all targets
if (dataFromServer.targets) {
const targets = dataFromServer.targets;
// STM32 Status
if (targets.stm32) {
const status = targets.stm32.status;
const progress = targets.stm32.progress || 0;
const updateAvailable = targets.stm32.update_available || false;
// Convert string status to TargetUpdateStatus enum
let statusEnum = TargetUpdateStatus.NO_UPDATE;
switch (status) {
case 'waiting': statusEnum = TargetUpdateStatus.WAITING; break;
case 'started': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
case 'in_progress': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
case 'completed': statusEnum = TargetUpdateStatus.COMPLETED; break;
case 'error': statusEnum = TargetUpdateStatus.ERROR; break;
case 'no_update': statusEnum = TargetUpdateStatus.NO_UPDATE; break;
case 'restarting': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
default: statusEnum = TargetUpdateStatus.NO_UPDATE; break;
}
stateSetters.setStm32FirmwareProgress(progress, statusEnum);
//console.log(`📊 STM32: status=${status}, progress=${progress}, available=${updateAvailable}`);
}
// P4 Webinterface Status
if (targets.p4_webinterface) {
const status = targets.p4_webinterface.status;
const progress = targets.p4_webinterface.progress || 0;
const updateAvailable = targets.p4_webinterface.update_available || false;
let statusEnum = TargetUpdateStatus.NO_UPDATE;
switch (status) {
case 'waiting': statusEnum = TargetUpdateStatus.WAITING; break;
case 'started': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
case 'in_progress': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
case 'completed': statusEnum = TargetUpdateStatus.COMPLETED; break;
case 'error': statusEnum = TargetUpdateStatus.ERROR; break;
case 'no_update': statusEnum = TargetUpdateStatus.NO_UPDATE; break;
case 'restarting': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
default: statusEnum = TargetUpdateStatus.NO_UPDATE; break;
}
stateSetters.setP4WebinterfaceProgress(progress, statusEnum);
//console.log(`📊 P4 Webinterface: status=${status}, progress=${progress}, available=${updateAvailable}`);
}
// P4 Firmware Status
if (targets.p4_firmware) {
const status = targets.p4_firmware.status;
const progress = targets.p4_firmware.progress || 0;
const updateAvailable = targets.p4_firmware.update_available || false;
let statusEnum = TargetUpdateStatus.NO_UPDATE;
switch (status) {
case 'waiting': statusEnum = TargetUpdateStatus.WAITING; break;
case 'started': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
case 'in_progress': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
case 'completed': statusEnum = TargetUpdateStatus.COMPLETED; break;
case 'error': statusEnum = TargetUpdateStatus.ERROR; break;
case 'no_update': statusEnum = TargetUpdateStatus.NO_UPDATE; break;
case 'restarting': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
default: statusEnum = TargetUpdateStatus.NO_UPDATE; break;
}
stateSetters.setP4FirmwareProgress(progress, statusEnum);
//console.log(`📊 P4 Firmware: status=${status}, progress=${progress}, available=${updateAvailable}`);
}
// C6 Status
if (targets.c6) {
const status = targets.c6.status;
const progress = targets.c6.progress || 0;
const updateAvailable = targets.c6.update_available || false;
let statusEnum = TargetUpdateStatus.NO_UPDATE;
switch (status) {
case 'waiting': statusEnum = TargetUpdateStatus.WAITING; break;
case 'started': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
case 'in_progress': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
case 'completed': statusEnum = TargetUpdateStatus.COMPLETED; break;
case 'error': statusEnum = TargetUpdateStatus.ERROR; break;
case 'no_update': statusEnum = TargetUpdateStatus.NO_UPDATE; break;
case 'restarting': statusEnum = TargetUpdateStatus.IN_PROGRESS; break;
default: statusEnum = TargetUpdateStatus.NO_UPDATE; break;
}
stateSetters.setC6FirmwareProgress(progress, statusEnum);
//console.log(`📊 C6: status=${status}, progress=${progress}, available=${updateAvailable}`);
}
}
// Check if there's an ongoing update
if (dataFromServer.update_in_progress) {
//console.log("🔄 Update in progress detected, setting total_update_status to updating");
stateSetters.setUpdateInProgress(true);
stateSetters.setTotalUpdateStatus(TotalUpdateStatus.UPDATING);
// Use the total_update_status from backend if available
if (dataFromServer.total_update_status !== undefined) {
const totalStatus = dataFromServer.total_update_status;
//console.log("📊 Backend total_update_status:", totalStatus);
switch (totalStatus) {
case 0: // INITIAL
stateSetters.setTotalUpdateStatus(TotalUpdateStatus.INITIAL);
break;
case 1: // STARTED
stateSetters.setTotalUpdateStatus(TotalUpdateStatus.STARTED);
break;
case 2: // UPDATING
stateSetters.setTotalUpdateStatus(TotalUpdateStatus.UPDATING);
break;
case 3: // SUCCESS
stateSetters.setTotalUpdateStatus(TotalUpdateStatus.SUCCESS);
break;
case 4: // ERROR
stateSetters.setTotalUpdateStatus(TotalUpdateStatus.ERROR);
break;
case 5: // RESTARTING
stateSetters.setTotalUpdateStatus(TotalUpdateStatus.RESTARTING);
break;
default:
console.warn("Unknown total_update_status:", totalStatus);
break;
}
}
// Open the updater modal, if it is not already open and no success/error popups are showing
const isModalOpen = $isUpdaterModalOpen.get();
const isSuccessPopupOpen = $isSuccessPopupOpen.get();
const isErrorPopupOpen = $isErrorPopupOpen.get();
/*console.log("🎯 Modal decision matrix:", {
isModalOpen,
totalUpdateStatus: currentUpdateState,
isSuccessPopupOpen,
isErrorPopupOpen,
shouldOpen: !isModalOpen && !isSuccessPopupOpen && !isErrorPopupOpen && currentUpdateState !== 'success' && currentUpdateState !== 'restarting'
});*/
// Don't open updater modal if success/error popups are open or we're in success/restarting state
const shouldOpenModal = !isModalOpen && !isSuccessPopupOpen && !isErrorPopupOpen &&
currentUpdateState !== 'success' && currentUpdateState !== 'restarting';
if (shouldOpenModal) {
//console.log("🚀 Opening updater modal - conditions met");
stateSetters.setUpdaterModalOpen(true);
console.log("[WS] $current_network_configuration updated:", $current_network_configuration.get());
} else if (dataFromServer.message_type === "network_config_failed") {
const failedSsid: string = dataFromServer.failed_ssid ?? "";
const reason: string = dataFromServer.reason ?? "unknown";
const connectionMode: ConnectionMode = dataFromServer.connection_mode ?? ConnectionMode.CONNECTION_MODE_UNKNOWN;
console.warn(`[WS] network_config_failed: ssid='${failedSsid}' reason='${reason}'`);
$network_config_error.set({ reason, failed_ssid: failedSsid, connection_mode: connectionMode });
} else if (dataFromServer.message_type === "error_popup") {
console.warn(`[error_popup] ${dataFromServer.error ?? dataFromServer.message ?? "(no detail)"}`);
} else {
//console.log("🚫 Skipping updater modal - conditions not met");
}
} else {
//console.log("✅ No update in progress, checking if all available updates are completed");
// Check if all targets with update_available: true are completed
let allAvailableUpdatesCompleted = true;
let hasAnyAvailableUpdates = false;
if (dataFromServer.targets.stm32 && dataFromServer.targets.stm32.update_available) {
hasAnyAvailableUpdates = true;
if (dataFromServer.targets.stm32.status !== 'completed') {
allAvailableUpdatesCompleted = false;
}
}
if (dataFromServer.targets.p4_webinterface && dataFromServer.targets.p4_webinterface.update_available) {
hasAnyAvailableUpdates = true;
if (dataFromServer.targets.p4_webinterface.status !== 'completed') {
allAvailableUpdatesCompleted = false;
}
}
if (dataFromServer.targets.p4_firmware && dataFromServer.targets.p4_firmware.update_available) {
hasAnyAvailableUpdates = true;
if (dataFromServer.targets.p4_firmware.status !== 'completed') {
allAvailableUpdatesCompleted = false;
}
}
if (dataFromServer.targets.c6 && dataFromServer.targets.c6.update_available) {
hasAnyAvailableUpdates = true;
if (dataFromServer.targets.c6.status !== 'completed') {
allAvailableUpdatesCompleted = false;
}
}
if (hasAnyAvailableUpdates && allAvailableUpdatesCompleted) {
//console.log("🎉 All available updates completed successfully, setting total_update_status to success");
stateSetters.setUpdateInProgress(false);
stateSetters.setTotalUpdateStatus(TotalUpdateStatus.SUCCESS);
} else if (!hasAnyAvailableUpdates) {
//console.log("ℹ️ No updates available, resetting total_update_status to initial");
stateSetters.setUpdateInProgress(false);
if (currentUpdateState !== 'success' && currentUpdateState !== 'error' && currentUpdateState !== 'restarting') {
stateSetters.setTotalUpdateStatus(TotalUpdateStatus.INITIAL);
}
} else {
//console.log("⏳ Some available updates still in progress, keeping current total_update_status");
}
}
} else if (dataFromServer.message_type === "response_update_status") {
console.log("📊 Update status response received:", dataFromServer);
// Check if we're currently in restarting state - if so, ignore status updates
const currentUpdateState = $updateState.get() as any;
if (currentUpdateState === 'restarting') {
//console.log("🔄 Ignoring update status during RESTARTING state - countdown in progress");
return;
}
// Always process the complete status information, regardless of update_in_progress
//console.log("🔄 Processing complete update status for all targets");
// Set progress and status for each update type if provided
if (dataFromServer.p4_firmware_progress !== undefined) {
const status = dataFromServer.p4_firmware_status || 0;
stateSetters.setP4FirmwareProgress(dataFromServer.p4_firmware_progress, status);
//console.log(`📊 P4 Firmware: progress=${dataFromServer.p4_firmware_progress}, status=${status}`);
}
if (dataFromServer.p4_webinterface_progress !== undefined) {
const status = dataFromServer.p4_webinterface_status || 0;
stateSetters.setP4WebinterfaceProgress(dataFromServer.p4_webinterface_progress, status);
//console.log(`📊 P4 Webinterface: progress=${dataFromServer.p4_webinterface_progress}, status=${status}`);
}
if (dataFromServer.stm32_firmware_progress !== undefined) {
const status = dataFromServer.stm32_firmware_status || 0;
stateSetters.setStm32FirmwareProgress(dataFromServer.stm32_firmware_progress, status);
//console.log(`📊 STM32 Firmware: progress=${dataFromServer.stm32_firmware_progress}, status=${status}`);
}
if (dataFromServer.c6_firmware_progress !== undefined) {
const status = dataFromServer.c6_firmware_status || 0;
stateSetters.setC6FirmwareProgress(dataFromServer.c6_firmware_progress, status);
//console.log(`📊 C6 Firmware: progress=${dataFromServer.c6_firmware_progress}, status=${status}`);
}
// Store updated_targets array from server for UpdaterModal
if (dataFromServer.updated_targets && Array.isArray(dataFromServer.updated_targets)) {
stateSetters.setUpdatedTargets(dataFromServer.updated_targets);
//console.log("🎯 Updated targets:", dataFromServer.updated_targets);
}
// Set current target if provided
if (dataFromServer.current_target !== undefined) {
stateSetters.setCurrentUpdateTargetIndex(dataFromServer.current_target);
//console.log("🎯 Current target:", dataFromServer.current_target);
}
// Check if there's an ongoing update
if (dataFromServer.update_in_progress) {
//console.log("🔄 Update in progress detected, setting state to updating");
stateSetters.setUpdateInProgress(true);
stateSetters.setUpdateState("updating");
// Open the updater modal, if it is not already open and no success/error popups are showing
const isModalOpen = $isUpdaterModalOpen.get();
const isSuccessPopupOpen = $isSuccessPopupOpen.get();
const isErrorPopupOpen = $isErrorPopupOpen.get();
/*console.log("🎯 Modal decision matrix:", {
isModalOpen,
updateState: currentUpdateState,
isSuccessPopupOpen,
isErrorPopupOpen,
shouldOpen: !isModalOpen && !isSuccessPopupOpen && !isErrorPopupOpen && currentUpdateState !== 'success' && currentUpdateState !== 'restarting'
});*/
// Don't open updater modal if success/error popups are open or we're in success/restarting state
const shouldOpenModal = !isModalOpen && !isSuccessPopupOpen && !isErrorPopupOpen &&
currentUpdateState !== 'success' && currentUpdateState !== 'restarting';
if (shouldOpenModal) {
//console.log("🚀 Opening updater modal - conditions met");
stateSetters.setUpdaterModalOpen(true);
} else {
//console.log("🚫 Skipping updater modal - conditions not met");
}
}
} else {
// Unbekannter Nachrichtentyp – korrektes Feld protokollieren
console.warn(`[${new Date().toISOString()}] Unbekannter Nachrichtentyp: ${dataFromServer.message_type ?? 'undefined'}`);
console.debug('Payload:', dataFromServer);
// Unbekannter Nachrichtentyp - korrektes Feld protokollieren
console.warn(`[${new Date().toISOString()}] Unbekannter Nachrichtentyp: ${dataFromServer.message_type ?? "undefined"}`);
console.debug("Payload:", dataFromServer);
}
}
function onError(error: Event) {
$wsConnectionStatus.set("Disconnected");
//console.log("WebSocket error:", error);
stateSetters.setWebSocketConnectionStatus("Disconnected");
console.log("WebSocket error:", error);
clearHeartbeat();
scheduleReconnect();
}
function onClose(event: CloseEvent) {
$wsConnectionStatus.set("Disconnected");
//console.log("WebSocket closed with code:", event.code);
//console.log("Close reason:", event.reason);
//console.log("Clean close:", event.wasClean);
stateSetters.setWebSocketConnectionStatus("Disconnected");
console.log("WebSocket closed with code:", event.code);
console.log("Close reason:", event.reason);
console.log("Clean close:", event.wasClean);
clearHeartbeat();
if (event.code === 1000) {
//console.log("Normal closure");
console.log("Normal closure");
}
scheduleReconnect();
}
function getWsAddress(): string {
// Derive WS address from the page's origin so it works with any hostname or IP.
const h = window.location.hostname;
if (h === "localhost" || h === "127.0.0.1") {
return (import.meta.env.PUBLIC_DEV_WS_HOST as string | undefined) ?? "ws://hydrobuddy.local/ws";
}
return `ws://${h}/ws`;
}
const connectWebSocket = () => {
// Clear any existing reconnect attempts
clearReconnect();
clearHeartbeat();
const wsAddress = `ws://hydrobuddy.local/ws`;
//console.log(`Verbinde mit: ${wsAddress}`);
const wsAddress = getWsAddress();
console.log(`Verbinde mit: ${wsAddress}`);
if ($websocket.get() !== null) {
($websocket.get() as WebSocket).close(1000);
$websocket.set(null);
}
$websocket.set(new WebSocket(wsAddress));
if ($websocket.get() !== null) {
($websocket.get() as WebSocket).onopen = onOpen;
($websocket.get() as WebSocket).onmessage = onMessage;
($websocket.get() as WebSocket).onerror = onError;
($websocket.get() as WebSocket).onclose = onClose;
}
const ws = $websocket.get();
if (ws !== null) {
(ws as WebSocket).onopen = onOpen;
(ws as WebSocket).onmessage = onMessage;
(ws as WebSocket).onerror = onError;
(ws as WebSocket).onclose = onClose;
}
};
export const WebSocketProvider = () => {
// Auto-connect WebSocket at first render
useEffect(() => {
//console.log("Component mounted, starting WebSocket connection...");
console.log("Component mounted, starting WebSocket connection...");
connectWebSocket();
// Cleanup beim Unmounting
return () => {
clearHeartbeat();
clearReconnect();
($websocket.get() as WebSocket).close();
const cur = $websocket.get();
if (cur) (cur as WebSocket).close();
$websocket.set(null);
$wsConnectionStatus.set("Disconnected");
stateSetters.setWebSocketConnectionStatus("Disconnected");
};
}, []);
};

View file

@ -0,0 +1,508 @@
---
import { IconPlant } from "@tabler/icons-preact";
---
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<!-- Heading -->
<div class="flex items-center gap-3 mb-2">
<span class="text-emerald-600"><IconPlant size={32} strokeWidth={1.75} /></span>
<h2 class="card-title text-2xl text-emerald-700">Let's get you online</h2>
</div>
<p class="opacity-70 mb-2">
Three quick steps and your HydroBuddy will be on the network.
</p>
<!-- Step indicator (DaisyUI steps) -->
<ul class="steps steps-horizontal w-full my-4">
<li class="step step-primary" id="step-indicator-1" data-step="1">Name</li>
<li class="step" id="step-indicator-2" data-step="2">WiFi</li>
<li class="step" id="step-indicator-3" data-step="3">Connect</li>
</ul>
<!-- ============== STEP 1 — Name the device ============== -->
<section id="wizard-step-1" class="space-y-4">
<div>
<h3 class="font-semibold text-lg mb-1">What should we call your device?</h3>
<p class="text-sm opacity-70">
Pick a name — you'll reach the web interface at <span class="font-mono">http://&lt;name&gt;.local</span>
from any device on the same network.
</p>
</div>
<label
class="input flex items-center gap-2 font-mono text-sm focus-within:bg-emerald-50 focus-within:outline-emerald-500"
>
<span class="opacity-60 select-none">http://</span>
<input
type="text"
id="wizard-hostname-input"
placeholder="hydrobuddy"
maxlength="31"
required
class="grow min-w-0 bg-transparent border-none outline-none"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
/>
<span class="opacity-60 select-none">.local</span>
</label>
<p id="wizard-hostname-error" class="text-sm text-error hidden"></p>
<div class="flex justify-end mt-4">
<button id="wizard-step-1-next" class="btn btn-primary" disabled>Next</button>
</div>
</section>
<!-- ============== STEP 2 — Pick a WiFi network ============== -->
<section id="wizard-step-2" class="hidden space-y-4">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<h3 class="font-semibold text-lg mb-1">Pick a WiFi network</h3>
<p class="text-sm opacity-70">Scan and tap the network you want to join.</p>
</div>
<button id="wizard-scan-btn" class="btn btn-outline btn-primary btn-sm">
<span id="wizard-scan-btn-label">Scan</span>
<span id="wizard-scan-btn-spinner" class="loading loading-spinner loading-xs hidden"></span>
</button>
</div>
<!-- Networks list. Populated dynamically; empty/placeholder text shown when len === 0. -->
<div
id="wizard-networks-list"
class="rounded-lg border border-base-300 divide-y divide-base-300 max-h-72 overflow-y-auto"
>
<div class="p-6 text-center text-sm opacity-60" id="wizard-networks-empty">
Press <span class="font-semibold">Scan</span> to discover nearby networks.
</div>
</div>
<!-- Manual SSID entry (hidden networks, or want to type the name) -->
<details class="collapse collapse-arrow bg-base-200/50">
<summary class="collapse-title text-sm font-medium">
Hidden network? Type the name manually
</summary>
<div class="collapse-content">
<input
type="text"
id="wizard-manual-ssid"
placeholder="SSID"
maxlength="31"
class="input input-bordered w-full"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
/>
</div>
</details>
<div class="flex justify-between mt-4">
<button id="wizard-step-2-back" class="btn btn-ghost">Back</button>
<button id="wizard-step-2-next" class="btn btn-primary" disabled>Next</button>
</div>
</section>
<!-- ============== STEP 3 — Password + Apply ============== -->
<section id="wizard-step-3" class="hidden space-y-4">
<div>
<h3 class="font-semibold text-lg mb-1">Connect to <span id="wizard-selected-ssid" class="text-emerald-700">-</span></h3>
<p class="text-sm opacity-70">Enter the network password. Leave empty for open networks.</p>
</div>
<label
class="input flex items-center gap-2 font-mono text-sm focus-within:bg-emerald-50 focus-within:outline-emerald-500"
>
<input
type="password"
id="wizard-password-input"
placeholder="Password"
maxlength="31"
class="grow min-w-0 bg-transparent border-none outline-none"
autocomplete="new-password"
spellcheck="false"
/>
<button type="button" id="wizard-password-toggle" class="btn btn-ghost btn-xs p-1 min-h-0 h-auto">
<span id="wizard-password-toggle-text" class="text-xs opacity-70">show</span>
</button>
</label>
<details class="collapse collapse-arrow bg-base-200/50">
<summary class="collapse-title text-sm font-medium">Advanced: Static IP (optional)</summary>
<div class="collapse-content space-y-3">
<p class="text-xs opacity-60">
Leave blank to use DHCP (recommended). Only set these if your network requires fixed IPs.
</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label class="form-control">
<span class="label-text text-xs">IP Address</span>
<input
type="text"
id="wizard-static-ip"
placeholder="192.168.1.50"
class="input input-bordered input-sm font-mono"
/>
</label>
<label class="form-control">
<span class="label-text text-xs">Netmask</span>
<input
type="text"
id="wizard-static-mask"
placeholder="255.255.255.0"
class="input input-bordered input-sm font-mono"
/>
</label>
<label class="form-control">
<span class="label-text text-xs">Gateway</span>
<input
type="text"
id="wizard-static-gw"
placeholder="192.168.1.1"
class="input input-bordered input-sm font-mono"
/>
</label>
</div>
<p id="wizard-static-error" class="text-sm text-error hidden"></p>
</div>
</details>
<div class="alert alert-info mt-4">
<span class="text-sm">
When you apply, the device will reboot its WiFi and briefly disconnect. You'll need to switch your
phone/laptop to the same network and reopen <span class="font-mono" id="wizard-final-url">http://hydrobuddy.local</span>.
</span>
</div>
<div class="flex justify-between mt-4 gap-3">
<button id="wizard-step-3-back" class="btn btn-ghost">Back</button>
<button id="wizard-step-3-apply" class="btn btn-primary">
<span id="wizard-apply-label">Apply &amp; Connect</span>
<span id="wizard-apply-spinner" class="loading loading-spinner loading-xs hidden"></span>
</button>
</div>
</section>
</div>
</div>
<script>
import {
ConnectionMode,
$current_network_configuration,
$scanned_wifi_networks,
$selected_ssid,
$wifi_network_pw,
$hostname_input_field,
scan_wifi_networks,
set_hostname,
send_new_network_configuration_to_alpha,
type WiFiNetwork,
} from "../components/NetworkManagerStore";
import {
HOSTNAME_REGEX,
MAX_HOSTNAME_LENGTH,
isValidHostname,
normalizeHostnameValue,
} from "../components/helper_scripts/hostname_validation";
import {
is_valid_ip_format,
isValidNetmask,
isPracticalNetmask,
isInSameSubnet,
isInValidHostRange,
} from "../components/helper_scripts/IP_Subnetmask_validation";
// ---------- DOM lookup ----------
const stepSections = [
document.getElementById("wizard-step-1"),
document.getElementById("wizard-step-2"),
document.getElementById("wizard-step-3"),
];
const stepIndicators = [
document.getElementById("step-indicator-1"),
document.getElementById("step-indicator-2"),
document.getElementById("step-indicator-3"),
];
// Step 1
const hostnameInput = document.getElementById("wizard-hostname-input") as HTMLInputElement | null;
const hostnameError = document.getElementById("wizard-hostname-error");
const step1Next = document.getElementById("wizard-step-1-next") as HTMLButtonElement | null;
// Step 2
const scanBtn = document.getElementById("wizard-scan-btn") as HTMLButtonElement | null;
const scanBtnLabel = document.getElementById("wizard-scan-btn-label");
const scanBtnSpinner = document.getElementById("wizard-scan-btn-spinner");
const networksList = document.getElementById("wizard-networks-list");
const networksEmpty = document.getElementById("wizard-networks-empty");
const manualSsid = document.getElementById("wizard-manual-ssid") as HTMLInputElement | null;
const step2Back = document.getElementById("wizard-step-2-back");
const step2Next = document.getElementById("wizard-step-2-next") as HTMLButtonElement | null;
// Step 3
const selectedSsidDisplay = document.getElementById("wizard-selected-ssid");
const passwordInput = document.getElementById("wizard-password-input") as HTMLInputElement | null;
const passwordToggle = document.getElementById("wizard-password-toggle");
const passwordToggleText = document.getElementById("wizard-password-toggle-text");
const staticIp = document.getElementById("wizard-static-ip") as HTMLInputElement | null;
const staticMask = document.getElementById("wizard-static-mask") as HTMLInputElement | null;
const staticGw = document.getElementById("wizard-static-gw") as HTMLInputElement | null;
const staticError = document.getElementById("wizard-static-error");
const finalUrl = document.getElementById("wizard-final-url");
const step3Back = document.getElementById("wizard-step-3-back");
const step3Apply = document.getElementById("wizard-step-3-apply") as HTMLButtonElement | null;
const applyLabel = document.getElementById("wizard-apply-label");
const applySpinner = document.getElementById("wizard-apply-spinner");
// ---------- Step navigation ----------
let currentStep: 1 | 2 | 3 = 1;
function gotoStep(n: 1 | 2 | 3) {
currentStep = n;
stepSections.forEach((el, i) => el?.classList.toggle("hidden", i + 1 !== n));
stepIndicators.forEach((el, i) => el?.classList.toggle("step-primary", i + 1 <= n));
}
// ---------- Step 1: hostname ----------
function validateHostname() {
if (!hostnameInput || !hostnameError || !step1Next) return;
const raw = hostnameInput.value;
const normalized = normalizeHostnameValue(raw);
if (normalized !== raw) {
hostnameInput.value = normalized;
}
if (normalized.length === 0) {
hostnameError.classList.add("hidden");
step1Next.disabled = true;
return;
}
if (!isValidHostname(normalized)) {
hostnameError.textContent =
"Invalid hostname. Use lowercase letters, digits, and hyphens only. Cannot start or end with a hyphen.";
hostnameError.classList.remove("hidden");
step1Next.disabled = true;
return;
}
hostnameError.classList.add("hidden");
step1Next.disabled = false;
$hostname_input_field.set(normalized);
}
hostnameInput?.addEventListener("input", validateHostname);
// Prime with the device's current hostname (typically "hydrobuddy") so the user can keep it with one click.
$current_network_configuration.subscribe((cfg) => {
if (hostnameInput && !hostnameInput.value && cfg.MDNS_Hostname) {
hostnameInput.value = cfg.MDNS_Hostname;
validateHostname();
}
});
step1Next?.addEventListener("click", () => {
// Persist the hostname change to the device immediately, then advance.
// This way even if the user bails at step 2 they keep the friendlier name.
const h = $hostname_input_field.get();
if (h) set_hostname(h);
if (finalUrl) finalUrl.textContent = `http://${h || "hydrobuddy"}.local`;
gotoStep(2);
});
// ---------- Step 2: scan + pick ----------
function setScanning(on: boolean) {
if (!scanBtn || !scanBtnLabel || !scanBtnSpinner) return;
scanBtn.disabled = on;
scanBtnLabel.textContent = on ? "Scanning…" : "Scan";
scanBtnSpinner.classList.toggle("hidden", !on);
}
scanBtn?.addEventListener("click", () => {
setScanning(true);
scan_wifi_networks();
// Scan takes ~3 s on the firmware side; release the button after a generous timeout
// even if no networks are returned (in which case the list stays empty).
setTimeout(() => setScanning(false), 5000);
});
function renderNetworks(networks: WiFiNetwork[]) {
if (!networksList) return;
if (networks.length === 0) {
// Keep the "press Scan" placeholder
networksList.innerHTML = `
<div class="p-6 text-center text-sm opacity-60">
Press <span class="font-semibold">Scan</span> to discover nearby networks.
</div>`;
return;
}
setScanning(false);
// Dedup + sort by signal (descending)
const seen = new Set<string>();
const dedup = networks.filter((n) => {
if (!n.SSID || seen.has(n.SSID)) return false;
seen.add(n.SSID);
return true;
});
dedup.sort((a, b) => b.RSSI - a.RSSI);
const selectedNow = $selected_ssid.get();
networksList.innerHTML = dedup
.map((n) => {
const strength = n.RSSI >= -50 ? "Excellent" : n.RSSI >= -60 ? "Good" : n.RSSI >= -70 ? "Fair" : "Weak";
const isOpen = n.SecurityType === "Open";
const isSel = n.SSID === selectedNow;
return `
<button
type="button"
class="w-full flex items-center justify-between gap-3 p-3 hover:bg-emerald-50 transition-colors text-left ${isSel ? "bg-emerald-100" : ""}"
data-ssid="${escapeHtml(n.SSID)}"
data-security="${escapeHtml(n.SecurityType)}"
>
<div class="min-w-0 flex-1">
<div class="font-medium truncate">${escapeHtml(n.SSID)}</div>
<div class="text-xs opacity-60 flex gap-2">
<span>${escapeHtml(n.SecurityType)}</span>
<span>·</span>
<span>Ch ${n.Channel}</span>
<span>·</span>
<span>${strength} (${n.RSSI} dBm)</span>
</div>
</div>
${isOpen ? '<span class="badge badge-ghost badge-sm shrink-0">Open</span>' : '<span class="badge badge-sm shrink-0">🔒</span>'}
</button>`;
})
.join("");
networksList.querySelectorAll("button[data-ssid]").forEach((btn) => {
btn.addEventListener("click", () => {
const ssid = (btn as HTMLElement).dataset.ssid ?? "";
$selected_ssid.set(ssid);
if (manualSsid) manualSsid.value = "";
updateStep2NextEnabled();
});
});
}
$scanned_wifi_networks.subscribe(renderNetworks);
manualSsid?.addEventListener("input", () => {
const v = manualSsid.value.trim();
if (v.length > 0) {
$selected_ssid.set(v);
} else if ($selected_ssid.get() === manualSsid.value) {
$selected_ssid.set("");
}
updateStep2NextEnabled();
});
function updateStep2NextEnabled() {
if (!step2Next) return;
const ssid = $selected_ssid.get();
step2Next.disabled = !ssid || ssid.length === 0;
}
$selected_ssid.subscribe(() => {
updateStep2NextEnabled();
if (selectedSsidDisplay) selectedSsidDisplay.textContent = $selected_ssid.get() || "-";
});
step2Back?.addEventListener("click", () => gotoStep(1));
step2Next?.addEventListener("click", () => gotoStep(3));
// ---------- Step 3: password + static IP + apply ----------
passwordInput?.addEventListener("input", () => {
$wifi_network_pw.set(passwordInput.value);
});
passwordToggle?.addEventListener("click", () => {
if (!passwordInput || !passwordToggleText) return;
const show = passwordInput.type === "password";
passwordInput.type = show ? "text" : "password";
passwordToggleText.textContent = show ? "hide" : "show";
});
function validateStaticIp(): { ok: boolean; useStatic: boolean; ip: string; mask: string; gw: string } {
if (!staticIp || !staticMask || !staticGw || !staticError) {
return { ok: true, useStatic: false, ip: "", mask: "", gw: "" };
}
const ip = staticIp.value.trim();
const mask = staticMask.value.trim();
const gw = staticGw.value.trim();
const anyFilled = ip || mask || gw;
if (!anyFilled) {
staticError.classList.add("hidden");
return { ok: true, useStatic: false, ip: "", mask: "", gw: "" };
}
if (!ip || !mask || !gw) {
staticError.textContent = "Fill all three fields (IP, netmask, gateway) or leave all blank for DHCP.";
staticError.classList.remove("hidden");
return { ok: false, useStatic: true, ip, mask, gw };
}
if (!is_valid_ip_format(ip)) {
staticError.textContent = "IP address is not a valid IPv4 address.";
staticError.classList.remove("hidden");
return { ok: false, useStatic: true, ip, mask, gw };
}
if (!isValidNetmask(mask) || !isPracticalNetmask(mask)) {
staticError.textContent = "Netmask must be a practical IPv4 netmask (e.g. 255.255.255.0).";
staticError.classList.remove("hidden");
return { ok: false, useStatic: true, ip, mask, gw };
}
if (!is_valid_ip_format(gw)) {
staticError.textContent = "Gateway is not a valid IPv4 address.";
staticError.classList.remove("hidden");
return { ok: false, useStatic: true, ip, mask, gw };
}
if (!isInSameSubnet(ip, gw, mask)) {
staticError.textContent = "Gateway must be in the same subnet as the IP address.";
staticError.classList.remove("hidden");
return { ok: false, useStatic: true, ip, mask, gw };
}
if (!isInValidHostRange(ip, mask) || !isInValidHostRange(gw, mask)) {
staticError.textContent = "IP or gateway is the network/broadcast address — pick a host address.";
staticError.classList.remove("hidden");
return { ok: false, useStatic: true, ip, mask, gw };
}
staticError.classList.add("hidden");
return { ok: true, useStatic: true, ip, mask, gw };
}
staticIp?.addEventListener("input", validateStaticIp);
staticMask?.addEventListener("input", validateStaticIp);
staticGw?.addEventListener("input", validateStaticIp);
step3Back?.addEventListener("click", () => gotoStep(2));
step3Apply?.addEventListener("click", () => {
const ssid = $selected_ssid.get();
if (!ssid) {
gotoStep(2);
return;
}
const ipCheck = validateStaticIp();
if (!ipCheck.ok) return;
const baseCfg = $current_network_configuration.get();
const cfg = {
...baseCfg,
Connection_Mode: ipCheck.useStatic ? ConnectionMode.CONNECTION_MODE_STA_STATIC : ConnectionMode.CONNECTION_MODE_STA_DHCP,
DHCP_Mode_Station: !ipCheck.useStatic,
SSID_Station: ssid,
Password_Station: passwordInput?.value ?? "",
MDNS_Hostname: $hostname_input_field.get() || baseCfg.MDNS_Hostname || "hydrobuddy",
Static_Cfg_IP_Address_Station: ipCheck.useStatic ? ipCheck.ip : baseCfg.Static_Cfg_IP_Address_Station,
Static_Cfg_Netmask_Station: ipCheck.useStatic ? ipCheck.mask : baseCfg.Static_Cfg_Netmask_Station,
Static_Cfg_Gateway_IP_Address_Station: ipCheck.useStatic ? ipCheck.gw : baseCfg.Static_Cfg_Gateway_IP_Address_Station,
};
if (step3Apply) step3Apply.disabled = true;
applyLabel?.classList.add("hidden");
applySpinner?.classList.remove("hidden");
const ok = send_new_network_configuration_to_alpha(cfg);
if (!ok) {
if (step3Apply) step3Apply.disabled = false;
applyLabel?.classList.remove("hidden");
applySpinner?.classList.add("hidden");
}
// On success, the device will briefly drop the WS, reconnect, and broadcast
// a new network_config_response — index.astro's pickView() then swaps the
// wizard for the overview automatically.
});
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
}
</script>

View file

@ -0,0 +1,74 @@
export function is_valid_ip_format(ip_str: string) {
if (
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
ip_str
)
) {
return true;
}
return false;
}
// Convert IP address string to 32-bit integer
export function ipToNumber(ip: string): number {
const parts = ip.split(".").map(Number);
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
}
// Validate that netmask is valid (contiguous 1s followed by 0s)
export function isValidNetmask(netmask: string): boolean {
if (!is_valid_ip_format(netmask)) return false;
const maskNum = ipToNumber(netmask);
// Check if mask is valid: all 1s followed by all 0s
// Invert the mask, add 1, and check if result is a power of 2
const inverted = ~maskNum >>> 0; // Use unsigned right shift to handle 32-bit
const isValid = (inverted & (inverted + 1)) === 0;
// Also check that mask is not all 0s or all 1s
return isValid && maskNum !== 0 && maskNum !== 0xFFFFFFFF;
}
// Check if netmask is practical for network use (has space for at least IP + Gateway)
// Returns true if netmask allows at least 2 host addresses
export function isPracticalNetmask(netmask: string): boolean {
if (!isValidNetmask(netmask)) return false;
const maskNum = ipToNumber(netmask);
const inverted = ~maskNum >>> 0;
const hostBits = Math.log2(inverted + 1);
// Need at least 2 host addresses (IP + Gateway), so hostBits must be >= 1
// /32 (255.255.255.255) has 0 host bits - not practical
// /31 (255.255.255.254) has 1 host bit but only 2 addresses total (network + broadcast) - not practical for IP + Gateway
// /30 (255.255.255.252) has 2 host bits = 4 addresses (network + 2 hosts + broadcast) - minimum practical
return hostBits >= 2;
}
// Calculate network address from IP and netmask
export function getNetworkAddress(ip: string, netmask: string): number {
return ipToNumber(ip) & ipToNumber(netmask);
}
// Calculate broadcast address from IP and netmask
export function getBroadcastAddress(ip: string, netmask: string): number {
const networkAddr = getNetworkAddress(ip, netmask);
const invertedMask = ~ipToNumber(netmask) >>> 0;
return networkAddr | invertedMask;
}
// Check if IP is in valid host range (not network address, not broadcast address)
export function isInValidHostRange(ip: string, netmask: string): boolean {
const ipNum = ipToNumber(ip);
const networkAddr = getNetworkAddress(ip, netmask);
const broadcastAddr = getBroadcastAddress(ip, netmask);
// IP must not be network address (all host bits 0) or broadcast address (all host bits 1)
return ipNum !== networkAddr && ipNum !== broadcastAddr;
}
// Check if two IPs are in the same subnet
export function isInSameSubnet(ip1: string, ip2: string, netmask: string): boolean {
return getNetworkAddress(ip1, netmask) === getNetworkAddress(ip2, netmask);
}

View file

@ -0,0 +1,140 @@
function showToast(message: string, type: "info" | "success" | "warning" | "error" = "info") {
// Determine the correct parent: if a <dialog> is open, append inside it
// so toasts render in the browser's top layer above the modal backdrop
const openDialog = document.querySelector("dialog[open]");
const parent = openDialog || document.body;
// Create toast container if it doesn't exist within the target parent
let toastContainer = parent.querySelector("#toast-container") as HTMLElement | null;
if (!toastContainer) {
toastContainer = document.createElement("div");
toastContainer.id = "toast-container";
toastContainer.className = "toast toast-top toast-end z-50";
toastContainer.style.zIndex = "9999";
parent.appendChild(toastContainer);
}
// Create toast element
const toast = document.createElement("div");
toast.className = `alert alert-${type}`;
toast.innerHTML = `<span>${message}</span>`;
// Add to container
toastContainer.appendChild(toast);
// Play sound based on type
playToastSound(type);
// Auto remove after 3 seconds
setTimeout(() => {
toast.remove();
}, 3000);
}
// Sound function for toasts
function playToastSound(type: "info" | "success" | "warning" | "error") {
try {
// Create audio context
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
// Different frequencies for different types
let frequency: number;
switch (type) {
case "success":
frequency = 800; // Higher pitch for success
break;
case "warning":
frequency = 600; // Medium pitch for warning
break;
case "error":
frequency = 400; // Lower pitch for error
break;
case "info":
default:
frequency = 500; // Default pitch for info
break;
}
// Create oscillator
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime);
oscillator.type = 'sine';
// Set volume and duration
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
// Play sound
// Play 3 bongs in sequence
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
// Second bong
setTimeout(() => {
const oscillator2 = audioContext.createOscillator();
const gainNode2 = audioContext.createGain();
oscillator2.connect(gainNode2);
gainNode2.connect(audioContext.destination);
oscillator2.frequency.setValueAtTime(frequency, audioContext.currentTime);
oscillator2.type = 'sine';
gainNode2.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
oscillator2.start(audioContext.currentTime);
oscillator2.stop(audioContext.currentTime + 0.1);
}, 100);
// Third bong (longer for error effect)
setTimeout(() => {
const oscillator3 = audioContext.createOscillator();
const gainNode3 = audioContext.createGain();
oscillator3.connect(gainNode3);
gainNode3.connect(audioContext.destination);
oscillator3.frequency.setValueAtTime(frequency, audioContext.currentTime);
oscillator3.type = 'sine';
gainNode3.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode3.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator3.start(audioContext.currentTime);
oscillator3.stop(audioContext.currentTime + 0.2);
}, 200);
setTimeout(() => {
const oscillator5 = audioContext.createOscillator();
const gainNode5 = audioContext.createGain();
oscillator5.connect(gainNode5);
gainNode5.connect(audioContext.destination);
oscillator5.frequency.setValueAtTime(frequency * 1.5, audioContext.currentTime); // 50% higher
oscillator5.type = 'sine';
gainNode5.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode5.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.15);
oscillator5.start(audioContext.currentTime);
oscillator5.stop(audioContext.currentTime + 0.15);
}, 400);
setTimeout(() => {
const oscillator4 = audioContext.createOscillator();
const gainNode4 = audioContext.createGain();
oscillator4.connect(gainNode4);
gainNode4.connect(audioContext.destination);
oscillator4.frequency.setValueAtTime(frequency * 0.7, audioContext.currentTime); // 30% lower
oscillator4.type = 'sine';
gainNode4.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode4.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
oscillator4.start(audioContext.currentTime);
oscillator4.stop(audioContext.currentTime + 0.3);
}, 600);
} catch (error) {
console.log("Audio not supported:", error);
}
}
export { showToast };

View file

@ -0,0 +1,54 @@
// mDNS hostname rules (RFC 952 + RFC 1123 + RFC 6762):
// - Lowercase letters a-z, digits 0-9, hyphens only
// - Cannot start or end with a hyphen
// - Single label (no dots) for mDNS
// - Max 31 chars (firmware NVS limit, stricter than DNS label max of 63)
export const MIN_HOSTNAME_LENGTH = 1;
export const MAX_HOSTNAME_LENGTH = 31;
export const HOSTNAME_PATTERN = "^[a-z0-9](?:[a-z0-9-]{0,29}[a-z0-9])?$";
export const HOSTNAME_REGEX = new RegExp(HOSTNAME_PATTERN);
export const HOSTNAME_URL_SUFFIX = ".local";
export const HOSTNAME_PLACEHOLDER = "hostname";
export function isValidHostname(hostname: string): boolean {
if (typeof hostname !== "string") {
return false;
}
const value = hostname.trim().toLowerCase();
const length = value.length;
if (length < MIN_HOSTNAME_LENGTH || length > MAX_HOSTNAME_LENGTH) {
return false;
}
return HOSTNAME_REGEX.test(value);
}
export function normalizeHostnameValue(rawValue: string): string {
if (!rawValue) return "";
let value = rawValue.trim().toLowerCase();
if (!value) return "";
// Strip protocol prefix
value = value.replace(/^https?:\/\//, "");
// Strip .local suffix (pasted from browser address bar)
value = value.replace(/\.local$/i, "");
// Take only first label — strip everything from first URL separator onward
value = value.split(/[/:.\s]/)[0];
// Strip all characters that are not a-z, 0-9, or hyphen
value = value.replace(/[^a-z0-9-]/g, "");
if (value.length > MAX_HOSTNAME_LENGTH) {
value = value.slice(0, MAX_HOSTNAME_LENGTH);
}
// Leading/trailing hyphens are NOT stripped here so the user can type
// "hostname-test" without the hyphen disappearing mid-word.
// The validator rejects them if the user leaves the field in that state.
return value;
}

View file

@ -1,168 +0,0 @@
---
import Sidebar from "../components/Sidebar.astro";
import GlobalModals from "../components/GlobalModals";
import MemoryProgressBar from "../components/MemoryProgressBar";
import "../styles/global.css";
import Router from "../components/Router.astro";
interface Props {
title?: string;
}
const { title = "Alpha Driver" } = Astro.props;
let currentTheme = "light";
let sidebarOpen = false;
---
<div class="drawer lg:drawer-open">
<input
id="drawer-toggle"
type="checkbox"
class="drawer-toggle"
checked={sidebarOpen}
/>
<div class="drawer-content flex flex-col">
{/* Top Navigation Bar */}
<div class="navbar bg-base-100 shadow-md">
<div class="flex-none lg:hidden">
<label for="drawer-toggle" class="btn btn-square btn-ghost">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-6 h-6 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div class="flex-1">
<span class="text-xl font-audiowide">Alpha Driver Webinterface</span>
</div>
<div class="flex-1 flex justify-center">
<div class="w-80">
<MemoryProgressBar showDetails={false} className="text-xs" client:load />
</div>
</div>
<div class="flex-none">
{/* Theme select */}
<select
class="select select-bordered select-sm mr-2 theme-selector"
id="theme-selector"
value={currentTheme}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="goggles1">Goggles</option>
<option value="gogglesBW">Goggles B&W</option>
<option value="emerald">Nature</option>
<option value="dark-neon">Dark Neon</option>
<option value="retro">Sepia</option>
<option value="halloween">Chocolate</option>
<option value="winter">Winter</option>
</select>
</div>
</div>
{/* Main content */}
<main class="flex-1 p-4 overflow-x-auto" id="spa-main-content" >
{/* Global Update Notification */}
<div id="update-notification" class="alert alert-info mb-4 hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
<path d="M12 9l0 3" />
<path d="M12 15l.01 0" />
</svg>
<span>Software updates are available for your device.</span>
<div>
<button class="btn btn-sm btn-success" onclick="if(typeof window.openUpdaterModal === 'function') window.openUpdaterModal()">
View Updates
</button>
<button class="btn btn-sm btn-ghost" onclick="document.getElementById('update-notification').classList.add('hidden')">
Dismiss
</button>
</div>
</div>
{/* Content will be loaded here by the router */}
<Router />
</main>
</div>
{/* Sidebar */}
<div class="drawer-side z-10">
<label for="drawer-toggle" aria-label="close sidebar" class="drawer-overlay"
></label>
<Sidebar />
</div>
{/* Global Modals */}
<GlobalModals client:load />
</div>
<script>
// Note: Update notification is now controlled by SecurityPage toggles
</script>
<script>
// Type declaration for global function
declare global {
interface Window {
handleNavigation: (path: string) => void;
navigateTo?: (path: string) => void;
}
}
// Theme handling
const themeSelector = document.getElementById(
"theme-selector",
) as HTMLSelectElement;
if (themeSelector) {
const savedTheme = localStorage.getItem("theme") || "light";
themeSelector.value = savedTheme;
document.documentElement.setAttribute("data-theme", savedTheme);
themeSelector.addEventListener("change", (e) => {
const target = e.target as HTMLSelectElement;
const theme = target.value;
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
});
}
// Drawer handling
const drawerToggle = document.getElementById(
"drawer-toggle",
) as HTMLInputElement;
if (drawerToggle) {
drawerToggle.addEventListener("change", (e) => {
const target = e.target as HTMLInputElement;
const checked = target.checked;
drawerToggle.checked = checked;
});
}
// Navigation handling
window.handleNavigation = function (path: string) {
console.log("handleNavigation", path);
// Add vibration feedback on mobile for page navigation
if ('vibrate' in navigator && window.innerWidth < 1024) {
navigator.vibrate(25);
}
if (drawerToggle) {
drawerToggle.checked = false;
}
if (typeof window !== "undefined" && window.navigateTo) {
window.navigateTo(path);
}
};
</script>

View file

@ -1,25 +1,108 @@
---
import { WebSocketProvider } from "../components/WebSocketProvider";
import { TestbenchWebSocketProvider } from "../components/TestbenchWebsocketProvider";
import Layout from "../layouts/Layout.astro";
import Router from "../components/Router.astro";
import { IconPlant } from "@tabler/icons-preact";
import WifiSetupWizard from "../components/WifiSetupWizard.astro";
import NetworkOverview from "../components/NetworkOverview.astro";
---
<html lang="en" data-theme="light">
<html lang="en" data-theme="emerald">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Testbench Webinterface</title>
<meta name="view-transition" content="same-origin" />
<title>HydroBuddy — Setup</title>
</head>
<body>
<Layout title="Alpha Driver - Dashboard" />
{/*<WebSocketProvider client:load />*/}
<body class="min-h-screen bg-gradient-to-b from-emerald-50 to-base-100">
<WebSocketProvider client:load />
<TestbenchWebSocketProvider client:load />
<!-- Hero / greeting -->
<header class="pt-12 pb-8 px-4">
<div class="max-w-3xl mx-auto flex flex-col items-center text-center gap-4">
<div class="text-emerald-600 drop-shadow-sm">
<IconPlant size={96} strokeWidth={1.5} />
</div>
<h1 class="text-5xl sm:text-6xl md:text-7xl font-extrabold tracking-tight text-emerald-700 leading-none">
HydroBuddy
</h1>
<p class="text-lg sm:text-xl text-emerald-900/70 max-w-xl">
Hi! I'm your plant's caretaker — managing pH, water, and airflow so your hydroponic garden thrives.
</p>
<p class="text-sm opacity-60 mt-1" id="connection-status-line">Connecting to the device…</p>
</div>
</header>
<main class="px-4 pb-12">
<div class="max-w-3xl mx-auto">
<!-- Shown while WS is opening or before first network_config_response arrives -->
<div id="loading-state" class="flex flex-col items-center gap-3 py-12">
<span class="loading loading-dots loading-lg text-emerald-600"></span>
<p class="text-sm opacity-60">Waiting for device…</p>
</div>
<!-- 3-step wizard (hostname → scan → connect). Stays hidden until SSID_Station is empty. -->
<section id="wizard-container" class="hidden">
<WifiSetupWizard />
</section>
<!-- Overview / status / reconfigure. Shown once SSID_Station is set. -->
<section id="overview-container" class="hidden">
<NetworkOverview />
</section>
</div>
</main>
<script>
import { $current_network_configuration, ConnectionMode } from "../components/NetworkManagerStore";
import { $wsConnectionStatus } from "../components/AppProviderStore";
const loadingEl = document.getElementById("loading-state");
const wizardEl = document.getElementById("wizard-container");
const overviewEl = document.getElementById("overview-container");
const statusLineEl = document.getElementById("connection-status-line");
// Track whether we've ever seen a real network_config_response. Until we have,
// we stay on the loading view so we don't flash the wizard for half a second
// while the WS is still opening.
let hasFirstConfig = false;
function pickView() {
if (!loadingEl || !wizardEl || !overviewEl) return;
if (!hasFirstConfig) {
loadingEl.classList.remove("hidden");
wizardEl.classList.add("hidden");
overviewEl.classList.add("hidden");
return;
}
const cfg = $current_network_configuration.get();
// "Unconfigured" = STA SSID never set. Firmware boots in APSTA with empty SSID_Station,
// so this is exactly the first-time-setup case.
const isUnconfigured = cfg.SSID_Station === "";
loadingEl.classList.add("hidden");
wizardEl.classList.toggle("hidden", !isUnconfigured);
overviewEl.classList.toggle("hidden", isUnconfigured);
}
// The store starts at default values (Connection_Mode UNKNOWN, SSID_Station "").
// The first message that flips this is "network_config_response" arriving from the device
// — at which point hasFirstConfig becomes true and we render the real view.
$current_network_configuration.subscribe((cfg) => {
if (cfg.Connection_Mode !== ConnectionMode.CONNECTION_MODE_UNKNOWN || cfg.MDNS_Hostname !== "hydrobuddy") {
hasFirstConfig = true;
}
pickView();
});
$wsConnectionStatus.subscribe((status) => {
if (!statusLineEl) return;
if (status === "Connected") {
statusLineEl.textContent = hasFirstConfig ? "Connected" : "Connected — fetching configuration…";
} else {
statusLineEl.textContent = "Reconnecting to the device…";
}
});
</script>
</body>
</html>