From a606796d9e860f7e564d134035608afc0c23302b Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 12:09:47 +0700 Subject: [PATCH 01/14] initial commit --- .gitignore | 41 ++ README.md | 36 + app/api/sensors/config/route.ts | 52 ++ app/api/sensors/route.ts | 295 +++++++++ app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 122 ++++ app/layout.tsx | 34 + app/page.tsx | 7 + bun.lock | 991 ++++++++++++++++++++++++++++ components.json | 21 + components/bed-pressure-monitor.tsx | 525 +++++++++++++++ components/ui/badge.tsx | 46 ++ components/ui/button.tsx | 59 ++ components/ui/card.tsx | 92 +++ eslint.config.mjs | 16 + lib/utils.ts | 6 + next.config.ts | 7 + package.json | 37 ++ postcss.config.mjs | 5 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + services/BedHardware.ts | 181 +++++ tsconfig.json | 28 + 26 files changed, 2606 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/api/sensors/config/route.ts create mode 100644 app/api/sensors/route.ts create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 bun.lock create mode 100644 components.json create mode 100644 components/bed-pressure-monitor.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 eslint.config.mjs create mode 100644 lib/utils.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 services/BedHardware.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/api/sensors/config/route.ts b/app/api/sensors/config/route.ts new file mode 100644 index 0000000..86350a2 --- /dev/null +++ b/app/api/sensors/config/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; + +// Sensor configuration that matches the API route +const SENSOR_CONFIG = [ + // Head area + { id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2 }, + { id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3 }, + + // Shoulder area + { id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4 }, + { id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5 }, + + // Upper back + { id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6 }, + { id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7 }, + { id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8 }, + + // Lower back/Hip area + { id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9 }, + { id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10 }, + { id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11 }, + + // Thigh area + { id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12 }, + { id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13 }, + + // Calf area (mock data) + { id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf" }, + { id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf" }, + + // Feet (mock data) + { id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot" }, + { id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot" }, +]; + +export async function GET() { + try { + return NextResponse.json({ + success: true, + sensors: SENSOR_CONFIG, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('Sensor config API error:', error); + return NextResponse.json({ + success: false, + error: 'Failed to get sensor configuration', + sensors: [], + timestamp: new Date().toISOString() + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/sensors/route.ts b/app/api/sensors/route.ts new file mode 100644 index 0000000..1e5fdd3 --- /dev/null +++ b/app/api/sensors/route.ts @@ -0,0 +1,295 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { BedHardware, PinState, PinChange } from '@/services/BedHardware'; + +// Complete sensor configuration with positions and pin mappings +const SENSOR_CONFIG = [ + // Head area + { id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2, baseNoise: 15 }, + { id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3, baseNoise: 12 }, + + // Shoulder area + { id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4, baseNoise: 20 }, + { id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5, baseNoise: 18 }, + + // Upper back + { id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6, baseNoise: 25 }, + { id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7, baseNoise: 30 }, + { id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8, baseNoise: 22 }, + + // Lower back/Hip area + { id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9, baseNoise: 35 }, + { id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10, baseNoise: 40 }, + { id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11, baseNoise: 32 }, + + // Thigh area + { id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12, baseNoise: 28 }, + { id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13, baseNoise: 26 }, + + // Calf area (mock data) + { id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf", baseNoise: 15 }, + { id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf", baseNoise: 18 }, + + // Feet (mock data) + { id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot", baseNoise: 10 }, + { id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot", baseNoise: 12 }, +]; + +// Create pin mapping from sensor config +const PIN_SENSOR_MAP: Record = {}; +SENSOR_CONFIG.forEach(sensor => { + if (sensor.pin) { + PIN_SENSOR_MAP[sensor.pin] = sensor; + } +}); + +let bedHardware: BedHardware | null = null; +const sensorData: Record; + status: string; +}> = {}; +let isHardwareConnected = false; + +// Initialize all sensor data +function initializeSensorData() { + SENSOR_CONFIG.forEach(sensor => { + if (!sensorData[sensor.id]) { + sensorData[sensor.id] = { + id: sensor.id, + x: sensor.x, + y: sensor.y, + label: sensor.label, + zone: sensor.zone, + pressure: 30 + Math.random() * 20, // Start with baseline pressure + pin: sensor.pin, + timestamp: new Date().toISOString(), + source: sensor.pin ? 'hardware' : 'mock', + data: generateTimeSeriesData(), + status: 'normal' + }; + } + }); +} + +// Generate time series data for a sensor +function generateTimeSeriesData(hours = 1) { + const data = []; + const now = new Date(); + + for (let i = hours * 60; i >= 0; i -= 5) { + const time = new Date(now.getTime() - i * 60 * 1000); + data.push({ + time: time.toLocaleTimeString("en-US", { hour12: false }), + timestamp: time.getTime(), + pressure: Math.random() * 100 + Math.sin(i / 60) * 20 + 40, + }); + } + return data; +} + +// Initialize hardware connection +async function initializeHardware() { + if (bedHardware && isHardwareConnected) return; + + try { + // Try to find available serial ports + const availablePorts = await BedHardware.listPorts(); + const portPath = availablePorts.find(port => + port.includes('ttyUSB') || port.includes('ttyACM') || port.includes('cu.usbmodem') + ) || '/dev/ttyUSB0'; // Default fallback + + bedHardware = new BedHardware(portPath, 9600); + + bedHardware.on('connected', () => { + console.log('BedHardware connected'); + isHardwareConnected = true; + }); + + bedHardware.on('disconnected', () => { + console.log('BedHardware disconnected'); + isHardwareConnected = false; + }); + + bedHardware.on('pinChanged', (change: PinChange) => { + updateSensorFromPin(change.pin, change.currentState); + }); + + bedHardware.on('pinInitialized', (pinState: PinState) => { + updateSensorFromPin(pinState.pin, pinState.state); + }); + + bedHardware.on('error', (error) => { + console.error('BedHardware error:', error); + isHardwareConnected = false; + }); + + await bedHardware.connect(); + } catch (error) { + console.warn('Failed to connect to hardware, using mock data:', error); + isHardwareConnected = false; + } +} + +// Convert digital pin state to analog pressure value with noise +function digitalToPressure(pinState: number, baseNoise: number): number { + // Base pressure from digital state + const basePressure = pinState === 1 ? 60 : 20; // High when pin is HIGH, low when LOW + + // Add realistic noise and variation + const timeNoise = Math.sin(Date.now() / 10000) * 10; // Slow oscillation + const randomNoise = (Math.random() - 0.5) * baseNoise; + const sensorDrift = (Math.random() - 0.5) * 5; // Small drift + + const pressure = basePressure + timeNoise + randomNoise + sensorDrift; + + // Clamp between 0 and 100 + return Math.max(0, Math.min(100, pressure)); +} + +// Update sensor data from pin change +function updateSensorFromPin(pin: number, state: number) { + const mapping = PIN_SENSOR_MAP[pin]; + if (!mapping) return; + + const pressure = digitalToPressure(state, mapping.baseNoise); + + if (sensorData[mapping.id]) { + // Update existing sensor data + const currentData = sensorData[mapping.id]; + sensorData[mapping.id] = { + ...currentData, + pressure, + digitalState: state, + timestamp: new Date().toISOString(), + source: 'hardware', + data: [ + ...currentData.data.slice(-287), // Keep last ~24 hours (288 points at 5min intervals) + { + time: new Date().toLocaleTimeString("en-US", { hour12: false }), + timestamp: Date.now(), + pressure: pressure, + } + ], + status: pressure > 80 ? 'critical' : pressure > 60 ? 'warning' : 'normal' + }; + } +} + +// Update mock sensor data with variation +function updateMockSensorData() { + SENSOR_CONFIG.forEach(sensor => { + if (!sensor.pin && sensorData[sensor.id]) { + // This is a mock sensor, update with variation + const currentSensor = sensorData[sensor.id]; + const variation = (Math.random() - 0.5) * 10; + const newPressure = Math.max(0, Math.min(100, currentSensor.pressure + variation)); + + sensorData[sensor.id] = { + ...currentSensor, + pressure: newPressure, + timestamp: new Date().toISOString(), + data: [ + ...currentSensor.data.slice(-287), // Keep last ~24 hours + { + time: new Date().toLocaleTimeString("en-US", { hour12: false }), + timestamp: Date.now(), + pressure: newPressure, + } + ], + status: newPressure > 80 ? 'critical' : newPressure > 60 ? 'warning' : 'normal' + }; + } + }); +} + +export async function GET() { + try { + // Initialize sensor data if not already done + if (Object.keys(sensorData).length === 0) { + initializeSensorData(); + } + + // Initialize hardware if not already done + if (!bedHardware) { + await initializeHardware(); + } + + // Update mock sensor data + updateMockSensorData(); + + // If hardware is connected, get current pin states + if (isHardwareConnected && bedHardware) { + const pinStates = bedHardware.getAllPinStates(); + pinStates.forEach(pinState => { + updateSensorFromPin(pinState.pin, pinState.state); + }); + } + + // Return all sensor data + return NextResponse.json({ + success: true, + connected: isHardwareConnected, + sensors: Object.values(sensorData), + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Sensor API error:', error); + return NextResponse.json({ + success: false, + error: 'Failed to get sensor data', + connected: false, + sensors: [], + timestamp: new Date().toISOString() + }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + if (body.action === 'connect') { + await initializeHardware(); + return NextResponse.json({ + success: true, + connected: isHardwareConnected, + message: isHardwareConnected ? 'Hardware connected' : 'Using mock data' + }); + } + + if (body.action === 'disconnect') { + if (bedHardware) { + await bedHardware.disconnect(); + bedHardware = null; + isHardwareConnected = false; + } + return NextResponse.json({ + success: true, + connected: false, + message: 'Hardware disconnected' + }); + } + + return NextResponse.json({ + success: false, + error: 'Invalid action' + }, { status: 400 }); + + } catch (error) { + console.error('Sensor API POST error:', error); + return NextResponse.json({ + success: false, + error: 'Failed to process request' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..dc98be7 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,122 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f7fa87e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..d97247f --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import Component from "@/components/bed-pressure-monitor" + +export default function Page() { + return +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..2d15527 --- /dev/null +++ b/bun.lock @@ -0,0 +1,991 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "m2-inno-bedpressure", + "dependencies": { + "@radix-ui/react-slot": "^1.2.3", + "@types/serialport": "^10.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.519.0", + "next": "15.3.4", + "port": "^0.8.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.4", + "serial": "^0.0.9", + "tailwind-merge": "^3.3.1", + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.3.4", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.4", + "typescript": "^5", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="], + + "@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/config-array": ["@eslint/config-array@0.20.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.2.3", "", {}, "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg=="], + + "@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + + "@eslint/js": ["@eslint/js@9.29.0", "", {}, "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.2", "", { "dependencies": { "@eslint/core": "^0.15.0", "levn": "^0.4.1" } }, "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.1.0" }, "os": "darwin", "cpu": "x64" }, "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.1.0" }, "os": "linux", "cpu": "arm" }, "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.1.0" }, "os": "linux", "cpu": "s390x" }, "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.2", "", { "dependencies": { "@emnapi/runtime": "^1.4.3" }, "cpu": "none" }, "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" } }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="], + + "@next/env": ["@next/env@15.3.4", "", {}, "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ=="], + + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.3.4", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-l8ZQOCCg7adwmsnFm8m5q9eIPAHdaB2F3cxhufYtVo84pymwKuWfpYTKcUiFcutJdp9xGHC+F1Uq3xnFU1B/7g=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-wFyZ7X470YJQtpKot4xCY3gpdn8lE9nTlldG07/kJYexCUpX1piX+MBfZdvulo+t1yADFVEuzFfVHfklfEx8kw=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEbH9rv9o7I12qPyvZNVTyP/PWKqOp8clvnoYZQiX800KkqsaJZuOXkWgMa7ANCCh/oEN2ZQheh3yH8/kWPSEg=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Cf8sr0ufuC/nu/yQ76AnarbSAXcwG/wj+1xFPNbyNo8ltA6kw5d5YqO8kQuwVIxk13SBdtgXrNyom3ZosHAy4A=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ay5+qADDN3rwRbRpEhTOreOn1OyJIXS60tg9WMYTWCy3fB6rGoyjLVxc4dR9PYjEdR2iDYsaF5h03NA+XuYPQQ=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + + "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="], + + "@serialport/binding-mock": ["@serialport/binding-mock@10.2.2", "", { "dependencies": { "@serialport/bindings-interface": "^1.2.1", "debug": "^4.3.3" } }, "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw=="], + + "@serialport/bindings-cpp": ["@serialport/bindings-cpp@13.0.0", "", { "dependencies": { "@serialport/bindings-interface": "1.2.2", "@serialport/parser-readline": "12.0.0", "debug": "4.4.0", "node-addon-api": "8.3.0", "node-gyp-build": "4.8.4" } }, "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ=="], + + "@serialport/bindings-interface": ["@serialport/bindings-interface@1.2.2", "", {}, "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA=="], + + "@serialport/parser-byte-length": ["@serialport/parser-byte-length@13.0.0", "", {}, "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng=="], + + "@serialport/parser-cctalk": ["@serialport/parser-cctalk@13.0.0", "", {}, "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA=="], + + "@serialport/parser-delimiter": ["@serialport/parser-delimiter@13.0.0", "", {}, "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig=="], + + "@serialport/parser-inter-byte-timeout": ["@serialport/parser-inter-byte-timeout@13.0.0", "", {}, "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA=="], + + "@serialport/parser-packet-length": ["@serialport/parser-packet-length@13.0.0", "", {}, "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg=="], + + "@serialport/parser-readline": ["@serialport/parser-readline@13.0.0", "", { "dependencies": { "@serialport/parser-delimiter": "13.0.0" } }, "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA=="], + + "@serialport/parser-ready": ["@serialport/parser-ready@13.0.0", "", {}, "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw=="], + + "@serialport/parser-regex": ["@serialport/parser-regex@13.0.0", "", {}, "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA=="], + + "@serialport/parser-slip-encoder": ["@serialport/parser-slip-encoder@13.0.0", "", {}, "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg=="], + + "@serialport/parser-spacepacket": ["@serialport/parser-spacepacket@13.0.0", "", {}, "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg=="], + + "@serialport/stream": ["@serialport/stream@13.0.0", "", { "dependencies": { "@serialport/bindings-interface": "1.2.2", "debug": "4.4.0" } }, "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.10", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.10" } }, "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.10", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.10", "@tailwindcss/oxide-darwin-arm64": "4.1.10", "@tailwindcss/oxide-darwin-x64": "4.1.10", "@tailwindcss/oxide-freebsd-x64": "4.1.10", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", "@tailwindcss/oxide-linux-x64-musl": "4.1.10", "@tailwindcss/oxide-wasm32-wasi": "4.1.10", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.10", "", { "os": "android", "cpu": "arm64" }, "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10", "", { "os": "linux", "cpu": "arm" }, "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.10", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.10", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.10", "", { "os": "win32", "cpu": "x64" }, "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.10", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.10", "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", "tailwindcss": "4.1.10" } }, "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + + "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + + "@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="], + + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + + "@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="], + + "@types/serialport": ["@types/serialport@10.2.0", "", { "dependencies": { "serialport": "*" } }, "sha512-jddRnvcjZLSQHyK8anaUFAAwnET8bcWoM2TVU7SZyY4xVqnorhsvsZLVfqgYk/zintnqrUTCshE/CgqFnBLEcg=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.34.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/type-utils": "8.34.1", "@typescript-eslint/utils": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.34.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.34.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/typescript-estree": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.1", "@typescript-eslint/types": "^8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1" } }, "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.34.1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.34.1", "@typescript-eslint/utils": "8.34.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Tv7tCCr6e5m8hP4+xFugcrwTOucB8lshffJ6zf1mF1TbU67R+ntCc6DzLNKM+s/uzDyv8gLq7tufaAhIBYeV8g=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.1", "@typescript-eslint/tsconfig-utils": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.34.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/typescript-estree": "8.34.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.9.1", "", { "os": "android", "cpu": "arm" }, "sha512-dd7yIp1hfJFX9ZlVLQRrh/Re9WMUHHmF9hrKD1yIvxcyNr2BhQ3xc1upAVhy8NijadnCswAxWQu8MkkSMC1qXQ=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.9.1", "", { "os": "android", "cpu": "arm64" }, "sha512-EzUPcMFtDVlo5yrbzMqUsGq3HnLXw+3ZOhSd7CUaDmbTtnrzM+RO2ntw2dm2wjbbc5djWj3yX0wzbbg8pLhx8g=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.9.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nB+dna3q4kOleKFcSZJ/wDXIsAd1kpMO9XrVAt8tG3RDWJ6vi+Ic6bpz4cmg5tWNeCfHEY4KuqJCB+pKejPEmQ=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.9.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-aKWHCrOGaCGwZcekf3TnczQoBxk5w//W3RZ4EQyhux6rKDwBPgDU9Y2yGigCV1Z+8DWqZgVGQi+hdpnlSy3a1w=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.9.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4dIEMXrXt0UqDVgrsUd1I+NoIzVQWXy/CNhgpfS75rOOMK/4Abn0Mx2M2gWH4Mk9+ds/ASAiCmqoUFynmMY5hA=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.9.1", "", { "os": "linux", "cpu": "arm" }, "sha512-vtvS13IXPs1eE8DuS/soiosqMBeyh50YLRZ+p7EaIKAPPeevRnA9G/wu/KbVt01ZD5qiGjxS+CGIdVC7I6gTOw=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.9.1", "", { "os": "linux", "cpu": "arm" }, "sha512-BfdnN6aZ7NcX8djW8SR6GOJc+K+sFhWRF4vJueVE0vbUu5N1bLnBpxJg1TGlhSyo+ImC4SR0jcNiKN0jdoxt+A=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.9.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jhge7lFtH0QqfRz2PyJjJXWENqywPteITd+nOS0L6AhbZli+UmEyGBd2Sstt1c+l9C+j/YvKTl9wJo9PPmsFNg=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.9.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ofdK/ow+ZSbSU0pRoB7uBaiRHeaAOYQFU5Spp87LdcPL/P1RhbCTMSIYVb61XWzsVEmYKjHFtoIE0wxP6AFvrA=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.9.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eC8SXVn8de67HacqU7PoGdHA+9tGbqfEdD05AEFRAB81ejeQtNi5Fx7lPcxpLH79DW0BnMAHau3hi4RVkHfSCw=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.9.1", "", { "os": "linux", "cpu": "none" }, "sha512-fIkwvAAQ41kfoGWfzeJ33iLGShl0JEDZHrMnwTHMErUcPkaaZRJYjQjsFhMl315NEQ4mmTlC+2nfK/J2IszDOw=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.9.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAAszxImSOFLk44aLwnSqpcOdce8sBcxASledSzuFAd8Q5ZhhVck472SisspnzHdc7THCvGXiUeZ2hOC7NUoBQ=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.9.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-QoP9vkY+THuQdZi05bA6s6XwFd6HIz3qlx82v9bTOgxeqin/3C12Ye7f7EOD00RQ36OtOPWnhEMMm84sv7d1XQ=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.9.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/p77cGN/h9zbsfCseAP5gY7tK+7+DdM8fkPfr9d1ye1fsF6bmtGbtZN6e/8j4jCZ9NEIBBkT0GhdgixSelTK9g=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.9.1", "", { "os": "linux", "cpu": "x64" }, "sha512-wInTqT3Bu9u50mDStEig1v8uxEL2Ht+K8pir/YhyyrM5ordJtxoqzsL1vR/CQzOJuDunUTrDkMM0apjW/d7/PA=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.9.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-eNwqO5kUa+1k7yFIircwwiniKWA0UFHo2Cfm8LYgkh9km7uMad+0x7X7oXbQonJXlqfitBTSjhA0un+DsHIrhw=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.9.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Eaz1xMUnoa2mFqh20mPqSdbYl6crnk8HnIXDu6nsla9zpgZJZO8w3c1gvNN/4Eb0RXRq3K9OG6mu8vw14gIqiA=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.9.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-H/+d+5BGlnEQif0gnwWmYbYv7HJj563PUKJfn8PlmzF8UmF+8KxdvXdwCsoOqh4HHnENnoLrav9NYBrv76x1wQ=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.9.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rS86wI4R6cknYM3is3grCb/laE8XBEbpWAMSIPjYfmYp75KL5dT87jXF2orDa4tQYg5aajP5G8Fgh34dRyR+Rw=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001724", "", {}, "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="], + + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.6", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.4", "safe-array-concat": "^1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.29.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.1", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.29.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ=="], + + "eslint-config-next": ["eslint-config-next@15.3.4", "", { "dependencies": { "@next/eslint-plugin-next": "15.3.4", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-WqeumCq57QcTP2lYlV6BRUySfGiBYEXlQ1L0mQ+u4N4X4ZhUVSSQ52WtjqHv60pJ6dD7jn+YZc0d1/ZSsxccvg=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], + + "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], + + "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="], + + "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lucide-react": ["lucide-react@0.519.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cLJyjRKBJFzaZ/+1oIeQaH7XUdxKOYU3uANcGSrKdIZWElmNbRAm8RXKiTJS7AWLCBOS8b7A497Al/kCHozd+A=="], + + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], + + "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "napi-postinstall": ["napi-postinstall@0.2.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "next": ["next@15.3.4", "", { "dependencies": { "@next/env": "15.3.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.4", "@next/swc-darwin-x64": "15.3.4", "@next/swc-linux-arm64-gnu": "15.3.4", "@next/swc-linux-arm64-musl": "15.3.4", "@next/swc-linux-x64-gnu": "15.3.4", "@next/swc-linux-x64-musl": "15.3.4", "@next/swc-win32-arm64-msvc": "15.3.4", "@next/swc-win32-x64-msvc": "15.3.4", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA=="], + + "node-addon-api": ["node-addon-api@8.3.0", "", {}, "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + + "port": ["port@0.8.1", "", {}, "sha512-PlmFeMxN/SD9LJ4enMg7hFTJuj2fpy+ZWndlts+XfNB6K207/yPhOXYrav/q2oLb5ZTiXnTRnnfuvvH+AVJFDg=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + + "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "serial": ["serial@0.0.9", "", {}, "sha512-VdFZU3cp9NPCQrwbmqXhMZnDetsmVKK/YBF2i+sq+iU96p3+qm0GZypB91/Gmp12Pe1DjVd7ObsVj22qWNk58w=="], + + "serialport": ["serialport@13.0.0", "", { "dependencies": { "@serialport/binding-mock": "10.2.2", "@serialport/bindings-cpp": "13.0.0", "@serialport/parser-byte-length": "13.0.0", "@serialport/parser-cctalk": "13.0.0", "@serialport/parser-delimiter": "13.0.0", "@serialport/parser-inter-byte-timeout": "13.0.0", "@serialport/parser-packet-length": "13.0.0", "@serialport/parser-readline": "13.0.0", "@serialport/parser-ready": "13.0.0", "@serialport/parser-regex": "13.0.0", "@serialport/parser-slip-encoder": "13.0.0", "@serialport/parser-spacepacket": "13.0.0", "@serialport/stream": "13.0.0", "debug": "4.4.0" } }, "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], + + "tailwindcss": ["tailwindcss@4.1.10", "", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="], + + "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + + "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tw-animate-css": ["tw-animate-css@1.3.4", "", {}, "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unrs-resolver": ["unrs-resolver@1.9.1", "", { "dependencies": { "napi-postinstall": "^0.2.2" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.9.1", "@unrs/resolver-binding-android-arm64": "1.9.1", "@unrs/resolver-binding-darwin-arm64": "1.9.1", "@unrs/resolver-binding-darwin-x64": "1.9.1", "@unrs/resolver-binding-freebsd-x64": "1.9.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.9.1", "@unrs/resolver-binding-linux-arm64-musl": "1.9.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.9.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.9.1", "@unrs/resolver-binding-linux-x64-gnu": "1.9.1", "@unrs/resolver-binding-linux-x64-musl": "1.9.1", "@unrs/resolver-binding-wasm32-wasi": "1.9.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.9.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.9.1", "@unrs/resolver-binding-win32-x64-msvc": "1.9.1" } }, "sha512-4AZVxP05JGN6DwqIkSP4VKLOcwQa5l37SWHF/ahcuqBMbfxbpN1L1QKafEhWCziHhzKex9H/AR09H0OuVyU+9g=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw=="], + + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + + "@serialport/bindings-cpp/@serialport/parser-readline": ["@serialport/parser-readline@12.0.0", "", { "dependencies": { "@serialport/parser-delimiter": "12.0.0" } }, "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w=="], + + "@serialport/bindings-cpp/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "@serialport/stream/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" }, "bundled": true }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "serialport/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "@serialport/bindings-cpp/@serialport/parser-readline/@serialport/parser-delimiter": ["@serialport/parser-delimiter@12.0.0", "", {}, "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw=="], + + "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..335484f --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/bed-pressure-monitor.tsx b/components/bed-pressure-monitor.tsx new file mode 100644 index 0000000..5b249f8 --- /dev/null +++ b/components/bed-pressure-monitor.tsx @@ -0,0 +1,525 @@ +"use client" + +import { useState, useEffect } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" +import { Activity, AlertTriangle, Download, Pause, Play, Settings, User } from "lucide-react" + +// Mock data generator +const generateTimeSeriesData = (hours = 24) => { + const data = [] + const now = new Date() + + for (let i = hours * 60; i >= 0; i -= 5) { + const time = new Date(now.getTime() - i * 60 * 1000) + data.push({ + time: time.toLocaleTimeString("en-US", { hour12: false }), + timestamp: time.getTime(), + pressure: Math.random() * 100 + Math.sin(i / 60) * 20 + 40, + }) + } + return data +} + +// Sensor configuration interface +interface SensorConfig { + id: string; + x: number; + y: number; + zone: string; + label: string; + pin?: number; +} + +const getPressureColor = (pressure: number) => { + if (pressure < 30) return "#22c55e" // Green - Low pressure + if (pressure < 50) return "#eab308" // Yellow - Medium pressure + if (pressure < 70) return "#f97316" // Orange - High pressure + return "#ef4444" // Red - Very high pressure +} + +const getPressureLevel = (pressure: number) => { + if (pressure < 30) return "Low" + if (pressure < 50) return "Medium" + if (pressure < 70) return "High" + return "Critical" +} + +interface SensorData { + id: string; + x: number; + y: number; + zone: string; + label: string; + currentPressure: number; + data: Array<{ time: string; timestamp: number; pressure: number }>; + status: string; + source?: 'hardware' | 'mock'; + pin?: number; + digitalState?: number; +} + +export default function Component() { + const [sensorData, setSensorData] = useState>({}) + const [sensorConfig, setSensorConfig] = useState([]) + const [selectedSensor, setSelectedSensor] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + const [isMonitoring, setIsMonitoring] = useState(true) + const [alerts, setAlerts] = useState>([]) + + // Initialize sensor configuration + useEffect(() => { + const fetchSensorConfig = async () => { + try { + const response = await fetch('/api/sensors/config') + const data = await response.json() + + if (data.success && data.sensors) { + setSensorConfig(data.sensors) + } + } catch (error) { + console.error('Failed to fetch sensor config:', error) + } + } + + fetchSensorConfig() + }, []) + + // Initialize sensor data + useEffect(() => { + if (sensorConfig.length === 0) return + + const initialData: Record = {} + sensorConfig.forEach((sensor) => { + initialData[sensor.id] = { + ...sensor, + currentPressure: Math.random() * 100, + data: generateTimeSeriesData(), + status: "normal", + } + }) + setSensorData(initialData) + }, [sensorConfig]) + + // Fetch sensor data from API + useEffect(() => { + if (!isMonitoring) return + + const fetchSensorData = async () => { + try { + const response = await fetch('/api/sensors') + const data = await response.json() + + if (data.success && data.sensors) { + setSensorData((prev) => { + const updated = { ...prev } + const newAlerts: Array<{ id: string; message: string; time: string }> = [] + + data.sensors.forEach((sensor: { + id: string; + label: string; + zone: string; + pressure: number; + source: 'hardware' | 'mock'; + pin?: number; + digitalState?: number; + }) => { + const currentSensor = updated[sensor.id] + const newPressure = sensor.pressure + + // Check for alerts + if (newPressure > 80 && currentSensor && currentSensor.currentPressure <= 80) { + newAlerts.push({ + id: `${sensor.id}-${Date.now()}`, + message: `High pressure detected at ${sensor.label}`, + time: new Date().toLocaleTimeString(), + }) + } + + // Update sensor data + updated[sensor.id] = { + ...sensorConfig.find(s => s.id === sensor.id), + id: sensor.id, + x: sensorConfig.find(s => s.id === sensor.id)?.x || 50, + y: sensorConfig.find(s => s.id === sensor.id)?.y || 50, + zone: sensor.zone, + label: sensor.label, + currentPressure: newPressure, + data: currentSensor ? [ + ...currentSensor.data.slice(1), + { + time: new Date().toLocaleTimeString("en-US", { hour12: false }), + timestamp: Date.now(), + pressure: newPressure, + }, + ] : [{ + time: new Date().toLocaleTimeString("en-US", { hour12: false }), + timestamp: Date.now(), + pressure: newPressure, + }], + status: newPressure > 80 ? "critical" : newPressure > 60 ? "warning" : "normal", + source: sensor.source, + pin: sensor.pin, + digitalState: sensor.digitalState + } + }) + + if (newAlerts.length > 0) { + setAlerts((prev) => [...newAlerts, ...prev].slice(0, 10)) + } + + return updated + }) + } + } catch (error) { + console.error('Failed to fetch sensor data:', error) + } + } + + // Initial fetch + fetchSensorData() + + // Set up polling + const interval = setInterval(fetchSensorData, 2000) + + return () => clearInterval(interval) + }, [isMonitoring, sensorConfig]) + + const averagePressure = + Object.values(sensorData).reduce((sum: number, sensor: SensorData) => sum + (sensor.currentPressure || 0), 0) / + Object.keys(sensorData).length + + const criticalSensors = Object.values(sensorData).filter((sensor: SensorData) => sensor.currentPressure > 80).length + + return ( +
+
+ {/* Header */} +
+
+
+ +

Bed Pressure Monitor

+
+ + {isMonitoring ? "Live" : "Paused"} + +
+ +
+ + + +
+
+ + {/* Stats Cards */} +
+ + +
+ +
+

Patient

+

John Doe - Room 204

+
+
+
+
+ + + +
+

Average Pressure

+

+ {averagePressure.toFixed(1)} mmHg +

+
+
+
+ + + +
+

Active Sensors

+

{Object.keys(sensorData).length}

+
+
+
+ + + +
+ 0 ? "text-red-600" : "text-gray-400"}`} /> +
+

Critical Alerts

+

0 ? "text-red-600" : "text-gray-600"}`}> + {criticalSensors} +

+
+
+
+
+
+ +
+ {/* Bed Visualization */} +
+ + + Pressure Distribution Map +

Click on any sensor point to view detailed pressure graphs

+
+ +
+ {/* Bed outline */} + + {/* Bed frame */} + + + {/* Pillow area */} + + + {/* Pressure sensors */} + {sensorConfig.map((sensor) => { + const sensorInfo = sensorData[sensor.id] + if (!sensorInfo) return null + + return ( + { + setSelectedSensor(sensor.id) + setIsModalOpen(true) + }} + /> + ) + })} + + + {/* Pressure Legend */} +
+
+
+ Low ({"<"}30) +
+
+
+ Medium (30-50) +
+
+
+ High (50-70) +
+
+
+ Critical ({">"}70) +
+
+
+
+
+
+ + {/* Sensor Details & Alerts */} +
+ {/* Pressure Graph Modal */} + {isModalOpen && selectedSensor && sensorData[selectedSensor] && ( +
+
+
+
+
+

{sensorData[selectedSensor].label}

+

Pressure Monitoring Details

+
+ +
+ +
+ + +

Current Pressure

+

+ {sensorData[selectedSensor].currentPressure.toFixed(1)} +

+

mmHg

+
+
+ + + +

Status Level

+ + {getPressureLevel(sensorData[selectedSensor].currentPressure)} + +
+
+ + + +

Body Zone

+

{sensorData[selectedSensor].zone}

+
+
+
+ + {/* Large Pressure Chart */} + + + 24-Hour Pressure Trend + + +
+ + + + + + [`${value.toFixed(1)} mmHg`, "Pressure"]} + labelFormatter={(label) => `Time: ${label}`} + /> + + + +
+
+
+ + {/* Additional Statistics */} +
+ + +

Max Pressure

+

+ {Math.max(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)} +

+
+
+ + + +

Min Pressure

+

+ {Math.min(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)} +

+
+
+ + + +

Average

+

+ {( + sensorData[selectedSensor].data.reduce((sum: number, d: { time: string; timestamp: number; pressure: number }) => sum + d.pressure, 0) / + sensorData[selectedSensor].data.length + ).toFixed(1)} +

+
+
+ + + +

Data Points

+

{sensorData[selectedSensor].data.length}

+
+
+
+ +
+ + +
+
+
+
+ )} + + {/* Alerts */} + + + + + Recent Alerts + + + +
+ {alerts.length === 0 ? ( +

No recent alerts

+ ) : ( + alerts.map((alert) => ( +
+ +
+

{alert.message}

+

{alert.time}

+
+
+ )) + )} +
+
+
+
+
+
+
+ ) +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..d05bbc6 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..c85fb67 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..e9ffa30 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..934ac85 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "m2-inno-bedpressure", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.2.3", + "@types/serialport": "^10.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.519.0", + "next": "15.3.4", + "port": "^0.8.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.4", + "serial": "^0.0.9", + "tailwind-merge": "^3.3.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.3.4", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.4", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/BedHardware.ts b/services/BedHardware.ts new file mode 100644 index 0000000..762fd6a --- /dev/null +++ b/services/BedHardware.ts @@ -0,0 +1,181 @@ +import { SerialPort } from 'serialport'; +import { ReadlineParser } from '@serialport/parser-readline'; +import { EventEmitter } from 'events'; + +export interface PinState { + pin: number; + state: number; + name: string; + timestamp: Date; +} + +export interface PinChange { + pin: number; + previousState: number; + currentState: number; + timestamp: Date; +} + +export class BedHardware extends EventEmitter { + private serialPort: SerialPort | null = null; + private parser: ReadlineParser | null = null; + private pinStates: Map = new Map(); + private isConnected: boolean = false; + + constructor(private portPath: string, private baudRate: number = 9600) { + super(); + } + + async connect(): Promise { + try { + this.serialPort = new SerialPort({ + path: this.portPath, + baudRate: this.baudRate, + autoOpen: false + }); + + this.parser = new ReadlineParser({ delimiter: '\n' }); + this.serialPort.pipe(this.parser); + + // Setup event handlers + this.serialPort.on('open', () => { + this.isConnected = true; + this.emit('connected'); + console.log('Serial port opened'); + }); + + this.serialPort.on('error', (error) => { + this.emit('error', error); + console.error('Serial port error:', error); + }); + + this.serialPort.on('close', () => { + this.isConnected = false; + this.emit('disconnected'); + console.log('Serial port closed'); + }); + + this.parser.on('data', (data: string) => { + this.handleSerialData(data.trim()); + }); + + // Open the port + await new Promise((resolve, reject) => { + this.serialPort!.open((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + + } catch (error) { + throw new Error(`Failed to connect to ${this.portPath}: ${error}`); + } + } + + async disconnect(): Promise { + if (this.serialPort && this.serialPort.isOpen) { + await new Promise((resolve) => { + this.serialPort!.close(() => { + resolve(); + }); + }); + } + this.serialPort = null; + this.parser = null; + this.isConnected = false; + } + + private handleSerialData(data: string): void { + const parts = data.split(':'); + + if (parts[0] === 'INIT') { + if (parts[1] === 'START') { + this.emit('initialized'); + console.log('Arduino initialization started'); + } else if (parts.length >= 3) { + // INIT:PIN:STATE format + const pin = parseInt(parts[1]); + const state = parseInt(parts[2]); + + if (!isNaN(pin) && !isNaN(state)) { + const pinState: PinState = { + pin, + state, + name: `PIN${pin}`, + timestamp: new Date() + }; + + this.pinStates.set(pin, pinState); + this.emit('pinInitialized', pinState); + } + } + } else if (parts[0] === 'CHANGE' && parts.length >= 4) { + // CHANGE:PIN:PREVIOUS_STATE:CURRENT_STATE format + const pin = parseInt(parts[1]); + const previousState = parseInt(parts[2]); + const currentState = parseInt(parts[3]); + + if (!isNaN(pin) && !isNaN(previousState) && !isNaN(currentState)) { + const pinChange: PinChange = { + pin, + previousState, + currentState, + timestamp: new Date() + }; + + // Update stored pin state + const pinState: PinState = { + pin, + state: currentState, + name: `PIN${pin}`, + timestamp: new Date() + }; + + this.pinStates.set(pin, pinState); + + this.emit('pinChanged', pinChange); + this.emit(`pin${pin}Changed`, pinChange); + } + } + } + + getPinState(pin: number): PinState | undefined { + return this.pinStates.get(pin); + } + + getAllPinStates(): PinState[] { + return Array.from(this.pinStates.values()); + } + + isPortConnected(): boolean { + return this.isConnected && this.serialPort?.isOpen === true; + } + + // Static method to list available serial ports + static async listPorts(): Promise { + const ports = await SerialPort.list(); + return ports.map(port => port.path); + } +} + +// Example usage: +/* +const bedHardware = new BedHardware('/dev/ttyUSB0', 9600); + +bedHardware.on('connected', () => { + console.log('Connected to bed hardware'); +}); + +bedHardware.on('pinChanged', (change: PinChange) => { + console.log(`Pin ${change.pin} changed from ${change.previousState} to ${change.currentState}`); +}); + +bedHardware.on('error', (error) => { + console.error('Hardware error:', error); +}); + +await bedHardware.connect(); +*/ \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..63a244e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noImplicitAny": false, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From 5e029ff99c9ad4523ba5e5699f79ce4bcd863a10 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 12:55:27 +0700 Subject: [PATCH 02/14] dynamic graph --- .gitignore | 2 + app/api/sensors/history/route.ts | 38 ++ app/api/sensors/route.ts | 136 +++-- bun.lock | 97 +++- components/bed-pressure-monitor.tsx | 517 +----------------- components/bed-pressure/AlertsPanel.tsx | 186 +++++++ components/bed-pressure/BedPressureHeader.tsx | 29 + components/bed-pressure/BedVisualization.tsx | 79 +++ components/bed-pressure/SensorDetailModal.tsx | 230 ++++++++ components/bed-pressure/StatsCards.tsx | 68 +++ components/ui/select.tsx | 185 +++++++ hooks/useBedPressureData.ts | 69 +++ lib/utils.ts | 2 +- package.json | 25 +- services/AlarmManager.ts | 207 +++++++ services/SensorDataStorage.ts | 131 +++++ stores/bedPressureStore.ts | 275 ++++++++++ 17 files changed, 1707 insertions(+), 569 deletions(-) create mode 100644 app/api/sensors/history/route.ts create mode 100644 components/bed-pressure/AlertsPanel.tsx create mode 100644 components/bed-pressure/BedPressureHeader.tsx create mode 100644 components/bed-pressure/BedVisualization.tsx create mode 100644 components/bed-pressure/SensorDetailModal.tsx create mode 100644 components/bed-pressure/StatsCards.tsx create mode 100644 components/ui/select.tsx create mode 100644 hooks/useBedPressureData.ts create mode 100644 services/AlarmManager.ts create mode 100644 services/SensorDataStorage.ts create mode 100644 stores/bedPressureStore.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..b957a62 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/data \ No newline at end of file diff --git a/app/api/sensors/history/route.ts b/app/api/sensors/history/route.ts new file mode 100644 index 0000000..d76b20c --- /dev/null +++ b/app/api/sensors/history/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { SensorDataStorage } from '@/services/SensorDataStorage'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const sensorId = searchParams.get('sensorId'); + const timespan = parseInt(searchParams.get('timespan') || '86400000'); // Default 24 hours in ms + + if (!sensorId) { + return NextResponse.json({ + success: false, + error: 'sensorId parameter is required' + }, { status: 400 }); + } + + const sensorDataStorage = SensorDataStorage.getInstance(); + const sensorData = await sensorDataStorage.getDataForSensor(sensorId, timespan); + + const timeSeriesData = sensorDataStorage.generateTimeSeriesData(sensorData, timespan); + + return NextResponse.json({ + success: true, + sensorId, + timespan, + dataPoints: sensorData.length, + data: timeSeriesData, + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Sensor history API error:', error); + return NextResponse.json({ + success: false, + error: 'Failed to get sensor history data' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/sensors/route.ts b/app/api/sensors/route.ts index 1e5fdd3..282dfc7 100644 --- a/app/api/sensors/route.ts +++ b/app/api/sensors/route.ts @@ -1,37 +1,38 @@ import { NextRequest, NextResponse } from 'next/server'; import { BedHardware, PinState, PinChange } from '@/services/BedHardware'; +import { SensorDataStorage, SensorDataPoint } from '@/services/SensorDataStorage'; -// Complete sensor configuration with positions and pin mappings +// Complete sensor configuration with positions, pin mappings, and thresholds const SENSOR_CONFIG = [ // Head area - { id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2, baseNoise: 15 }, - { id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3, baseNoise: 12 }, + { id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2, baseNoise: 200, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 }, + { id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3, baseNoise: 150, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 }, // Shoulder area - { id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4, baseNoise: 20 }, - { id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5, baseNoise: 18 }, + { id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4, baseNoise: 250, warningThreshold: 2800, alarmThreshold: 3200, warningDelayMs: 45000 }, + { id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5, baseNoise: 220, warningThreshold: 2800, alarmThreshold: 3200, warningDelayMs: 45000 }, // Upper back - { id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6, baseNoise: 25 }, - { id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7, baseNoise: 30 }, - { id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8, baseNoise: 22 }, + { id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6, baseNoise: 300, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 }, + { id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7, baseNoise: 350, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 }, + { id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8, baseNoise: 280, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 }, // Lower back/Hip area - { id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9, baseNoise: 35 }, - { id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10, baseNoise: 40 }, - { id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11, baseNoise: 32 }, + { id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9, baseNoise: 400, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 }, + { id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10, baseNoise: 450, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 }, + { id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11, baseNoise: 380, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 }, // Thigh area - { id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12, baseNoise: 28 }, - { id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13, baseNoise: 26 }, + { id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12, baseNoise: 320, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 }, + { id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13, baseNoise: 300, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 }, // Calf area (mock data) - { id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf", baseNoise: 15 }, - { id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf", baseNoise: 18 }, + { id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf", baseNoise: 200, warningThreshold: 1800, alarmThreshold: 2200, warningDelayMs: 150000 }, + { id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf", baseNoise: 220, warningThreshold: 1800, alarmThreshold: 2200, warningDelayMs: 150000 }, // Feet (mock data) - { id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot", baseNoise: 10 }, - { id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot", baseNoise: 12 }, + { id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot", baseNoise: 150, warningThreshold: 1500, alarmThreshold: 1800, warningDelayMs: 180000 }, + { id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot", baseNoise: 160, warningThreshold: 1500, alarmThreshold: 1800, warningDelayMs: 180000 }, ]; // Create pin mapping from sensor config @@ -43,19 +44,24 @@ SENSOR_CONFIG.forEach(sensor => { }); let bedHardware: BedHardware | null = null; +const sensorDataStorage = SensorDataStorage.getInstance(); const sensorData: Record; + data: Array<{ time: string; timestamp: number; value: number }>; status: string; + warningStartTime?: number; // Track when warning state started + warningThreshold: number; + alarmThreshold: number; + warningDelayMs: number; }> = {}; let isHardwareConnected = false; @@ -69,12 +75,15 @@ function initializeSensorData() { y: sensor.y, label: sensor.label, zone: sensor.zone, - pressure: 30 + Math.random() * 20, // Start with baseline pressure + value: 1000 + Math.random() * 500, // Start with baseline analog value (1000-1500) pin: sensor.pin, timestamp: new Date().toISOString(), source: sensor.pin ? 'hardware' : 'mock', data: generateTimeSeriesData(), - status: 'normal' + status: 'normal', + warningThreshold: sensor.warningThreshold, + alarmThreshold: sensor.alarmThreshold, + warningDelayMs: sensor.warningDelayMs }; } }); @@ -90,7 +99,7 @@ function generateTimeSeriesData(hours = 1) { data.push({ time: time.toLocaleTimeString("en-US", { hour12: false }), timestamp: time.getTime(), - pressure: Math.random() * 100 + Math.sin(i / 60) * 20 + 40, + value: Math.floor(Math.random() * 4096 + Math.sin(i / 60) * 500 + 2000), // 0-4095 range }); } return data; @@ -139,47 +148,81 @@ async function initializeHardware() { } } -// Convert digital pin state to analog pressure value with noise -function digitalToPressure(pinState: number, baseNoise: number): number { - // Base pressure from digital state - const basePressure = pinState === 1 ? 60 : 20; // High when pin is HIGH, low when LOW +// Convert digital pin state to analog value with noise +function digitalToAnalogValue(pinState: number, baseNoise: number): number { + // Base value from digital state + const baseValue = pinState === 1 ? 3000 : 1000; // High when pin is HIGH, low when LOW // Add realistic noise and variation - const timeNoise = Math.sin(Date.now() / 10000) * 10; // Slow oscillation + const timeNoise = Math.sin(Date.now() / 10000) * 200; // Slow oscillation const randomNoise = (Math.random() - 0.5) * baseNoise; - const sensorDrift = (Math.random() - 0.5) * 5; // Small drift + const sensorDrift = (Math.random() - 0.5) * 50; // Small drift - const pressure = basePressure + timeNoise + randomNoise + sensorDrift; + const value = baseValue + timeNoise + randomNoise + sensorDrift; - // Clamp between 0 and 100 - return Math.max(0, Math.min(100, pressure)); + // Clamp between 0 and 4095 + return Math.max(0, Math.min(4095, Math.floor(value))); } // Update sensor data from pin change -function updateSensorFromPin(pin: number, state: number) { +async function updateSensorFromPin(pin: number, state: number) { const mapping = PIN_SENSOR_MAP[pin]; if (!mapping) return; - const pressure = digitalToPressure(state, mapping.baseNoise); + const value = digitalToAnalogValue(state, mapping.baseNoise); + const timestamp = Date.now(); + const time = new Date(timestamp).toLocaleTimeString("en-US", { hour12: false }); + + // Save to persistent storage + const dataPoint: SensorDataPoint = { + sensorId: mapping.id, + value, + timestamp, + time, + source: 'hardware', + pin, + digitalState: state + }; + await sensorDataStorage.addDataPoint(dataPoint); if (sensorData[mapping.id]) { // Update existing sensor data const currentData = sensorData[mapping.id]; + + // Determine status based on thresholds + let status = 'normal'; + let warningStartTime = currentData.warningStartTime; + + if (value >= mapping.alarmThreshold) { + status = 'alarm'; + warningStartTime = undefined; // Clear warning timer for immediate alarm + } else if (value >= mapping.warningThreshold) { + status = 'warning'; + if (!warningStartTime) { + warningStartTime = timestamp; // Start warning timer + } else if (timestamp - warningStartTime >= mapping.warningDelayMs) { + status = 'alarm'; // Escalate to alarm after delay + } + } else { + warningStartTime = undefined; // Clear warning timer + } + sensorData[mapping.id] = { ...currentData, - pressure, + value, digitalState: state, timestamp: new Date().toISOString(), source: 'hardware', data: [ ...currentData.data.slice(-287), // Keep last ~24 hours (288 points at 5min intervals) { - time: new Date().toLocaleTimeString("en-US", { hour12: false }), - timestamp: Date.now(), - pressure: pressure, + time, + timestamp, + value: value, } ], - status: pressure > 80 ? 'critical' : pressure > 60 ? 'warning' : 'normal' + status, + warningStartTime }; } } @@ -190,22 +233,23 @@ function updateMockSensorData() { if (!sensor.pin && sensorData[sensor.id]) { // This is a mock sensor, update with variation const currentSensor = sensorData[sensor.id]; - const variation = (Math.random() - 0.5) * 10; - const newPressure = Math.max(0, Math.min(100, currentSensor.pressure + variation)); + const variation = (Math.random() - 0.5) * 200; // Larger variation for analog values + const newValue = Math.max(0, Math.min(4095, currentSensor.value + variation)); sensorData[sensor.id] = { ...currentSensor, - pressure: newPressure, + value: newValue, timestamp: new Date().toISOString(), data: [ ...currentSensor.data.slice(-287), // Keep last ~24 hours { time: new Date().toLocaleTimeString("en-US", { hour12: false }), timestamp: Date.now(), - pressure: newPressure, + value: newValue, } ], - status: newPressure > 80 ? 'critical' : newPressure > 60 ? 'warning' : 'normal' + status: newValue >= sensor.alarmThreshold ? 'alarm' : + newValue >= sensor.warningThreshold ? 'warning' : 'normal' }; } }); @@ -229,9 +273,9 @@ export async function GET() { // If hardware is connected, get current pin states if (isHardwareConnected && bedHardware) { const pinStates = bedHardware.getAllPinStates(); - pinStates.forEach(pinState => { - updateSensorFromPin(pinState.pin, pinState.state); - }); + for (const pinState of pinStates) { + await updateSensorFromPin(pinState.pin, pinState.state); + } } // Return all sensor data diff --git a/bun.lock b/bun.lock index 2d15527..7d4a858 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "m2-inno-bedpressure", "dependencies": { + "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@types/serialport": "^10.2.0", "class-variance-authority": "^0.7.1", @@ -11,23 +12,25 @@ "lucide-react": "^0.519.0", "next": "15.3.4", "port": "^0.8.1", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "recharts": "^2.15.4", "serial": "^0.0.9", + "serialport": "^13.0.0", "tailwind-merge": "^3.3.1", + "zustand": "^5.0.5", }, "devDependencies": { - "@eslint/eslintrc": "^3", - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", + "@eslint/eslintrc": "^3.3.1", + "@tailwindcss/postcss": "^4.1.10", + "@types/node": "^20.19.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "eslint": "^9.29.0", "eslint-config-next": "15.3.4", - "tailwindcss": "^4", + "tailwindcss": "^4.1.10", "tw-animate-css": "^1.3.4", - "typescript": "^5", + "typescript": "^5.8.3", }, }, }, @@ -62,6 +65,14 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.2", "", { "dependencies": { "@eslint/core": "^0.15.0", "levn": "^0.4.1" } }, "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.1", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.1", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/utils": "^0.2.9" } }, "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.3", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], @@ -154,10 +165,58 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="], @@ -326,6 +385,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], @@ -438,6 +499,8 @@ "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], @@ -534,6 +597,8 @@ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], @@ -780,8 +845,14 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], @@ -910,6 +981,10 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -928,6 +1003,8 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zustand": ["zustand@5.0.5", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw=="], diff --git a/components/bed-pressure-monitor.tsx b/components/bed-pressure-monitor.tsx index 5b249f8..355cb25 100644 --- a/components/bed-pressure-monitor.tsx +++ b/components/bed-pressure-monitor.tsx @@ -1,524 +1,39 @@ "use client" -import { useState, useEffect } from "react" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" -import { Activity, AlertTriangle, Download, Pause, Play, Settings, User } from "lucide-react" - -// Mock data generator -const generateTimeSeriesData = (hours = 24) => { - const data = [] - const now = new Date() - - for (let i = hours * 60; i >= 0; i -= 5) { - const time = new Date(now.getTime() - i * 60 * 1000) - data.push({ - time: time.toLocaleTimeString("en-US", { hour12: false }), - timestamp: time.getTime(), - pressure: Math.random() * 100 + Math.sin(i / 60) * 20 + 40, - }) - } - return data -} - -// Sensor configuration interface -interface SensorConfig { - id: string; - x: number; - y: number; - zone: string; - label: string; - pin?: number; -} - -const getPressureColor = (pressure: number) => { - if (pressure < 30) return "#22c55e" // Green - Low pressure - if (pressure < 50) return "#eab308" // Yellow - Medium pressure - if (pressure < 70) return "#f97316" // Orange - High pressure - return "#ef4444" // Red - Very high pressure -} - -const getPressureLevel = (pressure: number) => { - if (pressure < 30) return "Low" - if (pressure < 50) return "Medium" - if (pressure < 70) return "High" - return "Critical" -} - -interface SensorData { - id: string; - x: number; - y: number; - zone: string; - label: string; - currentPressure: number; - data: Array<{ time: string; timestamp: number; pressure: number }>; - status: string; - source?: 'hardware' | 'mock'; - pin?: number; - digitalState?: number; -} +import { BedPressureHeader } from "./bed-pressure/BedPressureHeader" +import { StatsCards } from "./bed-pressure/StatsCards" +import { BedVisualization } from "./bed-pressure/BedVisualization" +import { AlertsPanel } from "./bed-pressure/AlertsPanel" +import { SensorDetailModal } from "./bed-pressure/SensorDetailModal" +import { useBedPressureData } from "@/hooks/useBedPressureData" export default function Component() { - const [sensorData, setSensorData] = useState>({}) - const [sensorConfig, setSensorConfig] = useState([]) - const [selectedSensor, setSelectedSensor] = useState(null) - const [isModalOpen, setIsModalOpen] = useState(false) - const [isMonitoring, setIsMonitoring] = useState(true) - const [alerts, setAlerts] = useState>([]) - - // Initialize sensor configuration - useEffect(() => { - const fetchSensorConfig = async () => { - try { - const response = await fetch('/api/sensors/config') - const data = await response.json() - - if (data.success && data.sensors) { - setSensorConfig(data.sensors) - } - } catch (error) { - console.error('Failed to fetch sensor config:', error) - } - } - - fetchSensorConfig() - }, []) - - // Initialize sensor data - useEffect(() => { - if (sensorConfig.length === 0) return - - const initialData: Record = {} - sensorConfig.forEach((sensor) => { - initialData[sensor.id] = { - ...sensor, - currentPressure: Math.random() * 100, - data: generateTimeSeriesData(), - status: "normal", - } - }) - setSensorData(initialData) - }, [sensorConfig]) - - // Fetch sensor data from API - useEffect(() => { - if (!isMonitoring) return - - const fetchSensorData = async () => { - try { - const response = await fetch('/api/sensors') - const data = await response.json() - - if (data.success && data.sensors) { - setSensorData((prev) => { - const updated = { ...prev } - const newAlerts: Array<{ id: string; message: string; time: string }> = [] - - data.sensors.forEach((sensor: { - id: string; - label: string; - zone: string; - pressure: number; - source: 'hardware' | 'mock'; - pin?: number; - digitalState?: number; - }) => { - const currentSensor = updated[sensor.id] - const newPressure = sensor.pressure - - // Check for alerts - if (newPressure > 80 && currentSensor && currentSensor.currentPressure <= 80) { - newAlerts.push({ - id: `${sensor.id}-${Date.now()}`, - message: `High pressure detected at ${sensor.label}`, - time: new Date().toLocaleTimeString(), - }) - } - - // Update sensor data - updated[sensor.id] = { - ...sensorConfig.find(s => s.id === sensor.id), - id: sensor.id, - x: sensorConfig.find(s => s.id === sensor.id)?.x || 50, - y: sensorConfig.find(s => s.id === sensor.id)?.y || 50, - zone: sensor.zone, - label: sensor.label, - currentPressure: newPressure, - data: currentSensor ? [ - ...currentSensor.data.slice(1), - { - time: new Date().toLocaleTimeString("en-US", { hour12: false }), - timestamp: Date.now(), - pressure: newPressure, - }, - ] : [{ - time: new Date().toLocaleTimeString("en-US", { hour12: false }), - timestamp: Date.now(), - pressure: newPressure, - }], - status: newPressure > 80 ? "critical" : newPressure > 60 ? "warning" : "normal", - source: sensor.source, - pin: sensor.pin, - digitalState: sensor.digitalState - } - }) - - if (newAlerts.length > 0) { - setAlerts((prev) => [...newAlerts, ...prev].slice(0, 10)) - } - - return updated - }) - } - } catch (error) { - console.error('Failed to fetch sensor data:', error) - } - } - - // Initial fetch - fetchSensorData() - - // Set up polling - const interval = setInterval(fetchSensorData, 2000) - - return () => clearInterval(interval) - }, [isMonitoring, sensorConfig]) - - const averagePressure = - Object.values(sensorData).reduce((sum: number, sensor: SensorData) => sum + (sensor.currentPressure || 0), 0) / - Object.keys(sensorData).length - - const criticalSensors = Object.values(sensorData).filter((sensor: SensorData) => sensor.currentPressure > 80).length + // Initialize data fetching + useBedPressureData() return (
{/* Header */} -
-
-
- -

Bed Pressure Monitor

-
- - {isMonitoring ? "Live" : "Paused"} - -
- -
- - - -
-
+ {/* Stats Cards */} -
- - -
- -
-

Patient

-

John Doe - Room 204

-
-
-
-
- - - -
-

Average Pressure

-

- {averagePressure.toFixed(1)} mmHg -

-
-
-
- - - -
-

Active Sensors

-

{Object.keys(sensorData).length}

-
-
-
- - - -
- 0 ? "text-red-600" : "text-gray-400"}`} /> -
-

Critical Alerts

-

0 ? "text-red-600" : "text-gray-600"}`}> - {criticalSensors} -

-
-
-
-
-
+
{/* Bed Visualization */}
- - - Pressure Distribution Map -

Click on any sensor point to view detailed pressure graphs

-
- -
- {/* Bed outline */} - - {/* Bed frame */} - - - {/* Pillow area */} - - - {/* Pressure sensors */} - {sensorConfig.map((sensor) => { - const sensorInfo = sensorData[sensor.id] - if (!sensorInfo) return null - - return ( - { - setSelectedSensor(sensor.id) - setIsModalOpen(true) - }} - /> - ) - })} - - - {/* Pressure Legend */} -
-
-
- Low ({"<"}30) -
-
-
- Medium (30-50) -
-
-
- High (50-70) -
-
-
- Critical ({">"}70) -
-
-
-
-
+
- {/* Sensor Details & Alerts */} + {/* Alerts Panel */}
- {/* Pressure Graph Modal */} - {isModalOpen && selectedSensor && sensorData[selectedSensor] && ( -
-
-
-
-
-

{sensorData[selectedSensor].label}

-

Pressure Monitoring Details

-
- -
- -
- - -

Current Pressure

-

- {sensorData[selectedSensor].currentPressure.toFixed(1)} -

-

mmHg

-
-
- - - -

Status Level

- - {getPressureLevel(sensorData[selectedSensor].currentPressure)} - -
-
- - - -

Body Zone

-

{sensorData[selectedSensor].zone}

-
-
-
- - {/* Large Pressure Chart */} - - - 24-Hour Pressure Trend - - -
- - - - - - [`${value.toFixed(1)} mmHg`, "Pressure"]} - labelFormatter={(label) => `Time: ${label}`} - /> - - - -
-
-
- - {/* Additional Statistics */} -
- - -

Max Pressure

-

- {Math.max(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)} -

-
-
- - - -

Min Pressure

-

- {Math.min(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)} -

-
-
- - - -

Average

-

- {( - sensorData[selectedSensor].data.reduce((sum: number, d: { time: string; timestamp: number; pressure: number }) => sum + d.pressure, 0) / - sensorData[selectedSensor].data.length - ).toFixed(1)} -

-
-
- - - -

Data Points

-

{sensorData[selectedSensor].data.length}

-
-
-
- -
- - -
-
-
-
- )} - - {/* Alerts */} - - - - - Recent Alerts - - - -
- {alerts.length === 0 ? ( -

No recent alerts

- ) : ( - alerts.map((alert) => ( -
- -
-

{alert.message}

-

{alert.time}

-
-
- )) - )} -
-
-
+
+ + {/* Sensor Detail Modal */} +
) diff --git a/components/bed-pressure/AlertsPanel.tsx b/components/bed-pressure/AlertsPanel.tsx new file mode 100644 index 0000000..6f785b4 --- /dev/null +++ b/components/bed-pressure/AlertsPanel.tsx @@ -0,0 +1,186 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { AlertTriangle, VolumeX, Clock, CheckCircle } from "lucide-react" +import { useBedPressureStore } from "@/stores/bedPressureStore" +import { useEffect } from "react" + +export function AlertsPanel() { + const { + alerts, + activeAlarms, + alarmManager, + acknowledgeAlarm, + silenceAlarm, + silenceAllAlarms + } = useBedPressureStore() + + // Update active alarms periodically + useEffect(() => { + const interval = setInterval(() => { + // The store will handle updating alarms automatically + }, 1000) + + return () => clearInterval(interval) + }, [alarmManager]) + + const handleAcknowledge = (alarmId: string) => { + acknowledgeAlarm(alarmId) + } + + const handleSilence = (alarmId: string) => { + silenceAlarm(alarmId, 300000) // Silence for 5 minutes + } + + const handleSilenceAll = () => { + silenceAllAlarms(300000) // Silence all for 5 minutes + } + + const getAlarmIcon = (type: 'warning' | 'alarm') => { + return type === 'alarm' ? ( + + ) : ( + + ) + } + + const getAlarmBgColor = (type: 'warning' | 'alarm', silenced: boolean) => { + if (silenced) return "bg-gray-100 border-gray-300" + return type === 'alarm' ? "bg-red-50 border-red-200" : "bg-yellow-50 border-yellow-200" + } + + const hasActiveAlarms = activeAlarms.some(alarm => !alarm.silenced) + + return ( + + +
+ + + Active Alarms ({activeAlarms.length}) + + {activeAlarms.length > 0 && ( + + )} +
+
+ +
+ {activeAlarms.length === 0 ? ( +
+ +

No active alarms

+

System monitoring normally

+
+ ) : ( + activeAlarms.map((alarm) => ( +
+
+
+ {getAlarmIcon(alarm.type)} +
+
+

+ {alarm.sensorLabel} +

+ + {alarm.type.toUpperCase()} + + {alarm.silenced && ( + + + SILENCED + + )} +
+

+ Value: {alarm.value.toFixed(0)} (Threshold: {alarm.threshold}) +

+
+ + {alarm.time} + {alarm.acknowledged && ( + + + ACK + + )} + {alarm.silenced && alarm.silencedUntil && ( + + Until {new Date(alarm.silencedUntil).toLocaleTimeString()} + + )} +
+
+
+ +
+ {!alarm.acknowledged && ( + + )} + {!alarm.silenced && ( + + )} +
+
+
+ )) + )} +
+ + {/* Legacy Alerts Section */} + {alerts.length > 0 && ( +
+

Recent Alerts

+
+ {alerts.slice(0, 3).map((alert) => ( +
+ +
+

{alert.message}

+

{alert.time}

+
+
+ ))} +
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/components/bed-pressure/BedPressureHeader.tsx b/components/bed-pressure/BedPressureHeader.tsx new file mode 100644 index 0000000..863e920 --- /dev/null +++ b/components/bed-pressure/BedPressureHeader.tsx @@ -0,0 +1,29 @@ +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Activity, Pause, Play } from "lucide-react" +import { useBedPressureStore } from "@/stores/bedPressureStore" + +export function BedPressureHeader() { + const { isMonitoring, setIsMonitoring } = useBedPressureStore() + + return ( +
+
+
+ +

Bed Pressure Monitor

+
+ + {isMonitoring ? "Live" : "Paused"} + +
+ +
+ +
+
+ ) +} \ No newline at end of file diff --git a/components/bed-pressure/BedVisualization.tsx b/components/bed-pressure/BedVisualization.tsx new file mode 100644 index 0000000..db97fe3 --- /dev/null +++ b/components/bed-pressure/BedVisualization.tsx @@ -0,0 +1,79 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useBedPressureStore } from "@/stores/bedPressureStore" + +const getValueColor = (value: number) => { + if (value < 1500) return "#22c55e" // Green - Low value + if (value < 2500) return "#eab308" // Yellow - Medium value + if (value < 3500) return "#f97316" // Orange - High value + return "#ef4444" // Red - Very high value +} + +export function BedVisualization() { + const { sensorConfig, sensorData, setSelectedSensor, setIsModalOpen } = useBedPressureStore() + + const handleSensorClick = (sensorId: string) => { + setSelectedSensor(sensorId) + setIsModalOpen(true) + } + + return ( + + + Sensor Value Distribution Map +

Click on any sensor point to view detailed value graphs

+
+ +
+ {/* Bed outline */} + + {/* Bed frame */} + + + {/* Pillow area */} + + + {/* Pressure sensors */} + {sensorConfig.map((sensor) => { + const sensorInfo = sensorData[sensor.id] + if (!sensorInfo) return null + + return ( + handleSensorClick(sensor.id)} + /> + ) + })} + + + {/* Value Legend */} +
+
+
+ Low ({"<"}1500) +
+
+
+ Medium (1500-2500) +
+
+
+ High (2500-3500) +
+
+
+ Critical ({">"}3500) +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/bed-pressure/SensorDetailModal.tsx b/components/bed-pressure/SensorDetailModal.tsx new file mode 100644 index 0000000..04752b3 --- /dev/null +++ b/components/bed-pressure/SensorDetailModal.tsx @@ -0,0 +1,230 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" +import { Download, Clock } from "lucide-react" +import { useBedPressureStore } from "@/stores/bedPressureStore" +import { useEffect } from "react" + +const getValueColor = (value: number) => { + if (value < 1500) return "#22c55e" // Green - Low value + if (value < 2500) return "#eab308" // Yellow - Medium value + if (value < 3500) return "#f97316" // Orange - High value + return "#ef4444" // Red - Very high value +} + +const getValueLevel = (value: number) => { + if (value < 1500) return "Low" + if (value < 2500) return "Medium" + if (value < 3500) return "High" + return "Critical" +} + +export function SensorDetailModal() { + const { + isModalOpen, + setIsModalOpen, + selectedSensor, + sensorData, + selectedTimespan, + setSelectedTimespan, + fetchSensorHistory + } = useBedPressureStore() + + const sensor = selectedSensor && sensorData[selectedSensor] ? sensorData[selectedSensor] : null + + // Fetch historical data when modal opens or timespan changes + useEffect(() => { + if (isModalOpen && selectedSensor && sensor?.source === 'hardware') { + fetchSensorHistory(selectedSensor, selectedTimespan) + } + }, [isModalOpen, selectedSensor, selectedTimespan, fetchSensorHistory, sensor?.source]) + + const handleTimespanChange = (value: string) => { + const newTimespan = parseInt(value) + setSelectedTimespan(newTimespan) + } + + const getTimespanLabel = (timespan: number) => { + if (timespan < 60000) return `${timespan / 1000}s` + if (timespan < 3600000) return `${timespan / 60000}m` + if (timespan < 86400000) return `${timespan / 3600000}h` + return `${timespan / 86400000}d` + } + + if (!isModalOpen || !selectedSensor || !sensor) { + return null + } + + return ( +
+
+
+
+
+

{sensor.label}

+

Pressure Monitoring Details

+
+ +
+ +
+ + +

Current Value

+

+ {sensor.currentValue.toFixed(0)} +

+

ADC Units

+
+
+ + + +

Status Level

+ + {getValueLevel(sensor.currentValue)} + +
+
+ + + +

Body Zone

+

{sensor.zone}

+
+
+
+ + {/* Large Value Chart */} + + +
+ Value Trend +
+ + +
+
+

+ Showing data for the last {getTimespanLabel(selectedTimespan)} + {sensor.source === 'hardware' ? ' (Real sensor data)' : ' (Mock data)'} +

+
+ +
+ + + + + + [`${value.toFixed(0)}`, "Value"]} + labelFormatter={(label) => `Time: ${label}`} + /> + + + +
+
+
+ + {/* Additional Statistics */} +
+ + +

Max Value

+

+ {Math.max(...sensor.data.map(d => d.value)).toFixed(0)} +

+
+
+ + + +

Min Value

+

+ {Math.min(...sensor.data.map(d => d.value)).toFixed(0)} +

+
+
+ + + +

Average

+

+ {(sensor.data.reduce((sum, d) => sum + d.value, 0) / sensor.data.length).toFixed(0)} +

+
+
+ + + +

Data Points

+

{sensor.data.length}

+
+
+
+ +
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/bed-pressure/StatsCards.tsx b/components/bed-pressure/StatsCards.tsx new file mode 100644 index 0000000..183f372 --- /dev/null +++ b/components/bed-pressure/StatsCards.tsx @@ -0,0 +1,68 @@ +import { Card, CardContent } from "@/components/ui/card" +import { AlertTriangle, User } from "lucide-react" +import { useBedPressureStore } from "@/stores/bedPressureStore" + +const getValueColor = (value: number) => { + if (value < 1500) return "#22c55e" // Green - Low value + if (value < 2500) return "#eab308" // Yellow - Medium value + if (value < 3500) return "#f97316" // Orange - High value + return "#ef4444" // Red - Very high value +} + +export function StatsCards() { + const { sensorData, averageValue, criticalSensors } = useBedPressureStore() + + const avgValue = averageValue() + const criticalCount = criticalSensors() + const activeSensors = Object.keys(sensorData).length + + return ( +
+ + +
+ +
+

Patient

+

John Doe - Room 204

+
+
+
+
+ + + +
+

Average Value

+

+ {avgValue.toFixed(0)} +

+
+
+
+ + + +
+

Active Sensors

+

{activeSensors}

+
+
+
+ + + +
+ 0 ? "text-red-600" : "text-gray-400"}`} /> +
+

Critical Alerts

+

0 ? "text-red-600" : "text-gray-600"}`}> + {criticalCount} +

+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..dcbbc0c --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,185 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/hooks/useBedPressureData.ts b/hooks/useBedPressureData.ts new file mode 100644 index 0000000..a00822f --- /dev/null +++ b/hooks/useBedPressureData.ts @@ -0,0 +1,69 @@ +import { useEffect } from 'react' +import { useBedPressureStore, SensorData } from '@/stores/bedPressureStore' + +// Mock data generator +const generateTimeSeriesData = (hours = 24) => { + const data = [] + const now = new Date() + + for (let i = hours * 60; i >= 0; i -= 5) { + const time = new Date(now.getTime() - i * 60 * 1000) + data.push({ + time: time.toLocaleTimeString("en-US", { hour12: false }), + timestamp: time.getTime(), + value: Math.floor(Math.random() * 4096 + Math.sin(i / 60) * 500 + 2000), // 0-4095 range + }) + } + return data +} + +export function useBedPressureData() { + const { + sensorConfig, + sensorData, + isMonitoring, + fetchSensorConfig, + fetchSensorData, + setSensorData + } = useBedPressureStore() + + // Initialize sensor configuration + useEffect(() => { + fetchSensorConfig() + }, [fetchSensorConfig]) + + // Initialize sensor data + useEffect(() => { + if (sensorConfig.length === 0) return + + const initialData: Record = {} + sensorConfig.forEach((sensor) => { + initialData[sensor.id] = { + ...sensor, + currentValue: Math.floor(Math.random() * 1000 + 1000), // Start with baseline analog value (1000-2000) + data: generateTimeSeriesData(), + status: "normal", + } + }) + setSensorData(initialData) + }, [sensorConfig, setSensorData]) + + // Fetch sensor data from API + useEffect(() => { + if (!isMonitoring) return + + // Initial fetch + fetchSensorData() + + // Set up polling + const interval = setInterval(fetchSensorData, 2000) + + return () => clearInterval(interval) + }, [isMonitoring, fetchSensorData]) + + return { + sensorData, + sensorConfig, + isMonitoring + } +} \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..d084cca 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,4 +1,4 @@ -import { clsx, type ClassValue } from "clsx" +import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { diff --git a/package.json b/package.json index 934ac85..78a2b12 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@types/serialport": "^10.2.0", "class-variance-authority": "^0.7.1", @@ -16,22 +17,24 @@ "lucide-react": "^0.519.0", "next": "15.3.4", "port": "^0.8.1", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "recharts": "^2.15.4", "serial": "^0.0.9", - "tailwind-merge": "^3.3.1" + "serialport": "^13.0.0", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.5" }, "devDependencies": { - "@eslint/eslintrc": "^3", - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", + "@eslint/eslintrc": "^3.3.1", + "@tailwindcss/postcss": "^4.1.10", + "@types/node": "^20.19.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "eslint": "^9.29.0", "eslint-config-next": "15.3.4", - "tailwindcss": "^4", + "tailwindcss": "^4.1.10", "tw-animate-css": "^1.3.4", - "typescript": "^5" + "typescript": "^5.8.3" } } diff --git a/services/AlarmManager.ts b/services/AlarmManager.ts new file mode 100644 index 0000000..1d16615 --- /dev/null +++ b/services/AlarmManager.ts @@ -0,0 +1,207 @@ +export interface AlarmEvent { + id: string; + sensorId: string; + sensorLabel: string; + type: 'warning' | 'alarm'; + value: number; + threshold: number; + timestamp: number; + time: string; + acknowledged: boolean; + silenced: boolean; + silencedUntil?: number; +} + +export class AlarmManager { + private static instance: AlarmManager; + private activeAlarms: Map = new Map(); + private alarmHistory: AlarmEvent[] = []; + private alarmCallbacks: ((alarm: AlarmEvent) => void)[] = []; + private audioContext: AudioContext | null = null; + private alarmSound: AudioBuffer | null = null; + private isPlayingAlarm = false; + + private constructor() { + this.initializeAudio(); + } + + static getInstance(): AlarmManager { + if (!AlarmManager.instance) { + AlarmManager.instance = new AlarmManager(); + } + return AlarmManager.instance; + } + + private async initializeAudio() { + try { + // Check if we're in browser environment + if (typeof window !== 'undefined') { + this.audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); + // Generate alarm sound programmatically + await this.generateAlarmSound(); + } + } catch (error) { + console.warn('Audio context not available:', error); + } + } + + private async generateAlarmSound() { + if (!this.audioContext) return; + + const sampleRate = this.audioContext.sampleRate; + const duration = 1; // 1 second + const buffer = this.audioContext.createBuffer(1, sampleRate * duration, sampleRate); + const data = buffer.getChannelData(0); + + // Generate a beeping sound (sine wave with modulation) + for (let i = 0; i < data.length; i++) { + const t = i / sampleRate; + const frequency = 800; // Hz + const envelope = Math.sin(t * Math.PI * 4) > 0 ? 1 : 0; // Beeping pattern + data[i] = Math.sin(2 * Math.PI * frequency * t) * envelope * 0.3; + } + + this.alarmSound = buffer; + } + + private async playAlarmSound() { + if (!this.audioContext || !this.alarmSound || this.isPlayingAlarm) return; + + try { + // Resume audio context if suspended + if (this.audioContext.state === 'suspended') { + await this.audioContext.resume(); + } + + const source = this.audioContext.createBufferSource(); + source.buffer = this.alarmSound; + source.connect(this.audioContext.destination); + source.start(); + + this.isPlayingAlarm = true; + source.onended = () => { + this.isPlayingAlarm = false; + }; + } catch (error) { + console.warn('Failed to play alarm sound:', error); + } + } + + addAlarm(sensorId: string, sensorLabel: string, type: 'warning' | 'alarm', value: number, threshold: number) { + const alarmId = `${sensorId}-${type}`; + const timestamp = Date.now(); + + const alarm: AlarmEvent = { + id: alarmId, + sensorId, + sensorLabel, + type, + value, + threshold, + timestamp, + time: new Date(timestamp).toLocaleTimeString(), + acknowledged: false, + silenced: false + }; + + // Check if alarm is already active and silenced + const existingAlarm = this.activeAlarms.get(alarmId); + if (existingAlarm?.silenced && existingAlarm.silencedUntil && timestamp < existingAlarm.silencedUntil) { + // Update values but keep silenced state + alarm.silenced = true; + alarm.silencedUntil = existingAlarm.silencedUntil; + } + + this.activeAlarms.set(alarmId, alarm); + this.alarmHistory.unshift(alarm); + + // Keep only last 100 history items + if (this.alarmHistory.length > 100) { + this.alarmHistory = this.alarmHistory.slice(0, 100); + } + + // Play sound for new alarms + if (type === 'alarm' && !alarm.silenced) { + this.playAlarmSound(); + } + + // Notify callbacks + this.alarmCallbacks.forEach(callback => callback(alarm)); + + return alarm; + } + + clearAlarm(sensorId: string, type?: 'warning' | 'alarm') { + if (type) { + const alarmId = `${sensorId}-${type}`; + this.activeAlarms.delete(alarmId); + } else { + // Clear all alarms for this sensor + this.activeAlarms.delete(`${sensorId}-warning`); + this.activeAlarms.delete(`${sensorId}-alarm`); + } + } + + acknowledgeAlarm(alarmId: string) { + const alarm = this.activeAlarms.get(alarmId); + if (alarm) { + alarm.acknowledged = true; + this.activeAlarms.set(alarmId, alarm); + } + } + + silenceAlarm(alarmId: string, durationMs: number = 300000) { // Default 5 minutes + const alarm = this.activeAlarms.get(alarmId); + if (alarm) { + alarm.silenced = true; + alarm.silencedUntil = Date.now() + durationMs; + this.activeAlarms.set(alarmId, alarm); + } + } + + silenceAllAlarms(durationMs: number = 300000) { + const timestamp = Date.now(); + this.activeAlarms.forEach((alarm, alarmId) => { + alarm.silenced = true; + alarm.silencedUntil = timestamp + durationMs; + this.activeAlarms.set(alarmId, alarm); + }); + } + + getActiveAlarms(): AlarmEvent[] { + const now = Date.now(); + const activeAlarms: AlarmEvent[] = []; + + this.activeAlarms.forEach((alarm, alarmId) => { + // Check if silence period has expired + if (alarm.silenced && alarm.silencedUntil && now >= alarm.silencedUntil) { + alarm.silenced = false; + alarm.silencedUntil = undefined; + this.activeAlarms.set(alarmId, alarm); + } + + activeAlarms.push(alarm); + }); + + return activeAlarms.sort((a, b) => b.timestamp - a.timestamp); + } + + getAlarmHistory(): AlarmEvent[] { + return this.alarmHistory; + } + + hasUnacknowledgedAlarms(): boolean { + return Array.from(this.activeAlarms.values()).some(alarm => !alarm.acknowledged); + } + + onAlarm(callback: (alarm: AlarmEvent) => void) { + this.alarmCallbacks.push(callback); + } + + offAlarm(callback: (alarm: AlarmEvent) => void) { + const index = this.alarmCallbacks.indexOf(callback); + if (index > -1) { + this.alarmCallbacks.splice(index, 1); + } + } +} \ No newline at end of file diff --git a/services/SensorDataStorage.ts b/services/SensorDataStorage.ts new file mode 100644 index 0000000..d5a5013 --- /dev/null +++ b/services/SensorDataStorage.ts @@ -0,0 +1,131 @@ +import fs from 'fs/promises'; +import path from 'path'; + +const DATA_DIR = path.join(process.cwd(), 'data'); +const SENSOR_DATA_FILE = path.join(DATA_DIR, 'sensor-data.json'); + +export interface SensorDataPoint { + sensorId: string; + value: number; // Changed from pressure to value (0-4095) + timestamp: number; + time: string; + source: 'hardware' | 'mock'; + pin?: number; + digitalState?: number; +} + +export class SensorDataStorage { + private static instance: SensorDataStorage; + private dataCache: SensorDataPoint[] = []; + + private constructor() { + this.initializeStorage(); + } + + static getInstance(): SensorDataStorage { + if (!SensorDataStorage.instance) { + SensorDataStorage.instance = new SensorDataStorage(); + } + return SensorDataStorage.instance; + } + + private async initializeStorage() { + try { + // Ensure data directory exists + await fs.mkdir(DATA_DIR, { recursive: true }); + + // Load existing data + await this.loadData(); + } catch (error) { + console.warn('Failed to initialize sensor data storage:', error); + } + } + + private async loadData() { + try { + const data = await fs.readFile(SENSOR_DATA_FILE, 'utf8'); + this.dataCache = JSON.parse(data); + console.log(`Loaded ${this.dataCache.length} sensor data points from storage`); + } catch { + // File doesn't exist or is corrupted, start with empty cache + this.dataCache = []; + console.log('Starting with empty sensor data cache'); + } + } + + private async saveData() { + try { + await fs.writeFile(SENSOR_DATA_FILE, JSON.stringify(this.dataCache, null, 2)); + } catch (error) { + console.error('Failed to save sensor data:', error); + } + } + + async addDataPoint(dataPoint: SensorDataPoint) { + this.dataCache.push(dataPoint); + + // Keep only last 7 days of data to prevent unlimited storage growth + const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); + this.dataCache = this.dataCache.filter(point => point.timestamp > sevenDaysAgo); + + // Save to disk every 10 data points to reduce I/O + if (this.dataCache.length % 10 === 0) { + await this.saveData(); + } + } + + async getDataForSensor( + sensorId: string, + timespan: number = 24 * 60 * 60 * 1000 // Default 24 hours in milliseconds + ): Promise { + const cutoffTime = Date.now() - timespan; + return this.dataCache + .filter(point => point.sensorId === sensorId && point.timestamp > cutoffTime) + .sort((a, b) => a.timestamp - b.timestamp); + } + + async getAllRecentData(timespan: number = 24 * 60 * 60 * 1000): Promise { + const cutoffTime = Date.now() - timespan; + return this.dataCache + .filter(point => point.timestamp > cutoffTime) + .sort((a, b) => a.timestamp - b.timestamp); + } + + async forceSave() { + await this.saveData(); + } + + // Generate time series data for a specific timespan + generateTimeSeriesData( + sensorData: SensorDataPoint[], + timespan: number = 24 * 60 * 60 * 1000 + ): Array<{ time: string; timestamp: number; value: number }> { + if (sensorData.length === 0) { + // Generate mock data if no real data exists + return this.generateMockTimeSeriesData(timespan); + } + + return sensorData.map(point => ({ + time: point.time, + timestamp: point.timestamp, + value: point.value + })); + } + + private generateMockTimeSeriesData(timespan: number): Array<{ time: string; timestamp: number; value: number }> { + const data = []; + const now = Date.now(); + const interval = Math.max(1000, timespan / 288); // At least 1 second intervals, up to 288 points + + for (let i = timespan; i >= 0; i -= interval) { + const timestamp = now - i; + const time = new Date(timestamp); + data.push({ + time: time.toLocaleTimeString("en-US", { hour12: false }), + timestamp: timestamp, + value: Math.floor(Math.random() * 4096 + Math.sin(i / 60000) * 500 + 2000), // 0-4095 range + }); + } + return data; + } +} \ No newline at end of file diff --git a/stores/bedPressureStore.ts b/stores/bedPressureStore.ts new file mode 100644 index 0000000..0d4aa1d --- /dev/null +++ b/stores/bedPressureStore.ts @@ -0,0 +1,275 @@ +import { create } from 'zustand' +import { AlarmManager, AlarmEvent } from '@/services/AlarmManager' + +export interface SensorConfig { + id: string; + x: number; + y: number; + zone: string; + label: string; + pin?: number; +} + +export interface SensorData { + id: string; + x: number; + y: number; + zone: string; + label: string; + currentValue: number; // Changed from currentPressure to currentValue (0-4095) + data: Array<{ time: string; timestamp: number; value: number }>; // Changed from pressure to value + status: string; + source?: 'hardware' | 'mock'; + pin?: number; + digitalState?: number; + warningThreshold?: number; + alarmThreshold?: number; + warningDelayMs?: number; +} + +export interface Alert { + id: string; + message: string; + time: string; +} + +interface BedPressureStore { + // State + sensorData: Record; + sensorConfig: SensorConfig[]; + selectedSensor: string | null; + isModalOpen: boolean; + isMonitoring: boolean; + alerts: Alert[]; + isConnected: boolean; + selectedTimespan: number; // in milliseconds + activeAlarms: AlarmEvent[]; + alarmManager: AlarmManager; + + // Actions + setSensorData: (data: Record) => void; + updateSensorData: (updater: (prev: Record) => Record) => void; + setSensorConfig: (config: SensorConfig[]) => void; + setSelectedSensor: (sensorId: string | null) => void; + setIsModalOpen: (isOpen: boolean) => void; + setIsMonitoring: (isMonitoring: boolean) => void; + addAlerts: (newAlerts: Alert[]) => void; + setIsConnected: (isConnected: boolean) => void; + setSelectedTimespan: (timespan: number) => void; + setActiveAlarms: (alarms: AlarmEvent[]) => void; + + // Computed values + averageValue: () => number; // Changed from averagePressure + criticalSensors: () => number; + + // API actions + fetchSensorConfig: () => Promise; + fetchSensorData: () => Promise; + fetchSensorHistory: (sensorId: string, timespan?: number) => Promise; + + // Alarm actions + acknowledgeAlarm: (alarmId: string) => void; + silenceAlarm: (alarmId: string, durationMs?: number) => void; + silenceAllAlarms: (durationMs?: number) => void; +} + +export const useBedPressureStore = create((set, get) => ({ + // Initial state + sensorData: {}, + sensorConfig: [], + selectedSensor: null, + isModalOpen: false, + isMonitoring: true, + alerts: [], + isConnected: false, + selectedTimespan: 24 * 60 * 60 * 1000, // Default 24 hours + activeAlarms: [], + alarmManager: AlarmManager.getInstance(), + + // Actions + setSensorData: (data) => set({ sensorData: data }), + + updateSensorData: (updater) => set((state) => ({ + sensorData: updater(state.sensorData) + })), + + setSensorConfig: (config) => set({ sensorConfig: config }), + + setSelectedSensor: (sensorId) => set({ selectedSensor: sensorId }), + + setIsModalOpen: (isOpen) => set({ isModalOpen: isOpen }), + + setIsMonitoring: (isMonitoring) => set({ isMonitoring }), + + addAlerts: (newAlerts) => set((state) => ({ + alerts: [...newAlerts, ...state.alerts].slice(0, 10) + })), + + setIsConnected: (isConnected) => set({ isConnected }), + + setSelectedTimespan: (timespan) => set({ selectedTimespan: timespan }), + + setActiveAlarms: (alarms) => set({ activeAlarms: alarms }), + + // Computed values + averageValue: () => { + const { sensorData } = get(); + const sensors = Object.values(sensorData) as SensorData[]; + if (sensors.length === 0) return 0; + return sensors.reduce((sum: number, sensor: SensorData) => sum + sensor.currentValue, 0) / sensors.length; + }, + + criticalSensors: () => { + const { sensorData } = get(); + return (Object.values(sensorData) as SensorData[]).filter((sensor: SensorData) => + sensor.status === 'alarm' || sensor.status === 'critical' + ).length; + }, + + // API actions + fetchSensorConfig: async () => { + try { + const response = await fetch('/api/sensors/config'); + const data = await response.json(); + + if (data.success && data.sensors) { + set({ sensorConfig: data.sensors }); + } + } catch (error) { + console.error('Failed to fetch sensor config:', error); + } + }, + + fetchSensorData: async () => { + try { + const response = await fetch('/api/sensors'); + const data = await response.json(); + + if (data.success && data.sensors) { + const { sensorData, sensorConfig, alarmManager } = get(); + const updated = { ...sensorData }; + const newAlerts: Alert[] = []; + + data.sensors.forEach((sensor: { + id: string; + label: string; + zone: string; + value: number; // Changed from pressure to value + source: 'hardware' | 'mock'; + pin?: number; + digitalState?: number; + warningThreshold?: number; + alarmThreshold?: number; + status?: string; + }) => { + const currentSensor = updated[sensor.id]; + const newValue = sensor.value; + + // Check for alarms based on thresholds + if (sensor.alarmThreshold && newValue >= sensor.alarmThreshold) { + alarmManager.addAlarm(sensor.id, sensor.label, 'alarm', newValue, sensor.alarmThreshold); + } else if (sensor.warningThreshold && newValue >= sensor.warningThreshold) { + alarmManager.addAlarm(sensor.id, sensor.label, 'warning', newValue, sensor.warningThreshold); + } else { + alarmManager.clearAlarm(sensor.id); + } + + // Check for alerts (legacy alert system) + if (newValue > 3000 && currentSensor && currentSensor.currentValue <= 3000) { + newAlerts.push({ + id: `${sensor.id}-${Date.now()}`, + message: `High value detected at ${sensor.label}`, + time: new Date().toLocaleTimeString(), + }); + } + + // Update sensor data + updated[sensor.id] = { + ...sensorConfig.find(s => s.id === sensor.id), + id: sensor.id, + x: sensorConfig.find(s => s.id === sensor.id)?.x || 50, + y: sensorConfig.find(s => s.id === sensor.id)?.y || 50, + zone: sensor.zone, + label: sensor.label, + currentValue: newValue, + data: currentSensor ? [ + ...currentSensor.data.slice(1), + { + time: new Date().toLocaleTimeString("en-US", { hour12: false }), + timestamp: Date.now(), + value: newValue, + }, + ] : [{ + time: new Date().toLocaleTimeString("en-US", { hour12: false }), + timestamp: Date.now(), + value: newValue, + }], + status: sensor.status || (newValue > 3000 ? "critical" : newValue > 2500 ? "warning" : "normal"), + source: sensor.source, + pin: sensor.pin, + digitalState: sensor.digitalState, + warningThreshold: sensor.warningThreshold, + alarmThreshold: sensor.alarmThreshold + }; + }); + + set({ + sensorData: updated, + isConnected: data.connected, + activeAlarms: alarmManager.getActiveAlarms() + }); + + if (newAlerts.length > 0) { + get().addAlerts(newAlerts); + } + } + } catch (error) { + console.error('Failed to fetch sensor data:', error); + } + }, + + fetchSensorHistory: async (sensorId: string, timespan?: number) => { + try { + const { selectedTimespan } = get(); + const timespanToUse = timespan || selectedTimespan; + + const response = await fetch(`/api/sensors/history?sensorId=${sensorId}×pan=${timespanToUse}`); + const data = await response.json(); + + if (data.success && data.data) { + const { sensorData } = get(); + const updated = { ...sensorData }; + + if (updated[sensorId]) { + updated[sensorId] = { + ...updated[sensorId], + data: data.data + }; + + set({ sensorData: updated }); + } + } + } catch (error) { + console.error('Failed to fetch sensor history:', error); + } + }, + + // Alarm actions + acknowledgeAlarm: (alarmId: string) => { + const { alarmManager } = get(); + alarmManager.acknowledgeAlarm(alarmId); + set({ activeAlarms: alarmManager.getActiveAlarms() }); + }, + + silenceAlarm: (alarmId: string, durationMs?: number) => { + const { alarmManager } = get(); + alarmManager.silenceAlarm(alarmId, durationMs); + set({ activeAlarms: alarmManager.getActiveAlarms() }); + }, + + silenceAllAlarms: (durationMs?: number) => { + const { alarmManager } = get(); + alarmManager.silenceAllAlarms(durationMs); + set({ activeAlarms: alarmManager.getActiveAlarms() }); + } +})); \ No newline at end of file From 0c5c7bcb5f21d0990c6cb4702ba087623313b233 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 13:29:01 +0700 Subject: [PATCH 03/14] warning system --- app/api/sensors/config/route.ts | 221 +++++++++++++-- app/api/sensors/route.ts | 26 +- app/api/sensors/zones/route.ts | 60 ++++ app/api/test-scenarios/route.ts | 197 ++++++++++++++ app/page.tsx | 34 ++- components/bed-pressure/AlarmDashboard.tsx | 256 ++++++++++++++++++ components/bed-pressure/BedPressureHeader.tsx | 67 ++++- stores/bedPressureStore.ts | 91 +++++-- types/sensor.ts | 24 ++ utils/sensorConfig.ts | 152 +++++++++++ 10 files changed, 1074 insertions(+), 54 deletions(-) create mode 100644 app/api/sensors/zones/route.ts create mode 100644 app/api/test-scenarios/route.ts create mode 100644 components/bed-pressure/AlarmDashboard.tsx create mode 100644 types/sensor.ts create mode 100644 utils/sensorConfig.ts diff --git a/app/api/sensors/config/route.ts b/app/api/sensors/config/route.ts index 86350a2..7727d13 100644 --- a/app/api/sensors/config/route.ts +++ b/app/api/sensors/config/route.ts @@ -1,36 +1,209 @@ import { NextResponse } from 'next/server'; +import { SensorConfig } from '@/types/sensor'; // Sensor configuration that matches the API route -const SENSOR_CONFIG = [ - // Head area - { id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2 }, - { id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3 }, +const SENSOR_CONFIG: SensorConfig[] = [ + // Head area - Higher thresholds due to critical nature, faster escalation + { + id: "head-1", + x: 45, + y: 15, + zone: "head", + label: "Head Left", + pin: 2, + warningThreshold: 3000, + alarmThreshold: 3500, + warningDelayMs: 30000, // 30 seconds - fast escalation for head + baseNoise: 200 + }, + { + id: "head-2", + x: 55, + y: 15, + zone: "head", + label: "Head Right", + pin: 3, + warningThreshold: 3000, + alarmThreshold: 3500, + warningDelayMs: 30000, // 30 seconds + baseNoise: 150 + }, - // Shoulder area - { id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4 }, - { id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5 }, + // Shoulder area - Moderate thresholds, medium escalation time + { + id: "shoulder-1", + x: 35, + y: 25, + zone: "shoulders", + label: "Left Shoulder", + pin: 4, + warningThreshold: 2800, + alarmThreshold: 3200, + warningDelayMs: 45000, // 45 seconds + baseNoise: 250 + }, + { + id: "shoulder-2", + x: 65, + y: 25, + zone: "shoulders", + label: "Right Shoulder", + pin: 5, + warningThreshold: 2800, + alarmThreshold: 3200, + warningDelayMs: 45000, // 45 seconds + baseNoise: 220 + }, - // Upper back - { id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6 }, - { id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7 }, - { id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8 }, + // Upper back - Moderate thresholds, 1 minute escalation + { + id: "back-1", + x: 40, + y: 35, + zone: "back", + label: "Upper Back Left", + pin: 6, + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000, // 1 minute + baseNoise: 300 + }, + { + id: "back-2", + x: 50, + y: 35, + zone: "back", + label: "Upper Back Center", + pin: 7, + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000, // 1 minute + baseNoise: 350 + }, + { + id: "back-3", + x: 60, + y: 35, + zone: "back", + label: "Upper Back Right", + pin: 8, + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000, // 1 minute + baseNoise: 280 + }, - // Lower back/Hip area - { id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9 }, - { id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10 }, - { id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11 }, + // Lower back/Hip area - Lower thresholds, 90 second escalation + { + id: "hip-1", + x: 35, + y: 50, + zone: "hips", + label: "Left Hip", + pin: 9, + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000, // 90 seconds + baseNoise: 400 + }, + { + id: "hip-2", + x: 50, + y: 50, + zone: "hips", + label: "Lower Back", + pin: 10, + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000, // 90 seconds + baseNoise: 450 + }, + { + id: "hip-3", + x: 65, + y: 50, + zone: "hips", + label: "Right Hip", + pin: 11, + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000, // 90 seconds + baseNoise: 380 + }, - // Thigh area - { id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12 }, - { id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13 }, + // Thigh area - Lower thresholds, 2 minute escalation + { + id: "thigh-1", + x: 40, + y: 65, + zone: "legs", + label: "Left Thigh", + pin: 12, + warningThreshold: 2000, + alarmThreshold: 2500, + warningDelayMs: 120000, // 2 minutes + baseNoise: 320 + }, + { + id: "thigh-2", + x: 60, + y: 65, + zone: "legs", + label: "Right Thigh", + pin: 13, + warningThreshold: 2000, + alarmThreshold: 2500, + warningDelayMs: 120000, // 2 minutes + baseNoise: 300 + }, - // Calf area (mock data) - { id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf" }, - { id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf" }, + // Calf area (mock data) - Lower thresholds, 2.5 minute escalation + { + id: "calf-1", + x: 40, + y: 75, + zone: "legs", + label: "Left Calf", + warningThreshold: 1800, + alarmThreshold: 2200, + warningDelayMs: 150000, // 2.5 minutes + baseNoise: 200 + }, + { + id: "calf-2", + x: 60, + y: 75, + zone: "legs", + label: "Right Calf", + warningThreshold: 1800, + alarmThreshold: 2200, + warningDelayMs: 150000, // 2.5 minutes + baseNoise: 220 + }, - // Feet (mock data) - { id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot" }, - { id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot" }, + // Feet (mock data) - Lowest thresholds, 3 minute escalation + { + id: "feet-1", + x: 45, + y: 85, + zone: "feet", + label: "Left Foot", + warningThreshold: 1500, + alarmThreshold: 1800, + warningDelayMs: 180000, // 3 minutes + baseNoise: 150 + }, + { + id: "feet-2", + x: 55, + y: 85, + zone: "feet", + label: "Right Foot", + warningThreshold: 1500, + alarmThreshold: 1800, + warningDelayMs: 180000, // 3 minutes + baseNoise: 160 + }, ]; export async function GET() { diff --git a/app/api/sensors/route.ts b/app/api/sensors/route.ts index 282dfc7..77727c8 100644 --- a/app/api/sensors/route.ts +++ b/app/api/sensors/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { BedHardware, PinState, PinChange } from '@/services/BedHardware'; import { SensorDataStorage, SensorDataPoint } from '@/services/SensorDataStorage'; +import { SensorConfig } from '@/types/sensor'; // Complete sensor configuration with positions, pin mappings, and thresholds -const SENSOR_CONFIG = [ +const SENSOR_CONFIG: SensorConfig[] = [ // Head area { id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2, baseNoise: 200, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 }, { id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3, baseNoise: 150, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 }, @@ -235,6 +236,25 @@ function updateMockSensorData() { const currentSensor = sensorData[sensor.id]; const variation = (Math.random() - 0.5) * 200; // Larger variation for analog values const newValue = Math.max(0, Math.min(4095, currentSensor.value + variation)); + const timestamp = Date.now(); + + // Determine status based on thresholds + let status = 'normal'; + let warningStartTime = currentSensor.warningStartTime; + + if (newValue >= sensor.alarmThreshold) { + status = 'alarm'; + warningStartTime = undefined; // Clear warning timer for immediate alarm + } else if (newValue >= sensor.warningThreshold) { + status = 'warning'; + if (!warningStartTime) { + warningStartTime = timestamp; // Start warning timer + } else if (timestamp - warningStartTime >= sensor.warningDelayMs) { + status = 'alarm'; // Escalate to alarm after delay + } + } else { + warningStartTime = undefined; // Clear warning timer + } sensorData[sensor.id] = { ...currentSensor, @@ -248,8 +268,8 @@ function updateMockSensorData() { value: newValue, } ], - status: newValue >= sensor.alarmThreshold ? 'alarm' : - newValue >= sensor.warningThreshold ? 'warning' : 'normal' + status, + warningStartTime }; } }); diff --git a/app/api/sensors/zones/route.ts b/app/api/sensors/zones/route.ts new file mode 100644 index 0000000..0bcea3d --- /dev/null +++ b/app/api/sensors/zones/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { ZONE_CONFIGS, validateSensorConfig } from '@/utils/sensorConfig'; + +export async function GET() { + try { + return NextResponse.json({ + success: true, + zones: ZONE_CONFIGS, + description: 'Available sensor zones with default threshold configurations', + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('Zone config API error:', error); + return NextResponse.json({ + success: false, + error: 'Failed to get zone configurations', + zones: {}, + timestamp: new Date().toISOString() + }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const sensorConfigs = await request.json(); + + if (!Array.isArray(sensorConfigs)) { + return NextResponse.json({ + success: false, + error: 'Expected array of sensor configurations' + }, { status: 400 }); + } + + const validationResults = sensorConfigs.map(config => ({ + sensorId: config.id, + ...validateSensorConfig(config) + })); + + const hasErrors = validationResults.some(result => !result.isValid); + + return NextResponse.json({ + success: !hasErrors, + validationResults, + summary: { + total: sensorConfigs.length, + valid: validationResults.filter(r => r.isValid).length, + withWarnings: validationResults.filter(r => r.warnings.length > 0).length, + withErrors: validationResults.filter(r => !r.isValid).length + }, + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Sensor validation API error:', error); + return NextResponse.json({ + success: false, + error: 'Failed to validate sensor configurations' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/test-scenarios/route.ts b/app/api/test-scenarios/route.ts new file mode 100644 index 0000000..d4a4e8b --- /dev/null +++ b/app/api/test-scenarios/route.ts @@ -0,0 +1,197 @@ +import { NextRequest, NextResponse } from 'next/server'; + +interface TestSensorData { + sensorId: string; + value: number; + status: string; + warningStartTime?: number; +} + +// Test scenarios for the bed pressure monitoring system +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { scenario } = body; + + let testData: TestSensorData[] = []; + + switch (scenario) { + case 'normal': + testData = generateNormalScenario(); + break; + case 'warning': + testData = generateWarningScenario(); + break; + case 'alarm': + testData = generateAlarmScenario(); + break; + case 'escalation': + testData = generateEscalationScenario(); + break; + case 'mixed': + testData = generateMixedScenario(); + break; + default: + return NextResponse.json({ + success: false, + error: 'Invalid scenario. Use: normal, warning, alarm, escalation, or mixed' + }, { status: 400 }); + } + + return NextResponse.json({ + success: true, + scenario, + message: `Generated ${scenario} test scenario`, + testData, + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Test scenario API error:', error); + return NextResponse.json({ + success: false, + error: 'Failed to generate test scenario' + }, { status: 500 }); + } +} + +function generateNormalScenario() { + return [ + { sensorId: 'head-1', value: 1200, status: 'normal' }, + { sensorId: 'head-2', value: 1150, status: 'normal' }, + { sensorId: 'shoulder-1', value: 1800, status: 'normal' }, + { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, + { sensorId: 'back-1', value: 2000, status: 'normal' }, + { sensorId: 'back-2', value: 2100, status: 'normal' }, + { sensorId: 'back-3', value: 1950, status: 'normal' }, + { sensorId: 'hip-1', value: 1900, status: 'normal' }, + { sensorId: 'hip-2', value: 2000, status: 'normal' }, + { sensorId: 'hip-3', value: 1850, status: 'normal' }, + { sensorId: 'thigh-1', value: 1600, status: 'normal' }, + { sensorId: 'thigh-2', value: 1550, status: 'normal' }, + { sensorId: 'calf-1', value: 1400, status: 'normal' }, + { sensorId: 'calf-2', value: 1350, status: 'normal' }, + { sensorId: 'feet-1', value: 1200, status: 'normal' }, + { sensorId: 'feet-2', value: 1150, status: 'normal' } + ]; +} + +function generateWarningScenario() { + return [ + { sensorId: 'head-1', value: 3100, status: 'warning' }, // Above warning threshold + { sensorId: 'head-2', value: 1150, status: 'normal' }, + { sensorId: 'shoulder-1', value: 2900, status: 'warning' }, // Above warning threshold + { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, + { sensorId: 'back-1', value: 2600, status: 'warning' }, // Above warning threshold + { sensorId: 'back-2', value: 2100, status: 'normal' }, + { sensorId: 'back-3', value: 1950, status: 'normal' }, + { sensorId: 'hip-1', value: 1900, status: 'normal' }, + { sensorId: 'hip-2', value: 2300, status: 'warning' }, // Above warning threshold + { sensorId: 'hip-3', value: 1850, status: 'normal' }, + { sensorId: 'thigh-1', value: 1600, status: 'normal' }, + { sensorId: 'thigh-2', value: 1550, status: 'normal' }, + { sensorId: 'calf-1', value: 1400, status: 'normal' }, + { sensorId: 'calf-2', value: 1350, status: 'normal' }, + { sensorId: 'feet-1', value: 1200, status: 'normal' }, + { sensorId: 'feet-2', value: 1150, status: 'normal' } + ]; +} + +function generateAlarmScenario() { + return [ + { sensorId: 'head-1', value: 3600, status: 'alarm' }, // Above alarm threshold + { sensorId: 'head-2', value: 3550, status: 'alarm' }, // Above alarm threshold + { sensorId: 'shoulder-1', value: 3300, status: 'alarm' }, // Above alarm threshold + { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, + { sensorId: 'back-1', value: 3100, status: 'alarm' }, // Above alarm threshold + { sensorId: 'back-2', value: 2100, status: 'normal' }, + { sensorId: 'back-3', value: 1950, status: 'normal' }, + { sensorId: 'hip-1', value: 2900, status: 'alarm' }, // Above alarm threshold + { sensorId: 'hip-2', value: 2000, status: 'normal' }, + { sensorId: 'hip-3', value: 1850, status: 'normal' }, + { sensorId: 'thigh-1', value: 1600, status: 'normal' }, + { sensorId: 'thigh-2', value: 1550, status: 'normal' }, + { sensorId: 'calf-1', value: 1400, status: 'normal' }, + { sensorId: 'calf-2', value: 1350, status: 'normal' }, + { sensorId: 'feet-1', value: 1200, status: 'normal' }, + { sensorId: 'feet-2', value: 1150, status: 'normal' } + ]; +} + +function generateEscalationScenario() { + // This scenario would simulate sensors that have been in warning state for a while + // and are about to escalate to alarm + const now = Date.now(); + const warningStartTime = now - 25000; // Started warning 25 seconds ago (close to 30s threshold) + + return [ + { sensorId: 'head-1', value: 3100, status: 'warning', warningStartTime }, + { sensorId: 'head-2', value: 1150, status: 'normal' }, + { sensorId: 'shoulder-1', value: 2900, status: 'warning', warningStartTime: now - 40000 }, // Close to 45s threshold + { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, + { sensorId: 'back-1', value: 2600, status: 'warning', warningStartTime: now - 55000 }, // Close to 60s threshold + { sensorId: 'back-2', value: 2100, status: 'normal' }, + { sensorId: 'back-3', value: 1950, status: 'normal' }, + { sensorId: 'hip-1', value: 1900, status: 'normal' }, + { sensorId: 'hip-2', value: 2300, status: 'warning', warningStartTime: now - 85000 }, // Close to 90s threshold + { sensorId: 'hip-3', value: 1850, status: 'normal' }, + { sensorId: 'thigh-1', value: 2100, status: 'warning', warningStartTime: now - 115000 }, // Close to 120s threshold + { sensorId: 'thigh-2', value: 1550, status: 'normal' }, + { sensorId: 'calf-1', value: 1400, status: 'normal' }, + { sensorId: 'calf-2', value: 1350, status: 'normal' }, + { sensorId: 'feet-1', value: 1200, status: 'normal' }, + { sensorId: 'feet-2', value: 1150, status: 'normal' } + ]; +} + +function generateMixedScenario() { + const now = Date.now(); + + return [ + { sensorId: 'head-1', value: 3600, status: 'alarm' }, // Immediate alarm + { sensorId: 'head-2', value: 3100, status: 'warning', warningStartTime: now - 10000 }, // Recent warning + { sensorId: 'shoulder-1', value: 2900, status: 'warning', warningStartTime: now - 40000 }, // Long warning + { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, + { sensorId: 'back-1', value: 3100, status: 'alarm' }, // Immediate alarm + { sensorId: 'back-2', value: 2600, status: 'warning', warningStartTime: now - 30000 }, // Warning close to escalation + { sensorId: 'back-3', value: 1950, status: 'normal' }, + { sensorId: 'hip-1', value: 1900, status: 'normal' }, + { sensorId: 'hip-2', value: 2900, status: 'alarm' }, // Immediate alarm + { sensorId: 'hip-3', value: 2300, status: 'warning', warningStartTime: now - 60000 }, // Warning + { sensorId: 'thigh-1', value: 1600, status: 'normal' }, + { sensorId: 'thigh-2', value: 2100, status: 'warning', warningStartTime: now - 90000 }, // Warning + { sensorId: 'calf-1', value: 2300, status: 'alarm' }, // Immediate alarm + { sensorId: 'calf-2', value: 1900, status: 'warning', warningStartTime: now - 120000 }, // Warning + { sensorId: 'feet-1', value: 1200, status: 'normal' }, + { sensorId: 'feet-2', value: 1900, status: 'alarm' } // Immediate alarm + ]; +} + +export async function GET() { + return NextResponse.json({ + success: true, + availableScenarios: [ + { + name: 'normal', + description: 'All sensors operating within normal ranges' + }, + { + name: 'warning', + description: 'Several sensors in warning state (above warning threshold)' + }, + { + name: 'alarm', + description: 'Multiple sensors in immediate alarm state (above alarm threshold)' + }, + { + name: 'escalation', + description: 'Sensors in warning state close to escalating to alarm after delay' + }, + { + name: 'mixed', + description: 'Mixed scenario with normal, warning, and alarm states' + } + ], + usage: 'POST to /api/test-scenarios with body: { "scenario": "scenario_name" }' + }); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index d97247f..ea89759 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,37 @@ "use client" -import Component from "@/components/bed-pressure-monitor" +import { BedPressureHeader } from "@/components/bed-pressure/BedPressureHeader" +import { StatsCards } from "@/components/bed-pressure/StatsCards" +import { BedVisualization } from "@/components/bed-pressure/BedVisualization" +import { AlertsPanel } from "@/components/bed-pressure/AlertsPanel" +import { AlarmDashboard } from "@/components/bed-pressure/AlarmDashboard" +import { SensorDetailModal } from "@/components/bed-pressure/SensorDetailModal" +import { useBedPressureData } from "@/hooks/useBedPressureData" export default function Page() { - return + useBedPressureData() + + return ( +
+
+ + + + +
+
+ +
+
+ +
+
+ + {/* New Alarm Dashboard Section */} + + + +
+
+ ) } diff --git a/components/bed-pressure/AlarmDashboard.tsx b/components/bed-pressure/AlarmDashboard.tsx new file mode 100644 index 0000000..2aa72e3 --- /dev/null +++ b/components/bed-pressure/AlarmDashboard.tsx @@ -0,0 +1,256 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { AlertTriangle, VolumeX, CheckCircle, Bell, TestTube } from "lucide-react" +import { useBedPressureStore } from "@/stores/bedPressureStore" +import { useEffect, useState } from "react" + +export function AlarmDashboard() { + const { + activeAlarms, + isConnected, + acknowledgeAlarm, + silenceAllAlarms + } = useBedPressureStore() + + const [unsilencedAlarms, setUnsilencedAlarms] = useState(0) + const [testScenario, setTestScenario] = useState('') + + // Update alarm counts + useEffect(() => { + const unsilenced = activeAlarms.filter(alarm => !alarm.silenced).length + setUnsilencedAlarms(unsilenced) + }, [activeAlarms]) + + const handleTestScenario = async (scenario: string) => { + try { + setTestScenario(scenario) + const response = await fetch('/api/test-scenarios', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scenario }) + }) + + if (response.ok) { + console.log(`Applied ${scenario} test scenario`) + } + } catch (error) { + console.error('Failed to apply test scenario:', error) + } finally { + setTimeout(() => setTestScenario(''), 2000) + } + } + + const getSystemStatus = () => { + const alarmCount = activeAlarms.filter(a => a.type === 'alarm' && !a.silenced).length + const warningCount = activeAlarms.filter(a => a.type === 'warning' && !a.silenced).length + + if (alarmCount > 0) return { status: 'ALARM', color: 'text-red-600 bg-red-50 border-red-200', count: alarmCount } + if (warningCount > 0) return { status: 'WARNING', color: 'text-yellow-600 bg-yellow-50 border-yellow-200', count: warningCount } + return { status: 'NORMAL', color: 'text-green-600 bg-green-50 border-green-200', count: 0 } + } + + const systemStatus = getSystemStatus() + + return ( +
+ {/* System Status Overview */} + + +
+
+
+ {systemStatus.status === 'ALARM' ? ( + + ) : systemStatus.status === 'WARNING' ? ( + + ) : ( + + )} +
+
+ + SYSTEM STATUS: {systemStatus.status} + +

+ {isConnected ? 'Hardware Connected' : 'Using Mock Data'} • + {activeAlarms.length} Active Alarms • + {unsilencedAlarms} Unsilenced +

+
+
+ +
+ {systemStatus.count > 0 && ( + + {systemStatus.count} + + )} + {unsilencedAlarms > 0 && ( + + )} +
+
+
+
+ + {/* Active Alarms Summary */} +
+ + +
+ + Critical Alarms +
+

+ {activeAlarms.filter(a => a.type === 'alarm' && !a.silenced).length} +

+
+
+ + + +
+ + Warnings +
+

+ {activeAlarms.filter(a => a.type === 'warning' && !a.silenced).length} +

+
+
+ + + +
+ + Silenced +
+

+ {activeAlarms.filter(a => a.silenced).length} +

+
+
+ + + +
+ + Acknowledged +
+

+ {activeAlarms.filter(a => a.acknowledged).length} +

+
+
+
+ + {/* Test Scenarios */} + + + + + Test Scenarios + + + +
+ {[ + { name: 'normal', label: 'Normal', color: 'bg-green-100 text-green-700 hover:bg-green-200' }, + { name: 'warning', label: 'Warning', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200' }, + { name: 'alarm', label: 'Alarm', color: 'bg-red-100 text-red-700 hover:bg-red-200' }, + { name: 'escalation', label: 'Escalation', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200' }, + { name: 'mixed', label: 'Mixed', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200' } + ].map(scenario => ( + + ))} +
+

+ Click to simulate different alarm scenarios for testing +

+
+
+ + {/* Recent Alarm Activity */} + + + + + Recent Alarm Activity + + + +
+ {activeAlarms.length === 0 ? ( +
+ +

No active alarms

+
+ ) : ( + activeAlarms.slice(0, 5).map((alarm) => ( +
+
+ +
+

{alarm.sensorLabel}

+

+ {alarm.type.toUpperCase()} • Value: {alarm.value.toFixed(0)} • {alarm.time} +

+
+
+ +
+ {alarm.silenced && ( + + + Silenced + + )} + {alarm.acknowledged && ( + + + ACK + + )} + {!alarm.acknowledged && ( + + )} +
+
+ )) + )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/bed-pressure/BedPressureHeader.tsx b/components/bed-pressure/BedPressureHeader.tsx index 863e920..1017e57 100644 --- a/components/bed-pressure/BedPressureHeader.tsx +++ b/components/bed-pressure/BedPressureHeader.tsx @@ -1,10 +1,42 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Activity, Pause, Play } from "lucide-react" +import { Activity, Pause, Play, Clock, AlertTriangle } from "lucide-react" import { useBedPressureStore } from "@/stores/bedPressureStore" +import { useEffect, useState } from "react" export function BedPressureHeader() { - const { isMonitoring, setIsMonitoring } = useBedPressureStore() + const { isMonitoring, setIsMonitoring, sensorData } = useBedPressureStore() + const [countdowns, setCountdowns] = useState>({}) + + // Update countdowns every second + useEffect(() => { + const interval = setInterval(() => { + const newCountdowns: Record = {} + const now = Date.now() + + Object.values(sensorData).forEach(sensor => { + if (sensor.status === 'warning' && sensor.warningStartTime && sensor.warningDelayMs) { + const elapsed = now - sensor.warningStartTime + const remaining = Math.max(0, sensor.warningDelayMs - elapsed) + if (remaining > 0) { + newCountdowns[sensor.id] = remaining + } + } + }) + + setCountdowns(newCountdowns) + }, 1000) + + return () => clearInterval(interval) + }, [sensorData]) + + const formatCountdown = (ms: number) => { + const minutes = Math.floor(ms / 60000) + const seconds = Math.floor((ms % 60000) / 1000) + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } + + const warningCountdowns = Object.entries(countdowns).filter(([, time]) => time > 0) return (
@@ -16,6 +48,37 @@ export function BedPressureHeader() { {isMonitoring ? "Live" : "Paused"} + + {/* Warning Countdown Indicators */} + {warningCountdowns.length > 0 && ( +
+ {warningCountdowns.slice(0, 3).map(([sensorId, timeRemaining]) => { + const sensor = sensorData[sensorId] + return ( +
+ + + {sensor?.label} + +
+ + + {formatCountdown(timeRemaining)} + +
+
+ ) + })} + {warningCountdowns.length > 3 && ( + + +{warningCountdowns.length - 3} more + + )} +
+ )}
diff --git a/stores/bedPressureStore.ts b/stores/bedPressureStore.ts index 0d4aa1d..46f7246 100644 --- a/stores/bedPressureStore.ts +++ b/stores/bedPressureStore.ts @@ -1,14 +1,6 @@ import { create } from 'zustand' import { AlarmManager, AlarmEvent } from '@/services/AlarmManager' - -export interface SensorConfig { - id: string; - x: number; - y: number; - zone: string; - label: string; - pin?: number; -} +import { SensorConfig } from '@/types/sensor' export interface SensorData { id: string; @@ -25,6 +17,7 @@ export interface SensorData { warningThreshold?: number; alarmThreshold?: number; warningDelayMs?: number; + warningStartTime?: number; // Track when warning state started } export interface Alert { @@ -160,27 +153,77 @@ export const useBedPressureStore = create((set, get) => ({ digitalState?: number; warningThreshold?: number; alarmThreshold?: number; + warningDelayMs?: number; status?: string; }) => { const currentSensor = updated[sensor.id]; const newValue = sensor.value; + const config = sensorConfig.find(s => s.id === sensor.id); + + // Get thresholds from sensor data or config + const warningThreshold = sensor.warningThreshold || config?.warningThreshold || 2500; + const alarmThreshold = sensor.alarmThreshold || config?.alarmThreshold || 3500; + const warningDelayMs = sensor.warningDelayMs || config?.warningDelayMs || 60000; - // Check for alarms based on thresholds - if (sensor.alarmThreshold && newValue >= sensor.alarmThreshold) { - alarmManager.addAlarm(sensor.id, sensor.label, 'alarm', newValue, sensor.alarmThreshold); - } else if (sensor.warningThreshold && newValue >= sensor.warningThreshold) { - alarmManager.addAlarm(sensor.id, sensor.label, 'warning', newValue, sensor.warningThreshold); - } else { - alarmManager.clearAlarm(sensor.id); - } + // Determine status and handle alarm logic + let status = 'normal'; + let warningStartTime = currentSensor?.warningStartTime; + const now = Date.now(); - // Check for alerts (legacy alert system) - if (newValue > 3000 && currentSensor && currentSensor.currentValue <= 3000) { + if (newValue >= alarmThreshold) { + status = 'alarm'; + warningStartTime = undefined; // Clear warning timer for immediate alarm + + // Add alarm + alarmManager.addAlarm( + sensor.id, + sensor.label, + 'alarm', + newValue, + alarmThreshold + ); + newAlerts.push({ id: `${sensor.id}-${Date.now()}`, - message: `High value detected at ${sensor.label}`, + message: `ALARM: High value detected at ${sensor.label} (${newValue})`, time: new Date().toLocaleTimeString(), }); + + } else if (newValue >= warningThreshold) { + status = 'warning'; + + if (!warningStartTime) { + warningStartTime = now; // Start warning timer + } else if (now - warningStartTime >= warningDelayMs) { + status = 'alarm'; // Escalate to alarm after delay + + // Add escalated alarm + alarmManager.addAlarm( + sensor.id, + sensor.label, + 'alarm', + newValue, + warningThreshold + ); + + newAlerts.push({ + id: `${sensor.id}-${Date.now()}`, + message: `ALARM: Warning escalated for ${sensor.label} (${newValue})`, + time: new Date().toLocaleTimeString(), + }); + } else { + // Add warning alarm + alarmManager.addAlarm( + sensor.id, + sensor.label, + 'warning', + newValue, + warningThreshold + ); + } + } else { + warningStartTime = undefined; // Clear warning timer + alarmManager.clearAlarm(sensor.id); // Clear any existing alarms } // Update sensor data @@ -204,12 +247,14 @@ export const useBedPressureStore = create((set, get) => ({ timestamp: Date.now(), value: newValue, }], - status: sensor.status || (newValue > 3000 ? "critical" : newValue > 2500 ? "warning" : "normal"), + status, source: sensor.source, pin: sensor.pin, digitalState: sensor.digitalState, - warningThreshold: sensor.warningThreshold, - alarmThreshold: sensor.alarmThreshold + warningThreshold, + alarmThreshold, + warningDelayMs, + warningStartTime }; }); diff --git a/types/sensor.ts b/types/sensor.ts new file mode 100644 index 0000000..5833496 --- /dev/null +++ b/types/sensor.ts @@ -0,0 +1,24 @@ +export interface SensorConfig { + id: string; + x: number; + y: number; + zone: string; + label: string; + pin?: number; + warningThreshold: number; + alarmThreshold: number; + warningDelayMs: number; // Duration in milliseconds before warning escalates to alarm + baseNoise?: number; // For hardware sensors - noise level for analog conversion +} + +export interface SensorThresholds { + warningThreshold: number; + alarmThreshold: number; + warningDelayMs: number; +} + +export interface SensorZoneConfig { + zone: string; + defaultThresholds: SensorThresholds; + description: string; +} \ No newline at end of file diff --git a/utils/sensorConfig.ts b/utils/sensorConfig.ts new file mode 100644 index 0000000..271b356 --- /dev/null +++ b/utils/sensorConfig.ts @@ -0,0 +1,152 @@ +import { SensorConfig, SensorZoneConfig } from '@/types/sensor'; + +// Define zone-based default configurations +export const ZONE_CONFIGS: Record = { + head: { + zone: 'head', + defaultThresholds: { + warningThreshold: 3000, + alarmThreshold: 3500, + warningDelayMs: 30000 // 30 seconds - critical area needs fast response + }, + description: 'Head area - most critical, fastest escalation' + }, + shoulders: { + zone: 'shoulders', + defaultThresholds: { + warningThreshold: 2800, + alarmThreshold: 3200, + warningDelayMs: 45000 // 45 seconds + }, + description: 'Shoulder area - high priority, moderate escalation' + }, + back: { + zone: 'back', + defaultThresholds: { + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000 // 1 minute + }, + description: 'Back area - moderate priority, standard escalation' + }, + hips: { + zone: 'hips', + defaultThresholds: { + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000 // 90 seconds + }, + description: 'Hip area - moderate priority, longer escalation' + }, + legs: { + zone: 'legs', + defaultThresholds: { + warningThreshold: 2000, + alarmThreshold: 2500, + warningDelayMs: 120000 // 2 minutes + }, + description: 'Leg area - lower priority, extended escalation' + }, + feet: { + zone: 'feet', + defaultThresholds: { + warningThreshold: 1500, + alarmThreshold: 1800, + warningDelayMs: 180000 // 3 minutes + }, + description: 'Feet area - lowest priority, longest escalation' + } +}; + +/** + * Validates sensor configuration against zone defaults + */ +export function validateSensorConfig(config: SensorConfig): { + isValid: boolean; + warnings: string[]; + errors: string[]; +} { + const warnings: string[] = []; + const errors: string[] = []; + + // Check if zone exists + const zoneConfig = ZONE_CONFIGS[config.zone]; + if (!zoneConfig) { + errors.push(`Unknown zone: ${config.zone}`); + return { isValid: false, warnings, errors }; + } + + // Validate threshold values + if (config.alarmThreshold <= config.warningThreshold) { + errors.push(`Alarm threshold (${config.alarmThreshold}) must be greater than warning threshold (${config.warningThreshold})`); + } + + if (config.warningThreshold <= 0 || config.alarmThreshold <= 0) { + errors.push('Thresholds must be positive values'); + } + + if (config.warningDelayMs <= 0) { + errors.push('Warning delay must be positive'); + } + + // Check against zone defaults (warnings only) + const defaults = zoneConfig.defaultThresholds; + const tolerance = 0.2; // 20% tolerance + + if (Math.abs(config.warningThreshold - defaults.warningThreshold) / defaults.warningThreshold > tolerance) { + warnings.push(`Warning threshold (${config.warningThreshold}) differs significantly from zone default (${defaults.warningThreshold})`); + } + + if (Math.abs(config.alarmThreshold - defaults.alarmThreshold) / defaults.alarmThreshold > tolerance) { + warnings.push(`Alarm threshold (${config.alarmThreshold}) differs significantly from zone default (${defaults.alarmThreshold})`); + } + + if (Math.abs(config.warningDelayMs - defaults.warningDelayMs) / defaults.warningDelayMs > tolerance) { + warnings.push(`Warning delay (${config.warningDelayMs}ms) differs significantly from zone default (${defaults.warningDelayMs}ms)`); + } + + return { isValid: errors.length === 0, warnings, errors }; +} + +/** + * Creates a sensor config with zone defaults as base + */ +export function createSensorConfig( + id: string, + position: { x: number; y: number }, + zone: string, + label: string, + overrides: Partial = {} +): SensorConfig { + const zoneConfig = ZONE_CONFIGS[zone]; + if (!zoneConfig) { + throw new Error(`Unknown zone: ${zone}`); + } + + return { + id, + x: position.x, + y: position.y, + zone, + label, + ...zoneConfig.defaultThresholds, + ...overrides + }; +} + +/** + * Gets human-readable duration string + */ +export function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } +} \ No newline at end of file From fb87e74ec97e2a94256f3efdb3020832e3456338 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 14:55:10 +0700 Subject: [PATCH 04/14] feat: Refactor BedHardware to support both Serial and MQTT implementations - Added MQTT support to BedHardware, allowing for connection to MQTT brokers. - Created BedHardwareMQTT and BedHardwareSerial classes to handle respective connections. - Introduced a unified BedHardwareConfig interface for configuration management. - Implemented event forwarding from the underlying implementations to the BedHardware class. - Added MQTT adapter for handling MQTT connections and message subscriptions. - Updated package.json to include the mqtt library as a dependency. - Created a singleton MQTTService for managing MQTT client instances. - Enhanced error handling and logging throughout the BedHardware and MQTT classes. --- adapter/mqtt.ts | 158 ++++++++++++++++++++ bun.lock | 77 ++++++++++ package.json | 1 + services/BedHardware.ts | 261 ++++++++++++++-------------------- services/BedHardwareMQTT.ts | 140 ++++++++++++++++++ services/BedHardwareSerial.ts | 149 +++++++++++++++++++ services/mqttService.ts | 72 ++++++++++ types/bedhardware.ts | 53 +++++++ 8 files changed, 753 insertions(+), 158 deletions(-) create mode 100644 adapter/mqtt.ts create mode 100644 services/BedHardwareMQTT.ts create mode 100644 services/BedHardwareSerial.ts create mode 100644 services/mqttService.ts create mode 100644 types/bedhardware.ts diff --git a/adapter/mqtt.ts b/adapter/mqtt.ts new file mode 100644 index 0000000..d007af3 --- /dev/null +++ b/adapter/mqtt.ts @@ -0,0 +1,158 @@ +import mqtt, { type MqttClient } from "mqtt"; +import type { QoS } from "mqtt-packet"; + +export interface MQTTConfig { + host: string; + port: number; + username?: string; + password?: string; +} + +interface MQTTSubscription { + topic: string; + callback: (topic: string, message: string) => void; +} + +export interface MQTTMessage { + topic: string; + payload: string; + qos?: QoS; + retain?: boolean; +} + +class MQTT { + private client: MqttClient | null = null; + private config: MQTTConfig | null = null; + private readonly subscriptions: MQTTSubscription[] = []; + private isConnected: boolean = false; + private keepAliveTimer: NodeJS.Timeout | null = null; + + async keepAlive(): Promise { + if (this.isConnected) { + return; + } + console.log("MQTT client is not connected, attempting to reconnect..."); + await this.connectMQTT(); + } + + async initialize(config: MQTTConfig): Promise { + this.config = config; + this.connectMQTT(); + + // Start keep-alive timer + this.keepAliveTimer = setInterval(() => { + this.keepAlive(); + }, 5000); // Run every 5 seconds + } + + async connectMQTT(): Promise { + if (!this.config) { + throw new Error("MQTT configuration is not set."); + } + try { + this.client = mqtt.connect(`mqtt://${this.config.host}:${this.config.port}`, { + username: this.config?.username, + password: this.config?.password + }); + await this.setupHandler(); + } catch (error) { + console.error("Failed to connect to MQTT broker:", error); + } + } + + async publish(message: MQTTMessage): Promise { + if (!this.client || !this.isConnected) { + console.error("MQTT client is not connected, cannot publish message."); + return; + } + + const { topic, payload, qos = 0, retain = false } = message; + try { + await this.client.publishAsync(topic, payload, { qos, retain }); + } catch (error) { + console.error(`Failed to publish message to ${topic}:`, error); + } + } + + async subscribe(topic: string, callback: (topic: string, message: string) => void): Promise { + const subscription: MQTTSubscription = { topic, callback }; + this.subscriptions.push(subscription); + + if (this.client && this.isConnected) { + try { + await this.client.subscribeAsync(topic); + } catch (error) { + console.error(`Failed to subscribe to ${topic}:`, error); + } + } + } + + async setupHandler(): Promise { + if (!this.client) { + console.error("MQTT client is not initialized."); + return; + } + + this.client.on("connect", this.onConnect.bind(this)); + this.client.on("message", this.onMessage.bind(this)); + this.client.on("disconnect", this.onDisconnect.bind(this)); + } + + async disconnect(): Promise { + if (this.keepAliveTimer) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = null; + } + + if (this.client) { + await this.client.endAsync(); + this.client = null; + } + this.isConnected = false; + } + + private async onConnect(): Promise { + console.log("MQTT connected successfully."); + for (const sub of this.subscriptions) { + if (this.client) { + try { + await this.client.subscribeAsync(sub.topic); + } catch (error) { + console.error(`Failed to subscribe to ${sub.topic}:`, error); + } + } + } + this.isConnected = true; + } + + private onDisconnect(): void { + console.log("MQTT disconnected."); + this.isConnected = false; + } + + private onMessage(topic: string, message: Buffer): void { + const msg = message.toString(); + this.subscriptions.forEach(sub => { + if (this.matchTopic(sub.topic, topic)) { + sub.callback(topic, msg); + } + }); + } + + private matchTopic(subscriptionTopic: string, publishTopic: string): boolean { + // Exact match + if (subscriptionTopic === publishTopic) { + return true; + } + + // Convert MQTT wildcards to regex + const regexPattern = subscriptionTopic + .replace(/\+/g, '[^/]+') // + matches single level + .replace(/#$/, '.*'); // # matches multi-level (only at end) + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(publishTopic); + } +} + +export default MQTT; \ No newline at end of file diff --git a/bun.lock b/bun.lock index 7d4a858..1e74b03 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.519.0", + "mqtt": "^5.13.1", "next": "15.3.4", "port": "^0.8.1", "react": "^19.1.0", @@ -315,6 +316,8 @@ "@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="], + "@types/readable-stream": ["@types/readable-stream@4.0.21", "", { "dependencies": { "@types/node": "*" } }, "sha512-19eKVv9tugr03IgfXlA9UVUVRbW6IuqRO5B92Dl4a6pT7K8uaGrNS0GkxiZD0BOk6PLuXl5FhWl//eX/pzYdTQ=="], + "@types/serialport": ["@types/serialport@10.2.0", "", { "dependencies": { "serialport": "*" } }, "sha512-jddRnvcjZLSQHyK8anaUFAAwnET8bcWoM2TVU7SZyY4xVqnorhsvsZLVfqgYk/zintnqrUTCshE/CgqFnBLEcg=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.34.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/type-utils": "8.34.1", "@typescript-eslint/utils": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.34.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ=="], @@ -375,6 +378,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.9.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rS86wI4R6cknYM3is3grCb/laE8XBEbpWAMSIPjYfmYp75KL5dT87jXF2orDa4tQYg5aajP5G8Fgh34dRyR+Rw=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -417,10 +422,18 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bl": ["bl@6.1.0", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-ClDyJGQkc8ZtzdAAbAwBmhMSpwN/sC9HA8jxdYm6nVUbCfZbe2mgza4qh7AuEYyEPB/c4Kznf9s66bnsKMQDjw=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -451,8 +464,12 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "commist": ["commist@3.2.0", "", {}, "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -561,8 +578,12 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="], @@ -573,6 +594,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-unique-numbers": ["fast-unique-numbers@8.0.13", "", { "dependencies": { "@babel/runtime": "^7.23.8", "tslib": "^2.6.2" } }, "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g=="], + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], @@ -631,16 +654,24 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ip-address": ["ip-address@9.0.5", "", { "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" } }, "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g=="], + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], @@ -703,10 +734,14 @@ "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + "js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -755,6 +790,8 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "lucide-react": ["lucide-react@0.519.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cLJyjRKBJFzaZ/+1oIeQaH7XUdxKOYU3uANcGSrKdIZWElmNbRAm8RXKiTJS7AWLCBOS8b7A497Al/kCHozd+A=="], "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], @@ -775,6 +812,10 @@ "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + "mqtt": ["mqtt@5.13.1", "", { "dependencies": { "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.0", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "number-allocator": "^1.0.14", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.3", "split2": "^4.2.0", "worker-timers": "^7.1.8", "ws": "^8.18.0" }, "bin": { "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js", "mqtt": "build/bin/mqtt.js" } }, "sha512-g+4G+ma0UeL3Pgu1y1si2NHb4VLIEUCtF789WrG99lLG0XZyoab2EJoy58YgGSg/1yFdthDBH0+4llsZZD/vug=="], + + "mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -789,6 +830,8 @@ "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "number-allocator": ["number-allocator@1.0.14", "", { "dependencies": { "debug": "^4.3.1", "js-sdsl": "4.3.0" } }, "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -833,6 +876,10 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -855,6 +902,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], @@ -871,10 +920,14 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], @@ -909,8 +962,16 @@ "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.5", "", { "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -929,6 +990,8 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -971,6 +1034,8 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], @@ -985,6 +1050,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -999,6 +1066,14 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "worker-timers": ["worker-timers@7.1.8", "", { "dependencies": { "@babel/runtime": "^7.24.5", "tslib": "^2.6.2", "worker-timers-broker": "^6.1.8", "worker-timers-worker": "^7.0.71" } }, "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw=="], + + "worker-timers-broker": ["worker-timers-broker@6.1.8", "", { "dependencies": { "@babel/runtime": "^7.24.5", "fast-unique-numbers": "^8.0.13", "tslib": "^2.6.2", "worker-timers-worker": "^7.0.71" } }, "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ=="], + + "worker-timers-worker": ["worker-timers-worker@7.0.71", "", { "dependencies": { "@babel/runtime": "^7.24.5", "tslib": "^2.6.2" } }, "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ=="], + + "ws": ["ws@8.18.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -1037,6 +1112,8 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], diff --git a/package.json b/package.json index 78a2b12..911db9a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.519.0", + "mqtt": "^5.13.1", "next": "15.3.4", "port": "^0.8.1", "react": "^19.1.0", diff --git a/services/BedHardware.ts b/services/BedHardware.ts index 762fd6a..8e00e5c 100644 --- a/services/BedHardware.ts +++ b/services/BedHardware.ts @@ -1,181 +1,126 @@ -import { SerialPort } from 'serialport'; -import { ReadlineParser } from '@serialport/parser-readline'; import { EventEmitter } from 'events'; +import { IBedHardware, PinState, PinChange, BedHardwareConfig } from '../types/bedhardware'; +import { BedHardwareSerial } from './BedHardwareSerial'; +import { BedHardwareMQTT } from './BedHardwareMQTT'; -export interface PinState { - pin: number; - state: number; - name: string; - timestamp: Date; -} +/** + * BedHardware - Factory class for creating bed hardware implementations + * + * Usage: + * // MQTT (connects to broker.hivemq.com with base topic /Jtkcp2N/pressurebed/) + * const hardware = BedHardware.createSimpleMQTT(); + * + * // Serial + * const hardware = BedHardware.createSerial('COM3', 9600); + * + * // With custom topics + * const hardware = BedHardware.createMQTT({ + * topics: { + * pinState: '/custom/pin/state', + * pinChange: '/custom/pin/change', + * initialization: '/custom/init' + * } + * }); + */ +export class BedHardware extends EventEmitter implements IBedHardware { + private implementation: IBedHardware; -export interface PinChange { - pin: number; - previousState: number; - currentState: number; - timestamp: Date; -} - -export class BedHardware extends EventEmitter { - private serialPort: SerialPort | null = null; - private parser: ReadlineParser | null = null; - private pinStates: Map = new Map(); - private isConnected: boolean = false; - - constructor(private portPath: string, private baudRate: number = 9600) { + constructor(config: BedHardwareConfig) { super(); + + if (config.type === 'serial') { + if (!config.serial?.portPath) { + throw new Error('Serial port path is required for serial connection'); + } + this.implementation = new BedHardwareSerial( + config.serial.portPath, + config.serial.baudRate || 9600 ); + } else if (config.type === 'mqtt') { + this.implementation = new BedHardwareMQTT({ + topics: config.mqtt?.topics + }); + } else { + throw new Error(`Unsupported hardware type: ${config.type}`); + } + + // Forward all events from the implementation + this.forwardEvents(); + } + + private forwardEvents(): void { + const events = [ + 'connected', + 'disconnected', + 'error', + 'initialized', + 'pinInitialized', + 'pinChanged' + ]; + + events.forEach(event => { + this.implementation.on(event, (...args: unknown[]) => { + this.emit(event, ...args); + }); + }); + + // Forward dynamic pin events (pin{number}Changed) + this.implementation.on('pinChanged', (pinChange: PinChange) => { + this.emit(`pin${pinChange.pin}Changed`, pinChange); + }); } async connect(): Promise { - try { - this.serialPort = new SerialPort({ - path: this.portPath, - baudRate: this.baudRate, - autoOpen: false - }); - - this.parser = new ReadlineParser({ delimiter: '\n' }); - this.serialPort.pipe(this.parser); - - // Setup event handlers - this.serialPort.on('open', () => { - this.isConnected = true; - this.emit('connected'); - console.log('Serial port opened'); - }); - - this.serialPort.on('error', (error) => { - this.emit('error', error); - console.error('Serial port error:', error); - }); - - this.serialPort.on('close', () => { - this.isConnected = false; - this.emit('disconnected'); - console.log('Serial port closed'); - }); - - this.parser.on('data', (data: string) => { - this.handleSerialData(data.trim()); - }); - - // Open the port - await new Promise((resolve, reject) => { - this.serialPort!.open((error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - - } catch (error) { - throw new Error(`Failed to connect to ${this.portPath}: ${error}`); - } + return this.implementation.connect(); } async disconnect(): Promise { - if (this.serialPort && this.serialPort.isOpen) { - await new Promise((resolve) => { - this.serialPort!.close(() => { - resolve(); - }); - }); - } - this.serialPort = null; - this.parser = null; - this.isConnected = false; - } - - private handleSerialData(data: string): void { - const parts = data.split(':'); - - if (parts[0] === 'INIT') { - if (parts[1] === 'START') { - this.emit('initialized'); - console.log('Arduino initialization started'); - } else if (parts.length >= 3) { - // INIT:PIN:STATE format - const pin = parseInt(parts[1]); - const state = parseInt(parts[2]); - - if (!isNaN(pin) && !isNaN(state)) { - const pinState: PinState = { - pin, - state, - name: `PIN${pin}`, - timestamp: new Date() - }; - - this.pinStates.set(pin, pinState); - this.emit('pinInitialized', pinState); - } - } - } else if (parts[0] === 'CHANGE' && parts.length >= 4) { - // CHANGE:PIN:PREVIOUS_STATE:CURRENT_STATE format - const pin = parseInt(parts[1]); - const previousState = parseInt(parts[2]); - const currentState = parseInt(parts[3]); - - if (!isNaN(pin) && !isNaN(previousState) && !isNaN(currentState)) { - const pinChange: PinChange = { - pin, - previousState, - currentState, - timestamp: new Date() - }; - - // Update stored pin state - const pinState: PinState = { - pin, - state: currentState, - name: `PIN${pin}`, - timestamp: new Date() - }; - - this.pinStates.set(pin, pinState); - - this.emit('pinChanged', pinChange); - this.emit(`pin${pin}Changed`, pinChange); - } - } + return this.implementation.disconnect(); } getPinState(pin: number): PinState | undefined { - return this.pinStates.get(pin); + return this.implementation.getPinState(pin); } getAllPinStates(): PinState[] { - return Array.from(this.pinStates.values()); + return this.implementation.getAllPinStates(); } - isPortConnected(): boolean { - return this.isConnected && this.serialPort?.isOpen === true; + isConnected(): boolean { + return this.implementation.isConnected(); } - // Static method to list available serial ports - static async listPorts(): Promise { - const ports = await SerialPort.list(); - return ports.map(port => port.path); + // Static factory methods for convenience + static createSerial(portPath: string, baudRate: number = 9600): BedHardware { + return new BedHardware({ + type: 'serial', + serial: { portPath, baudRate } + }); + } static createMQTT(config?: { + topics?: { + pinState: string; + pinChange: string; + initialization: string; + }; + }): BedHardware { + return new BedHardware({ + type: 'mqtt', + mqtt: config + }); + } + + static createSimpleMQTT(): BedHardware { + return new BedHardware({ + type: 'mqtt', + mqtt: {} + }); + } + + // Static method to list available serial ports (for serial implementation) + static async listSerialPorts(): Promise { + return BedHardwareSerial.listPorts(); } } -// Example usage: -/* -const bedHardware = new BedHardware('/dev/ttyUSB0', 9600); - -bedHardware.on('connected', () => { - console.log('Connected to bed hardware'); -}); - -bedHardware.on('pinChanged', (change: PinChange) => { - console.log(`Pin ${change.pin} changed from ${change.previousState} to ${change.currentState}`); -}); - -bedHardware.on('error', (error) => { - console.error('Hardware error:', error); -}); - -await bedHardware.connect(); -*/ \ No newline at end of file +// Export all classes for direct access if needed +export { BedHardwareSerial, BedHardwareMQTT }; +export * from '../types/bedhardware'; \ No newline at end of file diff --git a/services/BedHardwareMQTT.ts b/services/BedHardwareMQTT.ts new file mode 100644 index 0000000..29ba4a4 --- /dev/null +++ b/services/BedHardwareMQTT.ts @@ -0,0 +1,140 @@ +import { EventEmitter } from 'events'; +import { IBedHardware, PinState, PinChange } from '../types/bedhardware'; +import MQTT from '../adapter/mqtt'; +import { getMQTTClient, BASE_TOPIC } from './mqttService'; + +export interface MqttConfig { + topics?: { + pinState: string; + pinChange: string; + initialization: string; + }; +} + +export class BedHardwareMQTT extends EventEmitter implements IBedHardware { + private client: MQTT | null = null; + private pinStates: Map = new Map(); + private connectionState: boolean = false; + private topics: { + pinState: string; + pinChange: string; + initialization: string; + }; + + constructor(private config: MqttConfig = {}) { + super(); + this.topics = config.topics || { + pinState: `${BASE_TOPIC}pin/state`, + pinChange: `${BASE_TOPIC}pin/change`, + initialization: `${BASE_TOPIC}init` + }; + } + async connect(): Promise { + try { + // Use the MQTT service to get a client connected to HiveMQ + this.client = await getMQTTClient(); + + // Subscribe to topics - adapter handles reconnection/resubscription + await this.client.subscribe(this.topics.initialization, (topic, message) => { + this.handleMqttMessage(topic, message); + }); + + await this.client.subscribe(this.topics.pinState, (topic, message) => { + this.handleMqttMessage(topic, message); + }); + + await this.client.subscribe(this.topics.pinChange, (topic, message) => { + this.handleMqttMessage(topic, message); + }); + + this.connectionState = true; + this.emit('connected'); + console.log('BedHardware MQTT connected to HiveMQ'); + + } catch (error) { + const errorMsg = `Failed to connect to MQTT broker: ${error}`; + console.error(errorMsg); + this.emit('error', new Error(errorMsg)); + throw new Error(errorMsg); + } + } async disconnect(): Promise { + if (this.client) { + await this.client.disconnect(); + } + this.client = null; + this.connectionState = false; + this.emit('disconnected'); + } + + private handleMqttMessage(topic: string, message: string): void { + try { + const data = JSON.parse(message); + + if (topic === this.topics.initialization) { + if (data.type === 'START') { + this.emit('initialized'); + console.log('MQTT initialization started'); + } else if (data.type === 'PIN_INIT' && data.pin !== undefined && data.state !== undefined) { + const pinState: PinState = { + pin: data.pin, + state: data.state, + name: data.name || `PIN${data.pin}`, + timestamp: new Date(data.timestamp || Date.now()) + }; + + this.pinStates.set(data.pin, pinState); + this.emit('pinInitialized', pinState); + } + } else if (topic === this.topics.pinChange) { + if (data.pin !== undefined && data.previousState !== undefined && data.currentState !== undefined) { + const pinChange: PinChange = { + pin: data.pin, + previousState: data.previousState, + currentState: data.currentState, + timestamp: new Date(data.timestamp || Date.now()) + }; + + // Update stored pin state + const pinState: PinState = { + pin: data.pin, + state: data.currentState, + name: data.name || `PIN${data.pin}`, + timestamp: new Date(data.timestamp || Date.now()) + }; + + this.pinStates.set(data.pin, pinState); + + this.emit('pinChanged', pinChange); + this.emit(`pin${data.pin}Changed`, pinChange); + } + } else if (topic === this.topics.pinState) { + if (data.pin !== undefined && data.state !== undefined) { + const pinState: PinState = { + pin: data.pin, + state: data.state, + name: data.name || `PIN${data.pin}`, + timestamp: new Date(data.timestamp || Date.now()) + }; + + this.pinStates.set(data.pin, pinState); + this.emit('pinInitialized', pinState); + } + } + } catch (error) { + console.error('Error parsing MQTT message:', error); + this.emit('error', new Error(`Failed to parse MQTT message: ${error}`)); + } + } + + getPinState(pin: number): PinState | undefined { + return this.pinStates.get(pin); + } + + getAllPinStates(): PinState[] { + return Array.from(this.pinStates.values()); + } + + isConnected(): boolean { + return this.connectionState; + } +} \ No newline at end of file diff --git a/services/BedHardwareSerial.ts b/services/BedHardwareSerial.ts new file mode 100644 index 0000000..1bf4537 --- /dev/null +++ b/services/BedHardwareSerial.ts @@ -0,0 +1,149 @@ +import { SerialPort } from 'serialport'; +import { ReadlineParser } from '@serialport/parser-readline'; +import { EventEmitter } from 'events'; +import { IBedHardware, PinState, PinChange } from '../types/bedhardware'; + +export class BedHardwareSerial extends EventEmitter implements IBedHardware { + private serialPort: SerialPort | null = null; + private parser: ReadlineParser | null = null; + private pinStates: Map = new Map(); + private connectionState: boolean = false; + + constructor(private portPath: string, private baudRate: number = 9600) { + super(); + } + + async connect(): Promise { + try { + this.serialPort = new SerialPort({ + path: this.portPath, + baudRate: this.baudRate, + autoOpen: false + }); + + this.parser = new ReadlineParser({ delimiter: '\n' }); + this.serialPort.pipe(this.parser); + + // Setup event handlers + this.serialPort.on('open', () => { + this.connectionState = true; + this.emit('connected'); + console.log('Serial port opened'); + }); + + this.serialPort.on('error', (error) => { + this.emit('error', error); + console.error('Serial port error:', error); + }); + + this.serialPort.on('close', () => { + this.connectionState = false; + this.emit('disconnected'); + console.log('Serial port closed'); + }); + + this.parser.on('data', (data: string) => { + this.handleSerialData(data.trim()); + }); + + // Open the port + await new Promise((resolve, reject) => { + this.serialPort!.open((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + + } catch (error) { + throw new Error(`Failed to connect to ${this.portPath}: ${error}`); + } + } + + async disconnect(): Promise { + if (this.serialPort && this.serialPort.isOpen) { + await new Promise((resolve) => { + this.serialPort!.close(() => { + resolve(); + }); + }); + } + this.serialPort = null; + this.parser = null; + this.connectionState = false; + } + + private handleSerialData(data: string): void { + const parts = data.split(':'); + + if (parts[0] === 'INIT') { + if (parts[1] === 'START') { + this.emit('initialized'); + console.log('Arduino initialization started'); + } else if (parts.length >= 3) { + // INIT:PIN:STATE format + const pin = parseInt(parts[1]); + const state = parseInt(parts[2]); + + if (!isNaN(pin) && !isNaN(state)) { + const pinState: PinState = { + pin, + state, + name: `PIN${pin}`, + timestamp: new Date() + }; + + this.pinStates.set(pin, pinState); + this.emit('pinInitialized', pinState); + } + } + } else if (parts[0] === 'CHANGE' && parts.length >= 4) { + // CHANGE:PIN:PREVIOUS_STATE:CURRENT_STATE format + const pin = parseInt(parts[1]); + const previousState = parseInt(parts[2]); + const currentState = parseInt(parts[3]); + + if (!isNaN(pin) && !isNaN(previousState) && !isNaN(currentState)) { + const pinChange: PinChange = { + pin, + previousState, + currentState, + timestamp: new Date() + }; + + // Update stored pin state + const pinState: PinState = { + pin, + state: currentState, + name: `PIN${pin}`, + timestamp: new Date() + }; + + this.pinStates.set(pin, pinState); + + this.emit('pinChanged', pinChange); + this.emit(`pin${pin}Changed`, pinChange); + } + } + } + + getPinState(pin: number): PinState | undefined { + return this.pinStates.get(pin); + } + + getAllPinStates(): PinState[] { + return Array.from(this.pinStates.values()); + } + + isConnected(): boolean { + return this.connectionState && this.serialPort?.isOpen === true; + } + + // Static method to list available serial ports + static async listPorts(): Promise { + const ports = await SerialPort.list(); + return ports.map(port => port.path); + } +} \ No newline at end of file diff --git a/services/mqttService.ts b/services/mqttService.ts new file mode 100644 index 0000000..59c787e --- /dev/null +++ b/services/mqttService.ts @@ -0,0 +1,72 @@ +import MQTT, { MQTTConfig } from '../adapter/mqtt'; + +// Default MQTT configuration for HiveMQ broker +const defaultConfig: MQTTConfig = { + host: 'broker.hivemq.com', + port: 1883, + username: undefined, + password: undefined +}; + +export const BASE_TOPIC = '/Jtkcp2N/pressurebed/'; + +// Singleton MQTT client instance +let mqttInstance: MQTT | null = null; + +export class MQTTService { + private static instance: MQTT | null = null; + + static async initialize(config?: Partial): Promise { + if (!MQTTService.instance) { + const finalConfig = { ...defaultConfig, ...config }; + MQTTService.instance = new MQTT(); + await MQTTService.instance.initialize(finalConfig); + } + return MQTTService.instance; + } + + static getInstance(): MQTT | null { + return MQTTService.instance; + } + + static async disconnect(): Promise { + if (MQTTService.instance) { + await MQTTService.instance.disconnect(); + MQTTService.instance = null; + } + } +} + +// Factory function to get or create MQTT client +export async function getMQTTClient(config?: Partial): Promise { + if (!mqttInstance) { + mqttInstance = new MQTT(); + const finalConfig = { ...defaultConfig, ...config }; + await mqttInstance.initialize(finalConfig); + } + return mqttInstance; +} + +// Export the singleton instance getter +export function getMQTTInstance(): MQTT | null { + return mqttInstance; +} + +// Cleanup function +export async function disconnectMQTT(): Promise { + if (mqttInstance) { + await mqttInstance.disconnect(); + mqttInstance = null; + } +} + +// Export default configured client (lazy initialization) +const mqttService = { + async getClient(config?: Partial): Promise { + return getMQTTClient(config); + }, + getInstance: getMQTTInstance, + disconnect: disconnectMQTT +}; + +export default mqttService; \ No newline at end of file diff --git a/types/bedhardware.ts b/types/bedhardware.ts new file mode 100644 index 0000000..32dccc1 --- /dev/null +++ b/types/bedhardware.ts @@ -0,0 +1,53 @@ +export interface PinState { + pin: number; + state: number; + name: string; + timestamp: Date; +} + +export interface PinChange { + pin: number; + previousState: number; + currentState: number; + timestamp: Date; +} + +export interface BedHardwareConfig { + type: 'serial' | 'mqtt'; + serial?: { + portPath: string; + baudRate?: number; + }; + mqtt?: { + topics?: { + pinState: string; + pinChange: string; + initialization: string; + }; + }; +} + +export interface IBedHardware { + connect(): Promise; + disconnect(): Promise; + getPinState(pin: number): PinState | undefined; + getAllPinStates(): PinState[]; + isConnected(): boolean; + + // Event emitter methods on(event: 'connected', listener: () => void): this; + on(event: 'disconnected', listener: () => void): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'initialized', listener: () => void): this; + on(event: 'pinInitialized', listener: (pinState: PinState) => void): this; + on(event: 'pinChanged', listener: (pinChange: PinChange) => void): this; + on(event: string, listener: (...args: unknown[]) => void): this; + emit(event: 'connected'): boolean; + emit(event: 'disconnected'): boolean; + emit(event: 'error', error: Error): boolean; + emit(event: 'initialized'): boolean; + emit(event: 'pinInitialized', pinState: PinState): boolean; + emit(event: 'pinChanged', pinChange: PinChange): boolean; + emit(event: string, ...args: unknown[]): boolean; +} + +export type BedHardwareType = 'serial' | 'mqtt'; \ No newline at end of file From fd8cacd62bfcde073c1be10113757a472c6603b5 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 15:21:32 +0700 Subject: [PATCH 05/14] feat: Refactor BedHardware to use a singleton instance and remove serial implementation --- app/api/sensors/route.ts | 36 ++++++---------- services/BedHardware.ts | 41 +++++-------------- ...erial.ts => BedHardwareSerial.ts.disabled} | 0 tsconfig.json | 2 +- 4 files changed, 25 insertions(+), 54 deletions(-) rename services/{BedHardwareSerial.ts => BedHardwareSerial.ts.disabled} (100%) diff --git a/app/api/sensors/route.ts b/app/api/sensors/route.ts index 77727c8..5371c5e 100644 --- a/app/api/sensors/route.ts +++ b/app/api/sensors/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { BedHardware, PinState, PinChange } from '@/services/BedHardware'; +import { bedHardwareInstance, PinState, PinChange } from '@/services/BedHardware'; import { SensorDataStorage, SensorDataPoint } from '@/services/SensorDataStorage'; import { SensorConfig } from '@/types/sensor'; @@ -44,7 +44,6 @@ SENSOR_CONFIG.forEach(sensor => { } }); -let bedHardware: BedHardware | null = null; const sensorDataStorage = SensorDataStorage.getInstance(); const sensorData: Record - port.includes('ttyUSB') || port.includes('ttyACM') || port.includes('cu.usbmodem') - ) || '/dev/ttyUSB0'; // Default fallback - - bedHardware = new BedHardware(portPath, 9600); - - bedHardware.on('connected', () => { + bedHardwareInstance.on('connected', () => { console.log('BedHardware connected'); isHardwareConnected = true; }); - bedHardware.on('disconnected', () => { + bedHardwareInstance.on('disconnected', () => { console.log('BedHardware disconnected'); isHardwareConnected = false; }); - bedHardware.on('pinChanged', (change: PinChange) => { + bedHardwareInstance.on('pinChanged', (change: PinChange) => { updateSensorFromPin(change.pin, change.currentState); }); - bedHardware.on('pinInitialized', (pinState: PinState) => { + bedHardwareInstance.on('pinInitialized', (pinState: PinState) => { updateSensorFromPin(pinState.pin, pinState.state); }); - bedHardware.on('error', (error) => { + bedHardwareInstance.on('error', (error) => { console.error('BedHardware error:', error); isHardwareConnected = false; }); - await bedHardware.connect(); + await bedHardwareInstance.connect(); } catch (error) { console.warn('Failed to connect to hardware, using mock data:', error); isHardwareConnected = false; @@ -283,7 +274,7 @@ export async function GET() { } // Initialize hardware if not already done - if (!bedHardware) { + if (!isHardwareConnected) { await initializeHardware(); } @@ -291,8 +282,8 @@ export async function GET() { updateMockSensorData(); // If hardware is connected, get current pin states - if (isHardwareConnected && bedHardware) { - const pinStates = bedHardware.getAllPinStates(); + if (isHardwareConnected) { + const pinStates = bedHardwareInstance.getAllPinStates(); for (const pinState of pinStates) { await updateSensorFromPin(pinState.pin, pinState.state); } @@ -332,9 +323,8 @@ export async function POST(request: NextRequest) { } if (body.action === 'disconnect') { - if (bedHardware) { - await bedHardware.disconnect(); - bedHardware = null; + if (bedHardwareInstance) { + await bedHardwareInstance.disconnect(); isHardwareConnected = false; } return NextResponse.json({ diff --git a/services/BedHardware.ts b/services/BedHardware.ts index 8e00e5c..20f0121 100644 --- a/services/BedHardware.ts +++ b/services/BedHardware.ts @@ -1,19 +1,15 @@ import { EventEmitter } from 'events'; import { IBedHardware, PinState, PinChange, BedHardwareConfig } from '../types/bedhardware'; -import { BedHardwareSerial } from './BedHardwareSerial'; import { BedHardwareMQTT } from './BedHardwareMQTT'; /** - * BedHardware - Factory class for creating bed hardware implementations + * BedHardware - MQTT-based bed hardware implementation * * Usage: - * // MQTT (connects to broker.hivemq.com with base topic /Jtkcp2N/pressurebed/) + * MQTT (connects to broker.hivemq.com with base topic /Jtkcp2N/pressurebed/) * const hardware = BedHardware.createSimpleMQTT(); * - * // Serial - * const hardware = BedHardware.createSerial('COM3', 9600); - * - * // With custom topics + * With custom topics * const hardware = BedHardware.createMQTT({ * topics: { * pinState: '/custom/pin/state', @@ -24,18 +20,10 @@ import { BedHardwareMQTT } from './BedHardwareMQTT'; */ export class BedHardware extends EventEmitter implements IBedHardware { private implementation: IBedHardware; - constructor(config: BedHardwareConfig) { super(); - if (config.type === 'serial') { - if (!config.serial?.portPath) { - throw new Error('Serial port path is required for serial connection'); - } - this.implementation = new BedHardwareSerial( - config.serial.portPath, - config.serial.baudRate || 9600 ); - } else if (config.type === 'mqtt') { + if (config.type === 'mqtt') { this.implementation = new BedHardwareMQTT({ topics: config.mqtt?.topics }); @@ -88,14 +76,8 @@ export class BedHardware extends EventEmitter implements IBedHardware { isConnected(): boolean { return this.implementation.isConnected(); } - // Static factory methods for convenience - static createSerial(portPath: string, baudRate: number = 9600): BedHardware { - return new BedHardware({ - type: 'serial', - serial: { portPath, baudRate } - }); - } static createMQTT(config?: { + static createMQTT(config?: { topics?: { pinState: string; pinChange: string; @@ -114,13 +96,12 @@ export class BedHardware extends EventEmitter implements IBedHardware { mqtt: {} }); } - - // Static method to list available serial ports (for serial implementation) - static async listSerialPorts(): Promise { - return BedHardwareSerial.listPorts(); - } } -// Export all classes for direct access if needed -export { BedHardwareSerial, BedHardwareMQTT }; +// Create and export a default MQTT instance +export const bedHardwareInstance = new BedHardware({ + type: 'mqtt', + mqtt: {} +}); + export * from '../types/bedhardware'; \ No newline at end of file diff --git a/services/BedHardwareSerial.ts b/services/BedHardwareSerial.ts.disabled similarity index 100% rename from services/BedHardwareSerial.ts rename to services/BedHardwareSerial.ts.disabled diff --git a/tsconfig.json b/tsconfig.json index 63a244e..bc97aa1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "services/BedHardwareSerial.ts.disabled"], "exclude": ["node_modules"] } From 738ae59c69eb7c5ad0c5b9458322c89257fc736f Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 15:35:24 +0700 Subject: [PATCH 06/14] feat: Remove test scenario functionality and update related components for real MQTT data usage --- app/api/test-scenarios/route.ts | 201 +----------------- components/bed-pressure/AlarmDashboard.tsx | 67 +----- components/bed-pressure/SensorDetailModal.tsx | 5 +- services/SensorDataStorage.ts | 33 +-- stores/bedPressureStore.ts | 20 +- 5 files changed, 36 insertions(+), 290 deletions(-) diff --git a/app/api/test-scenarios/route.ts b/app/api/test-scenarios/route.ts index d4a4e8b..2854b52 100644 --- a/app/api/test-scenarios/route.ts +++ b/app/api/test-scenarios/route.ts @@ -1,197 +1,16 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; -interface TestSensorData { - sensorId: string; - value: number; - status: string; - warningStartTime?: number; -} - -// Test scenarios for the bed pressure monitoring system -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { scenario } = body; - - let testData: TestSensorData[] = []; - - switch (scenario) { - case 'normal': - testData = generateNormalScenario(); - break; - case 'warning': - testData = generateWarningScenario(); - break; - case 'alarm': - testData = generateAlarmScenario(); - break; - case 'escalation': - testData = generateEscalationScenario(); - break; - case 'mixed': - testData = generateMixedScenario(); - break; - default: - return NextResponse.json({ - success: false, - error: 'Invalid scenario. Use: normal, warning, alarm, escalation, or mixed' - }, { status: 400 }); - } - - return NextResponse.json({ - success: true, - scenario, - message: `Generated ${scenario} test scenario`, - testData, - timestamp: new Date().toISOString() - }); - - } catch (error) { - console.error('Test scenario API error:', error); - return NextResponse.json({ - success: false, - error: 'Failed to generate test scenario' - }, { status: 500 }); - } -} - -function generateNormalScenario() { - return [ - { sensorId: 'head-1', value: 1200, status: 'normal' }, - { sensorId: 'head-2', value: 1150, status: 'normal' }, - { sensorId: 'shoulder-1', value: 1800, status: 'normal' }, - { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, - { sensorId: 'back-1', value: 2000, status: 'normal' }, - { sensorId: 'back-2', value: 2100, status: 'normal' }, - { sensorId: 'back-3', value: 1950, status: 'normal' }, - { sensorId: 'hip-1', value: 1900, status: 'normal' }, - { sensorId: 'hip-2', value: 2000, status: 'normal' }, - { sensorId: 'hip-3', value: 1850, status: 'normal' }, - { sensorId: 'thigh-1', value: 1600, status: 'normal' }, - { sensorId: 'thigh-2', value: 1550, status: 'normal' }, - { sensorId: 'calf-1', value: 1400, status: 'normal' }, - { sensorId: 'calf-2', value: 1350, status: 'normal' }, - { sensorId: 'feet-1', value: 1200, status: 'normal' }, - { sensorId: 'feet-2', value: 1150, status: 'normal' } - ]; -} - -function generateWarningScenario() { - return [ - { sensorId: 'head-1', value: 3100, status: 'warning' }, // Above warning threshold - { sensorId: 'head-2', value: 1150, status: 'normal' }, - { sensorId: 'shoulder-1', value: 2900, status: 'warning' }, // Above warning threshold - { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, - { sensorId: 'back-1', value: 2600, status: 'warning' }, // Above warning threshold - { sensorId: 'back-2', value: 2100, status: 'normal' }, - { sensorId: 'back-3', value: 1950, status: 'normal' }, - { sensorId: 'hip-1', value: 1900, status: 'normal' }, - { sensorId: 'hip-2', value: 2300, status: 'warning' }, // Above warning threshold - { sensorId: 'hip-3', value: 1850, status: 'normal' }, - { sensorId: 'thigh-1', value: 1600, status: 'normal' }, - { sensorId: 'thigh-2', value: 1550, status: 'normal' }, - { sensorId: 'calf-1', value: 1400, status: 'normal' }, - { sensorId: 'calf-2', value: 1350, status: 'normal' }, - { sensorId: 'feet-1', value: 1200, status: 'normal' }, - { sensorId: 'feet-2', value: 1150, status: 'normal' } - ]; -} - -function generateAlarmScenario() { - return [ - { sensorId: 'head-1', value: 3600, status: 'alarm' }, // Above alarm threshold - { sensorId: 'head-2', value: 3550, status: 'alarm' }, // Above alarm threshold - { sensorId: 'shoulder-1', value: 3300, status: 'alarm' }, // Above alarm threshold - { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, - { sensorId: 'back-1', value: 3100, status: 'alarm' }, // Above alarm threshold - { sensorId: 'back-2', value: 2100, status: 'normal' }, - { sensorId: 'back-3', value: 1950, status: 'normal' }, - { sensorId: 'hip-1', value: 2900, status: 'alarm' }, // Above alarm threshold - { sensorId: 'hip-2', value: 2000, status: 'normal' }, - { sensorId: 'hip-3', value: 1850, status: 'normal' }, - { sensorId: 'thigh-1', value: 1600, status: 'normal' }, - { sensorId: 'thigh-2', value: 1550, status: 'normal' }, - { sensorId: 'calf-1', value: 1400, status: 'normal' }, - { sensorId: 'calf-2', value: 1350, status: 'normal' }, - { sensorId: 'feet-1', value: 1200, status: 'normal' }, - { sensorId: 'feet-2', value: 1150, status: 'normal' } - ]; -} - -function generateEscalationScenario() { - // This scenario would simulate sensors that have been in warning state for a while - // and are about to escalate to alarm - const now = Date.now(); - const warningStartTime = now - 25000; // Started warning 25 seconds ago (close to 30s threshold) - - return [ - { sensorId: 'head-1', value: 3100, status: 'warning', warningStartTime }, - { sensorId: 'head-2', value: 1150, status: 'normal' }, - { sensorId: 'shoulder-1', value: 2900, status: 'warning', warningStartTime: now - 40000 }, // Close to 45s threshold - { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, - { sensorId: 'back-1', value: 2600, status: 'warning', warningStartTime: now - 55000 }, // Close to 60s threshold - { sensorId: 'back-2', value: 2100, status: 'normal' }, - { sensorId: 'back-3', value: 1950, status: 'normal' }, - { sensorId: 'hip-1', value: 1900, status: 'normal' }, - { sensorId: 'hip-2', value: 2300, status: 'warning', warningStartTime: now - 85000 }, // Close to 90s threshold - { sensorId: 'hip-3', value: 1850, status: 'normal' }, - { sensorId: 'thigh-1', value: 2100, status: 'warning', warningStartTime: now - 115000 }, // Close to 120s threshold - { sensorId: 'thigh-2', value: 1550, status: 'normal' }, - { sensorId: 'calf-1', value: 1400, status: 'normal' }, - { sensorId: 'calf-2', value: 1350, status: 'normal' }, - { sensorId: 'feet-1', value: 1200, status: 'normal' }, - { sensorId: 'feet-2', value: 1150, status: 'normal' } - ]; -} - -function generateMixedScenario() { - const now = Date.now(); - - return [ - { sensorId: 'head-1', value: 3600, status: 'alarm' }, // Immediate alarm - { sensorId: 'head-2', value: 3100, status: 'warning', warningStartTime: now - 10000 }, // Recent warning - { sensorId: 'shoulder-1', value: 2900, status: 'warning', warningStartTime: now - 40000 }, // Long warning - { sensorId: 'shoulder-2', value: 1750, status: 'normal' }, - { sensorId: 'back-1', value: 3100, status: 'alarm' }, // Immediate alarm - { sensorId: 'back-2', value: 2600, status: 'warning', warningStartTime: now - 30000 }, // Warning close to escalation - { sensorId: 'back-3', value: 1950, status: 'normal' }, - { sensorId: 'hip-1', value: 1900, status: 'normal' }, - { sensorId: 'hip-2', value: 2900, status: 'alarm' }, // Immediate alarm - { sensorId: 'hip-3', value: 2300, status: 'warning', warningStartTime: now - 60000 }, // Warning - { sensorId: 'thigh-1', value: 1600, status: 'normal' }, - { sensorId: 'thigh-2', value: 2100, status: 'warning', warningStartTime: now - 90000 }, // Warning - { sensorId: 'calf-1', value: 2300, status: 'alarm' }, // Immediate alarm - { sensorId: 'calf-2', value: 1900, status: 'warning', warningStartTime: now - 120000 }, // Warning - { sensorId: 'feet-1', value: 1200, status: 'normal' }, - { sensorId: 'feet-2', value: 1900, status: 'alarm' } // Immediate alarm - ]; +// API endpoint disabled - only real MQTT data is used +export async function POST() { + return NextResponse.json({ + success: false, + error: 'Test scenarios have been removed. System uses real MQTT data only.' + }, { status: 410 }); } export async function GET() { return NextResponse.json({ - success: true, - availableScenarios: [ - { - name: 'normal', - description: 'All sensors operating within normal ranges' - }, - { - name: 'warning', - description: 'Several sensors in warning state (above warning threshold)' - }, - { - name: 'alarm', - description: 'Multiple sensors in immediate alarm state (above alarm threshold)' - }, - { - name: 'escalation', - description: 'Sensors in warning state close to escalating to alarm after delay' - }, - { - name: 'mixed', - description: 'Mixed scenario with normal, warning, and alarm states' - } - ], - usage: 'POST to /api/test-scenarios with body: { "scenario": "scenario_name" }' - }); + success: false, + error: 'Test scenarios have been removed. System uses real MQTT data only.' + }, { status: 410 }); } \ No newline at end of file diff --git a/components/bed-pressure/AlarmDashboard.tsx b/components/bed-pressure/AlarmDashboard.tsx index 2aa72e3..8ca5698 100644 --- a/components/bed-pressure/AlarmDashboard.tsx +++ b/components/bed-pressure/AlarmDashboard.tsx @@ -1,7 +1,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" -import { AlertTriangle, VolumeX, CheckCircle, Bell, TestTube } from "lucide-react" +import { AlertTriangle, VolumeX, CheckCircle, Bell } from "lucide-react" import { useBedPressureStore } from "@/stores/bedPressureStore" import { useEffect, useState } from "react" @@ -12,35 +12,13 @@ export function AlarmDashboard() { acknowledgeAlarm, silenceAllAlarms } = useBedPressureStore() - const [unsilencedAlarms, setUnsilencedAlarms] = useState(0) - const [testScenario, setTestScenario] = useState('') - // Update alarm counts useEffect(() => { const unsilenced = activeAlarms.filter(alarm => !alarm.silenced).length setUnsilencedAlarms(unsilenced) }, [activeAlarms]) - const handleTestScenario = async (scenario: string) => { - try { - setTestScenario(scenario) - const response = await fetch('/api/test-scenarios', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ scenario }) - }) - - if (response.ok) { - console.log(`Applied ${scenario} test scenario`) - } - } catch (error) { - console.error('Failed to apply test scenario:', error) - } finally { - setTimeout(() => setTestScenario(''), 2000) - } - } - const getSystemStatus = () => { const alarmCount = activeAlarms.filter(a => a.type === 'alarm' && !a.silenced).length const warningCount = activeAlarms.filter(a => a.type === 'warning' && !a.silenced).length @@ -71,9 +49,8 @@ export function AlarmDashboard() {
SYSTEM STATUS: {systemStatus.status} - -

- {isConnected ? 'Hardware Connected' : 'Using Mock Data'} • +

+ {isConnected ? 'Hardware Connected' : 'Hardware Offline'} • {activeAlarms.length} Active Alarms • {unsilencedAlarms} Unsilenced

@@ -149,45 +126,9 @@ export function AlarmDashboard() {

{activeAlarms.filter(a => a.acknowledged).length}

- - +
- {/* Test Scenarios */} - - - - - Test Scenarios - - - -
- {[ - { name: 'normal', label: 'Normal', color: 'bg-green-100 text-green-700 hover:bg-green-200' }, - { name: 'warning', label: 'Warning', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200' }, - { name: 'alarm', label: 'Alarm', color: 'bg-red-100 text-red-700 hover:bg-red-200' }, - { name: 'escalation', label: 'Escalation', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200' }, - { name: 'mixed', label: 'Mixed', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200' } - ].map(scenario => ( - - ))} -
-

- Click to simulate different alarm scenarios for testing -

-
-
- {/* Recent Alarm Activity */} diff --git a/components/bed-pressure/SensorDetailModal.tsx b/components/bed-pressure/SensorDetailModal.tsx index 04752b3..f438241 100644 --- a/components/bed-pressure/SensorDetailModal.tsx +++ b/components/bed-pressure/SensorDetailModal.tsx @@ -143,10 +143,9 @@ export function SensorDetailModal() {
-
-

+

Showing data for the last {getTimespanLabel(selectedTimespan)} - {sensor.source === 'hardware' ? ' (Real sensor data)' : ' (Mock data)'} + {sensor.source === 'hardware' ? ' (Real sensor data)' : ' (Hardware offline - no data)'}

diff --git a/services/SensorDataStorage.ts b/services/SensorDataStorage.ts index d5a5013..a8c3e69 100644 --- a/services/SensorDataStorage.ts +++ b/services/SensorDataStorage.ts @@ -9,7 +9,7 @@ export interface SensorDataPoint { value: number; // Changed from pressure to value (0-4095) timestamp: number; time: string; - source: 'hardware' | 'mock'; + source: 'hardware'; pin?: number; digitalState?: number; } @@ -93,39 +93,24 @@ export class SensorDataStorage { async forceSave() { await this.saveData(); - } - - // Generate time series data for a specific timespan + } // Generate time series data for a specific timespan generateTimeSeriesData( sensorData: SensorDataPoint[], timespan: number = 24 * 60 * 60 * 1000 ): Array<{ time: string; timestamp: number; value: number }> { if (sensorData.length === 0) { - // Generate mock data if no real data exists - return this.generateMockTimeSeriesData(timespan); + // Return empty array if no real data exists + return []; } - return sensorData.map(point => ({ + // Filter data by timespan + const cutoffTime = Date.now() - timespan; + const filteredData = sensorData.filter(point => point.timestamp > cutoffTime); + + return filteredData.map(point => ({ time: point.time, timestamp: point.timestamp, value: point.value })); } - - private generateMockTimeSeriesData(timespan: number): Array<{ time: string; timestamp: number; value: number }> { - const data = []; - const now = Date.now(); - const interval = Math.max(1000, timespan / 288); // At least 1 second intervals, up to 288 points - - for (let i = timespan; i >= 0; i -= interval) { - const timestamp = now - i; - const time = new Date(timestamp); - data.push({ - time: time.toLocaleTimeString("en-US", { hour12: false }), - timestamp: timestamp, - value: Math.floor(Math.random() * 4096 + Math.sin(i / 60000) * 500 + 2000), // 0-4095 range - }); - } - return data; - } } \ No newline at end of file diff --git a/stores/bedPressureStore.ts b/stores/bedPressureStore.ts index 46f7246..72a94a4 100644 --- a/stores/bedPressureStore.ts +++ b/stores/bedPressureStore.ts @@ -11,7 +11,7 @@ export interface SensorData { currentValue: number; // Changed from currentPressure to currentValue (0-4095) data: Array<{ time: string; timestamp: number; value: number }>; // Changed from pressure to value status: string; - source?: 'hardware' | 'mock'; + source?: 'hardware'; pin?: number; digitalState?: number; warningThreshold?: number; @@ -132,8 +132,7 @@ export const useBedPressureStore = create((set, get) => ({ console.error('Failed to fetch sensor config:', error); } }, - - fetchSensorData: async () => { + fetchSensorData: async () => { try { const response = await fetch('/api/sensors'); const data = await response.json(); @@ -143,12 +142,13 @@ export const useBedPressureStore = create((set, get) => ({ const updated = { ...sensorData }; const newAlerts: Alert[] = []; + // Only process hardware sensors, no mock data data.sensors.forEach((sensor: { id: string; label: string; zone: string; - value: number; // Changed from pressure to value - source: 'hardware' | 'mock'; + value: number; + source: 'hardware'; pin?: number; digitalState?: number; warningThreshold?: number; @@ -156,6 +156,10 @@ export const useBedPressureStore = create((set, get) => ({ warningDelayMs?: number; status?: string; }) => { + // Only process hardware data + if (sensor.source !== 'hardware') { + return; + } const currentSensor = updated[sensor.id]; const newValue = sensor.value; const config = sensorConfig.find(s => s.id === sensor.id); @@ -224,9 +228,7 @@ export const useBedPressureStore = create((set, get) => ({ } else { warningStartTime = undefined; // Clear warning timer alarmManager.clearAlarm(sensor.id); // Clear any existing alarms - } - - // Update sensor data + } // Update sensor data updated[sensor.id] = { ...sensorConfig.find(s => s.id === sensor.id), id: sensor.id, @@ -236,7 +238,7 @@ export const useBedPressureStore = create((set, get) => ({ label: sensor.label, currentValue: newValue, data: currentSensor ? [ - ...currentSensor.data.slice(1), + ...currentSensor.data.slice(-100), // Keep only last 100 points { time: new Date().toLocaleTimeString("en-US", { hour12: false }), timestamp: Date.now(), From 9eb57f675bc8abde4eb3286e078aeb84f3c37da3 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 15:45:47 +0700 Subject: [PATCH 07/14] feat: Update averageValue calculation to consider only hardware sensors and improve error handling for sensor data fetching --- stores/bedPressureStore.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/stores/bedPressureStore.ts b/stores/bedPressureStore.ts index 72a94a4..ef52c9f 100644 --- a/stores/bedPressureStore.ts +++ b/stores/bedPressureStore.ts @@ -103,13 +103,14 @@ export const useBedPressureStore = create((set, get) => ({ setSelectedTimespan: (timespan) => set({ selectedTimespan: timespan }), setActiveAlarms: (alarms) => set({ activeAlarms: alarms }), - - // Computed values + // Computed values averageValue: () => { const { sensorData } = get(); const sensors = Object.values(sensorData) as SensorData[]; - if (sensors.length === 0) return 0; - return sensors.reduce((sum: number, sensor: SensorData) => sum + sensor.currentValue, 0) / sensors.length; + // Only calculate average for sensors with hardware data + const hardwareSensors = sensors.filter(sensor => sensor.source === 'hardware'); + if (hardwareSensors.length === 0) return 0; + return hardwareSensors.reduce((sum: number, sensor: SensorData) => sum + sensor.currentValue, 0) / hardwareSensors.length; }, criticalSensors: () => { @@ -258,20 +259,29 @@ export const useBedPressureStore = create((set, get) => ({ warningDelayMs, warningStartTime }; - }); - - set({ + }); set({ sensorData: updated, - isConnected: data.connected, + isConnected: data.connected || false, activeAlarms: alarmManager.getActiveAlarms() }); if (newAlerts.length > 0) { get().addAlerts(newAlerts); } - } - } catch (error) { + } else { // No sensor data available - set as disconnected + const { alarmManager } = get(); + set({ + isConnected: false, + activeAlarms: alarmManager.getActiveAlarms() + }); + } } catch (error) { console.error('Failed to fetch sensor data:', error); + // Set as disconnected on error + const { alarmManager } = get(); + set({ + isConnected: false, + activeAlarms: alarmManager.getActiveAlarms() + }); } }, From b76d6b99eeff8fbe2ef6e748fb0a04ff60ab4132 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 18:00:46 +0700 Subject: [PATCH 08/14] Initial commit (via bun create) --- .gitignore | 42 ++++++++++++++++++++ README.md | 15 ++++++++ package.json | 15 ++++++++ src/index.ts | 7 ++++ tsconfig.json | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87e5610 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..688c87e --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Elysia with Bun runtime + +## Getting Started +To get started with this template, simply paste this command into your terminal: +```bash +bun create elysia ./elysia-example +``` + +## Development +To start the development server run: +```bash +bun run dev +``` + +Open http://localhost:3000/ with your browser to see the result. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1e60d34 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "elysia", + "version": "1.0.50", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "bun run --watch src/index.ts" + }, + "dependencies": { + "elysia": "latest" + }, + "devDependencies": { + "bun-types": "latest" + }, + "module": "src/index.js" +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9c1f7a1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +import { Elysia } from "elysia"; + +const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); + +console.log( + `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` +); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ca2350 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,103 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ES2022", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} From a767dc3635a6eba3da3f33f1ce9d39041f8175e9 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 18:24:54 +0700 Subject: [PATCH 09/14] feat: restructure backend with Elysia framework and add MQTT adapter - Updated .gitignore to exclude generated files and database - Modified package.json to change dev script and add new dependencies - Removed src/index.ts and created app.ts for Elysia server initialization - Added environment variable configuration in config/env.ts - Implemented MQTT adapter in adapter/mqtt.ts for message handling - Created Prisma client in prisma/client.ts and defined schema in prisma/schema.prisma - Added seeding script in prisma/seed.ts for measurement points - Established logging utility in utils/logger.ts for structured logging - Created bed router in routes/bed.ts for handling bed-related routes --- .env | 7 ++ .gitignore | 9 +- adapter/mqtt.ts | 160 +++++++++++++++++++++++++++++++ app.ts | 39 ++++++++ bun.lock | 122 ++++++++++++++++++++++++ config/env.ts | 58 ++++++++++++ package.json | 14 ++- prisma/client.ts | 5 + prisma/schema.prisma | 81 ++++++++++++++++ prisma/seed.ts | 219 +++++++++++++++++++++++++++++++++++++++++++ routes/bed.ts | 5 + src/index.ts | 7 -- tsconfig.json | 6 +- utils/logger.ts | 83 ++++++++++++++++ 14 files changed, 801 insertions(+), 14 deletions(-) create mode 100644 .env create mode 100644 adapter/mqtt.ts create mode 100644 app.ts create mode 100644 bun.lock create mode 100644 config/env.ts create mode 100644 prisma/client.ts create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 routes/bed.ts delete mode 100644 src/index.ts create mode 100644 utils/logger.ts diff --git a/.env b/.env new file mode 100644 index 0000000..45b2f31 --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +# Environment variables declared in this file are automatically made available to Prisma. +# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +DATABASE_URL="file:./data.db" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 87e5610..5d992ce 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,11 @@ yarn-error.log* **/*.tgz **/*.log package-lock.json -**/*.bun \ No newline at end of file +**/*.bun +/generated/prisma + +/generated/prisma + +/generated/prisma + +/data.db \ No newline at end of file diff --git a/adapter/mqtt.ts b/adapter/mqtt.ts new file mode 100644 index 0000000..93cf1e5 --- /dev/null +++ b/adapter/mqtt.ts @@ -0,0 +1,160 @@ +import mqtt, { type MqttClient } from "mqtt"; +import type { QoS } from "mqtt-packet"; + +export interface MQTTConfig { + host: string; + port: number; + username?: string; + password?: string; +} + +interface MQTTSubscription { + topic: string; + callback: (topic: string, message: string) => void; +} + +export interface MQTTMessage { + topic: string; + payload: string; + qos?: QoS; + retain?: boolean; +} + +class MQTT { + private client: MqttClient | null = null; + private config: MQTTConfig | null = null; + private readonly subscriptions: MQTTSubscription[] = []; + private isConnected: boolean = false; + private keepAliveTimer: NodeJS.Timeout | null = null; + + async keepAlive(): Promise { + if (this.isConnected) { + return; + } + console.log("MQTT client is not connected, attempting to reconnect..."); + await this.connectMQTT(); + } + + async initialize(config: MQTTConfig): Promise { + this.config = config; + this.connectMQTT(); + + // Start keep-alive timer + this.keepAliveTimer = setInterval(() => { + this.keepAlive(); + }, 5000); // Run every 5 seconds + } + + async connectMQTT(): Promise { + if (!this.config) { + throw new Error("MQTT configuration is not set."); + } + try { + this.client = mqtt.connect(`mqtt://${this.config.host}:${this.config.port}`, { + username: this.config?.username, + password: this.config?.password + }); + await this.setupHandler(); + } catch (error) { + console.error("Failed to connect to MQTT broker:", error); + } + } + + async publish(message: MQTTMessage): Promise { + if (!this.client || !this.isConnected) { + console.error("MQTT client is not connected, cannot publish message."); + return; + } + + const { topic, payload, qos = 0, retain = false } = message; + try { + await this.client.publishAsync(topic, payload, { qos, retain }); + } catch (error) { + console.error(`Failed to publish message to ${topic}:`, error); + } + } + + async subscribe(topic: string, callback: (topic: string, message: string) => void): Promise { + console.log(`Subscribing to topic: ${topic}`); + const subscription: MQTTSubscription = { topic, callback }; + this.subscriptions.push(subscription); + + if (this.client && this.isConnected) { + try { + await this.client.subscribeAsync(topic); + } catch (error) { + console.error(`Failed to subscribe to ${topic}:`, error); + } + } + } + + async setupHandler(): Promise { + if (!this.client) { + console.error("MQTT client is not initialized."); + return; + } + + this.client.on("connect", this.onConnect.bind(this)); + this.client.on("message", this.onMessage.bind(this)); + this.client.on("disconnect", this.onDisconnect.bind(this)); + } + + async disconnect(): Promise { + if (this.keepAliveTimer) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = null; + } + + if (this.client) { + await this.client.endAsync(); + this.client = null; + } + this.isConnected = false; + } + + private async onConnect(): Promise { + console.log("MQTT connected successfully."); + for (const sub of this.subscriptions) { + if (this.client) { + try { + await this.client.subscribeAsync(sub.topic); + } catch (error) { + console.error(`Failed to subscribe to ${sub.topic}:`, error); + } + } + } + this.isConnected = true; + } + + private onDisconnect(): void { + console.log("MQTT disconnected."); + this.isConnected = false; + } + + private onMessage(topic: string, message: Buffer): void { + const msg = message.toString(); + console.log(`Received message on topic ${topic}: ${msg}`); + this.subscriptions.forEach(sub => { + if (this.matchTopic(sub.topic, topic)) { + sub.callback(topic, msg); + } + }); + } + + private matchTopic(subscriptionTopic: string, publishTopic: string): boolean { + // Exact match + if (subscriptionTopic === publishTopic) { + return true; + } + + // Convert MQTT wildcards to regex + const regexPattern = subscriptionTopic + .replace(/\+/g, '[^/]+') // + matches single level + .replace(/#$/, '.*'); // # matches multi-level (only at end) + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(publishTopic); + } +} + +export default MQTT; \ No newline at end of file diff --git a/app.ts b/app.ts new file mode 100644 index 0000000..d197cac --- /dev/null +++ b/app.ts @@ -0,0 +1,39 @@ +import { Elysia } from 'elysia'; +import { cors } from '@elysiajs/cors'; + +import { createTopicLogger } from '~/utils/logger'; +import env from './config/env'; + +async function initialize() { + const logger = createTopicLogger({ topic: 'Initializer' }); + try { + await initializeElysia(); + } catch (error) { + logger.error(`Initialization error: ${error}`); + process.exit(1); + } + +} + +async function initializeElysia() { + const logger = createTopicLogger({ topic: 'Elysia' }); + const PORT = env.BACKEND_PORT; + + // Instantiate and configure Elysia + const app = new Elysia() + // Logging Messages + .onStart(() => { + logger.info(`API server starting on port ${PORT}`); + }) + .onStop(() => { + logger.info('API server shutting down'); + }) + + // Core Components + .use(cors()) + + // Start the server + app.listen(PORT); +} + +initialize(); \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..2e8e5ca --- /dev/null +++ b/bun.lock @@ -0,0 +1,122 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "elysia", + "dependencies": { + "@elysiajs/cors": "^1.3.3", + "elysia": "latest", + "envalid": "^8.0.0", + "winston": "^3.17.0", + }, + "devDependencies": { + "bun-types": "^1.2.17", + }, + }, + }, + "packages": { + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + + "@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="], + + "@elysiajs/cors": ["@elysiajs/cors@1.3.3", "", { "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-mYIU6PyMM6xIJuj7d27Vt0/wuzVKIEnFPjcvlkyd7t/m9xspAG37cwNjFxVOnyvY43oOd2I/oW2DB85utXpA2Q=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.35", "", {}, "sha512-C6ypdODf2VZkgRT6sFM8E1F8vR+HcffniX0Kp8MsU8PIfrlXbNCBz0jzj17GjdmjTx1OtZzdH8+iALL21UjF5A=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="], + + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], + + "color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="], + + "color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "colorspace": ["colorspace@1.1.4", "", { "dependencies": { "color": "^3.1.3", "text-hex": "1.0.x" } }, "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w=="], + + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "elysia": ["elysia@1.3.5", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-XVIKXlKFwUT7Sta8GY+wO5reD9I0rqAEtaz1Z71UgJb61csYt8Q3W9al8rtL5RgumuRR8e3DNdzlUN9GkC4KDw=="], + + "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], + + "envalid": ["envalid@8.0.0", "", { "dependencies": { "tslib": "2.6.2" } }, "sha512-PGeYJnJB5naN0ME6SH8nFcDj9HVbLpYIfg1p5lAyM9T4cH2lwtu2fLbozC/bq+HUUOIFxhX/LP0/GmlqPHT4tQ=="], + + "exact-mirror": ["exact-mirror@0.1.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-wFCPCDLmHbKGUb8TOi/IS7jLsgR8WVDGtDK3CzcB4Guf/weq7G+I+DkXiRSZfbemBFOxOINKpraM6ml78vo8Zw=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="], + + "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], + + "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strtok3": ["strtok3@10.3.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-3JWEZM6mfix/GCJBBUrkA8p2Id2pBkyTkVCJKto55w080QBKZ+8R171fGrbiSp+yMO/u6F8/yUh7K4V9K+YCnw=="], + + "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + + "token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="], + + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + + "tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "uint8array-extras": ["uint8array-extras@1.4.0", "", {}, "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ=="], + + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="], + + "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + } +} diff --git a/config/env.ts b/config/env.ts new file mode 100644 index 0000000..213028c --- /dev/null +++ b/config/env.ts @@ -0,0 +1,58 @@ +import { cleanEnv, str, bool, port, url, host } from "envalid"; + +const env = cleanEnv(process.env, { + BACKEND_PORT: port({ + desc: "The port for the backend server", + default: 3000, + }), + + DATABASE_URL: url({ + desc: "The URL for the database connection", + }), + + // Redis configuration + REDIS_HOST: host({ + desc: "The host for the Redis server", + default: "localhost", + }), + REDIS_PORT: port({ + desc: "The port for the Redis server", + default: 6379, + }), + REDIS_PASSWORD: str({ + desc: "The password for the Redis server", + }), + + // S3 configuration + S3_ENDPOINT: host({ + desc: "The endpoint for the S3 service", + default: "localhost", + }), + S3_BUCKET: str({ + desc: "The name of the S3 bucket", + default: "my-bucket", + }), + S3_PORT: port({ + desc: "The port for the S3 service", + default: 9000, + }), + S3_USE_SSL: bool({ + desc: "Use SSL for S3 service", + default: false, + }), + S3_ACCESS_KEY: str({ + desc: "Access key for the S3 service", + }), + S3_SECRET_KEY: str({ + desc: "Secret key for the S3 service", + }), + + // Log Level configuration + LOG_LEVEL: str({ + desc: "The log level for the application", + choices: ["debug", "info", "warn", "error", "silent"], + default: "info", + }), +}); + +export default env; \ No newline at end of file diff --git a/package.json b/package.json index 1e60d34..1e60494 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,19 @@ "version": "1.0.50", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "bun run --watch src/index.ts" + "dev": "bun run --watch app.ts", + "start": "bun run app.ts" }, "dependencies": { - "elysia": "latest" + "@elysiajs/cors": "^1.3.3", + "@prisma/client": "^6.10.1", + "elysia": "latest", + "envalid": "^8.0.0", + "winston": "^3.17.0" }, "devDependencies": { - "bun-types": "latest" + "bun-types": "^1.2.17", + "prisma": "^6.10.1" }, "module": "src/index.js" -} \ No newline at end of file +} diff --git a/prisma/client.ts b/prisma/client.ts new file mode 100644 index 0000000..4ef0690 --- /dev/null +++ b/prisma/client.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "~/generated/prisma"; + +const db = new PrismaClient() + +export default db; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..d00de9a --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,81 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + output = "../generated/prisma" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model MeasurementPoint { + id String @id @default(cuid()) + sensorId String @unique // e.g., "head-1", "back-2" + label String // e.g., "Head Left", "Upper Back Center" + zone String // e.g., "head", "back", "shoulders" + x Int // X coordinate on bed layout + y Int // Y coordinate on bed layout + pin Int // Hardware pin number + + // Threshold configuration + warningThreshold Int // Pressure value that triggers warning + alarmThreshold Int // Pressure value that triggers alarm + warningDelayMs Int // Delay before warning escalates to alarm + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + data MeasurementPointData[] + alerts Alert[] +} + +model MeasurementPointData { + id String @id @default(cuid()) + measurementPointId String + + // Sensor reading data + value Int // Analog sensor value (0-4095) + + // Timestamps + timestamp DateTime @default(now()) + time String // Formatted time string + + // Relations + measurementPoint MeasurementPoint @relation(fields: [measurementPointId], references: [id], onDelete: Cascade) + + @@index([measurementPointId, timestamp]) +} + +enum AlertType { + WARNING + ALARM +} + +model Alert { + id String @id @default(cuid()) + measurementPointId String + + // Alert details + type AlertType + value Int // Sensor value that triggered alert + threshold Int // Threshold that was exceeded + + // Alert state + acknowledged Boolean @default(false) + silenced Boolean @default(false) + + // Timing + startTime DateTime @default(now()) + endTime DateTime? + + // Relations + measurementPoint MeasurementPoint @relation(fields: [measurementPointId], references: [id], onDelete: Cascade) + + @@index([measurementPointId, startTime]) + @@index([type, acknowledged]) +} \ No newline at end of file diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..1e33e90 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,219 @@ +import db from "./client"; + +async function main() { + console.log('Seeding MeasurementPoints...'); + + // Delete existing measurement points + await db.measurementPoint.deleteMany(); + + // Create measurement points based on API sensor configuration + const measurementPoints = [ + // Head area - Higher thresholds due to critical nature, faster escalation + { + sensorId: "head-1", + label: "Head Left", + zone: "head", + x: 45, + y: 15, + pin: 2, + warningThreshold: 3000, + alarmThreshold: 3500, + warningDelayMs: 30000 // 30 seconds + }, + { + sensorId: "head-2", + label: "Head Right", + zone: "head", + x: 55, + y: 15, + pin: 3, + warningThreshold: 3000, + alarmThreshold: 3500, + warningDelayMs: 30000 // 30 seconds + }, + + // Shoulder area - Moderate thresholds, medium escalation time + { + sensorId: "shoulder-1", + label: "Left Shoulder", + zone: "shoulders", + x: 35, + y: 25, + pin: 4, + warningThreshold: 2800, + alarmThreshold: 3200, + warningDelayMs: 45000 // 45 seconds + }, + { + sensorId: "shoulder-2", + label: "Right Shoulder", + zone: "shoulders", + x: 65, + y: 25, + pin: 5, + warningThreshold: 2800, + alarmThreshold: 3200, + warningDelayMs: 45000 // 45 seconds + }, + + // Upper back - Moderate thresholds, 1 minute escalation + { + sensorId: "back-1", + label: "Upper Back Left", + zone: "back", + x: 40, + y: 35, + pin: 6, + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000 // 1 minute + }, + { + sensorId: "back-2", + label: "Upper Back Center", + zone: "back", + x: 50, + y: 35, + pin: 7, + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000 // 1 minute + }, + { + sensorId: "back-3", + label: "Upper Back Right", + zone: "back", + x: 60, + y: 35, + pin: 8, + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000 // 1 minute + }, + + // Lower back/Hip area - Lower thresholds, 90 second escalation + { + sensorId: "hip-1", + label: "Left Hip", + zone: "hips", + x: 35, + y: 50, + pin: 9, + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000 // 90 seconds + }, + { + sensorId: "hip-2", + label: "Lower Back", + zone: "hips", + x: 50, + y: 50, + pin: 10, + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000 // 90 seconds + }, + { + sensorId: "hip-3", + label: "Right Hip", + zone: "hips", + x: 65, + y: 50, + pin: 11, + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000 // 90 seconds + }, + + // Thigh area - Lower thresholds, 2 minute escalation + { + sensorId: "thigh-1", + label: "Left Thigh", + zone: "legs", + x: 40, + y: 65, + pin: 12, + warningThreshold: 2000, + alarmThreshold: 2500, + warningDelayMs: 120000 // 2 minutes + }, + { + sensorId: "thigh-2", + label: "Right Thigh", + zone: "legs", + x: 60, + y: 65, + pin: 13, + warningThreshold: 2000, + alarmThreshold: 2500, + warningDelayMs: 120000 // 2 minutes + }, + + // Calf area (mock sensors - no pin) - Lower thresholds, 2.5 minute escalation + { + sensorId: "calf-1", + label: "Left Calf", + zone: "legs", + x: 40, + y: 75, + pin: null, + warningThreshold: 1800, + alarmThreshold: 2200, + warningDelayMs: 150000 // 2.5 minutes + }, + { + sensorId: "calf-2", + label: "Right Calf", + zone: "legs", + x: 60, + y: 75, + pin: null, + warningThreshold: 1800, + alarmThreshold: 2200, + warningDelayMs: 150000 // 2.5 minutes + }, + + // Feet (mock sensors - no pin) - Lowest thresholds, 3 minute escalation + { + sensorId: "feet-1", + label: "Left Foot", + zone: "feet", + x: 45, + y: 85, + pin: null, + warningThreshold: 1500, + alarmThreshold: 1800, + warningDelayMs: 180000 // 3 minutes + }, + { + sensorId: "feet-2", + label: "Right Foot", + zone: "feet", + x: 55, + y: 85, + pin: null, + warningThreshold: 1500, + alarmThreshold: 1800, + warningDelayMs: 180000 // 3 minutes + } + ]; + + // Insert all measurement points + for (const point of measurementPoints) { + await db.measurementPoint.create({ + data: point + }); + } + + console.log(`Created ${measurementPoints.length} measurement points`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await db.$disconnect(); + }); \ No newline at end of file diff --git a/routes/bed.ts b/routes/bed.ts new file mode 100644 index 0000000..a3a495b --- /dev/null +++ b/routes/bed.ts @@ -0,0 +1,5 @@ +import Elysia from "elysia"; + +const bedRouter = new Elysia() + +export default bedRouter; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 9c1f7a1..0000000 --- a/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Elysia } from "elysia"; - -const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); - -console.log( - `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` -); diff --git a/tsconfig.json b/tsconfig.json index 1ca2350..380e6e8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,8 +28,10 @@ "module": "ES2022", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + "paths": { + "~/*": ["./*"] + }, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..953e424 --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,83 @@ +import { createLogger, format, transports, Logger, LeveledLogMethod } from 'winston'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Define log directory +const LOG_DIR = path.join(process.cwd(), 'logs'); + +// Ensure log directory exists +if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); +} + +// Interface for topic logger options +interface TopicLoggerOptions { + topic: string; + level?: string; +} + +// Create the base logger +const createBaseLogger = (level: string = 'debug') => { + return createLogger({ + level, + format: format.combine( + format.timestamp(), + format.json() + ), + transports: [ + new transports.Console({ + format: format.combine( + format.colorize(), + format.printf(({ timestamp, level, message, topic, ...meta }) => { + const topicStr = topic ? `[${topic}] ` : ''; + return `${timestamp} ${level}: ${topicStr}${message} ${Object.keys(meta).length ? JSON.stringify(meta) : ''}`; + }) + ) + }), + new transports.File({ + filename: path.join(LOG_DIR, 'combined.log') + }) + ] + }); +}; + +// Main logger instance +const logger = createBaseLogger(); + +// Create a topic-specific logger +const createTopicLogger = (options: TopicLoggerOptions): Logger => { + const topicLogger = createBaseLogger(options.level); + + // Create a wrapper that adds topic to all log messages + const wrappedLogger = { + ...topicLogger, + }; + + // Wrap each log level method to include topic + (['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly'] as const).forEach((level) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wrappedLogger[level] = ((...args: any[]) => { + // Handle different call patterns + if (typeof args[0] === 'string') { + const message = args[0]; + const meta = args[1] || {}; + return topicLogger[level]({ + message, + topic: options.topic, + ...meta + }); + } else { + // If first argument is an object, add topic to it + return topicLogger[level]({ + ...args[0], + topic: options.topic + }); + } + }) as LeveledLogMethod; + }); + + return wrappedLogger as Logger; +}; + +export { logger, createTopicLogger }; +export default logger; From 4ae5196ef1cc315bc64feb5a4f267306e5c5e3f6 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 18:56:34 +0700 Subject: [PATCH 10/14] feat: Add Swagger documentation support and restructure routes - Added @elysiajs/swagger dependency to package.json for API documentation. - Removed the old bed router and replaced it with a new history router. - Created a new state router to manage WebSocket connections and state updates. - Implemented a comprehensive state management system with the StateManager service. - Introduced AlarmManagement and BedService services for handling alarms and sensor readings. - Established a new MQTT service for managing MQTT connections and subscriptions. - Created an AlarmStateStore to manage volatile alerts and their states. - Defined FrontendState types for structured state management and WebSocket messaging. --- app.ts | 7 +- bun.lock | 47 +++++++ package.json | 1 + routes/{bed.ts => history.ts} | 0 routes/state.ts | 172 +++++++++++++++++++++++++ routes/swagger.ts | 19 +++ services/AlarmManagement.ts | 148 ++++++++++++++++++++++ services/BedService.ts | 228 +++++++++++++++++++++++++++++++++ services/StateManager.ts | 232 ++++++++++++++++++++++++++++++++++ services/mqttService.ts | 72 +++++++++++ store/AlarmStateStore.ts | 201 +++++++++++++++++++++++++++++ types/FrontendState.ts | 63 +++++++++ 12 files changed, 1189 insertions(+), 1 deletion(-) rename routes/{bed.ts => history.ts} (100%) create mode 100644 routes/state.ts create mode 100644 routes/swagger.ts create mode 100644 services/AlarmManagement.ts create mode 100644 services/BedService.ts create mode 100644 services/StateManager.ts create mode 100644 services/mqttService.ts create mode 100644 store/AlarmStateStore.ts create mode 100644 types/FrontendState.ts diff --git a/app.ts b/app.ts index d197cac..da774c8 100644 --- a/app.ts +++ b/app.ts @@ -3,6 +3,8 @@ import { cors } from '@elysiajs/cors'; import { createTopicLogger } from '~/utils/logger'; import env from './config/env'; +import stateRouter from './routes/state'; +import swaggerElysia from './routes/swagger'; async function initialize() { const logger = createTopicLogger({ topic: 'Initializer' }); @@ -12,7 +14,6 @@ async function initialize() { logger.error(`Initialization error: ${error}`); process.exit(1); } - } async function initializeElysia() { @@ -31,6 +32,10 @@ async function initializeElysia() { // Core Components .use(cors()) + .use(swaggerElysia) + + // State routes (includes WebSocket) + .use(stateRouter) // Start the server app.listen(PORT); diff --git a/bun.lock b/bun.lock index 2e8e5ca..295fcc3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,15 @@ "name": "elysia", "dependencies": { "@elysiajs/cors": "^1.3.3", + "@elysiajs/swagger": "^1.3.0", + "@prisma/client": "^6.10.1", "elysia": "latest", "envalid": "^8.0.0", "winston": "^3.17.0", }, "devDependencies": { "bun-types": "^1.2.17", + "prisma": "^6.10.1", }, }, }, @@ -21,6 +24,28 @@ "@elysiajs/cors": ["@elysiajs/cors@1.3.3", "", { "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-mYIU6PyMM6xIJuj7d27Vt0/wuzVKIEnFPjcvlkyd7t/m9xspAG37cwNjFxVOnyvY43oOd2I/oW2DB85utXpA2Q=="], + "@elysiajs/swagger": ["@elysiajs/swagger@1.3.0", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-0fo3FWkDRPNYpowJvLz3jBHe9bFe6gruZUyf+feKvUEEMG9ZHptO1jolSoPE0ffFw1BgN1/wMsP19p4GRXKdfg=="], + + "@prisma/client": ["@prisma/client@6.10.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-Re4pMlcUsQsUTAYMK7EJ4Bw2kg3WfZAAlr8GjORJaK4VOP6LxRQUQ1TuLnxcF42XqGkWQ36q5CQF1yVadANQ6w=="], + + "@prisma/config": ["@prisma/config@6.10.1", "", { "dependencies": { "jiti": "2.4.2" } }, "sha512-kz4/bnqrOrzWo8KzYguN0cden4CzLJJ+2VSpKtF8utHS3l1JS0Lhv6BLwpOX6X9yNreTbZQZwewb+/BMPDCIYQ=="], + + "@prisma/debug": ["@prisma/debug@6.10.1", "", {}, "sha512-k2YT53cWxv9OLjW4zSYTZ6Z7j0gPfCzcr2Mj99qsuvlxr8WAKSZ2NcSR0zLf/mP4oxnYG842IMj3utTgcd7CaA=="], + + "@prisma/engines": ["@prisma/engines@6.10.1", "", { "dependencies": { "@prisma/debug": "6.10.1", "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", "@prisma/fetch-engine": "6.10.1", "@prisma/get-platform": "6.10.1" } }, "sha512-Q07P5rS2iPwk2IQr/rUQJ42tHjpPyFcbiH7PXZlV81Ryr9NYIgdxcUrwgVOWVm5T7ap02C0dNd1dpnNcSWig8A=="], + + "@prisma/engines-version": ["@prisma/engines-version@6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", "", {}, "sha512-ZJFTsEqapiTYVzXya6TUKYDFnSWCNegfUiG5ik9fleQva5Sk3DNyyUi7X1+0ZxWFHwHDr6BZV5Vm+iwP+LlciA=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@6.10.1", "", { "dependencies": { "@prisma/debug": "6.10.1", "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", "@prisma/get-platform": "6.10.1" } }, "sha512-clmbG/Jgmrc/n6Y77QcBmAUlq9LrwI9Dbgy4pq5jeEARBpRCWJDJ7PWW1P8p0LfFU0i5fsyO7FqRzRB8mkdS4g=="], + + "@prisma/get-platform": ["@prisma/get-platform@6.10.1", "", { "dependencies": { "@prisma/debug": "6.10.1" } }, "sha512-4CY5ndKylcsce9Mv+VWp5obbR2/86SHOLVV053pwIkhVtT9C9A83yqiqI/5kJM9T1v1u1qco/bYjDKycmei9HA=="], + + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="], + + "@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="], + + "@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.35", "", {}, "sha512-C6ypdODf2VZkgRT6sFM8E1F8vR+HcffniX0Kp8MsU8PIfrlXbNCBz0jzj17GjdmjTx1OtZzdH8+iALL21UjF5A=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], @@ -31,6 +56,8 @@ "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], @@ -67,6 +94,8 @@ "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -75,16 +104,24 @@ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="], + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "prisma": ["prisma@6.10.1", "", { "dependencies": { "@prisma/config": "6.10.1", "@prisma/engines": "6.10.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -107,6 +144,8 @@ "tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "uint8array-extras": ["uint8array-extras@1.4.0", "", {}, "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ=="], @@ -118,5 +157,13 @@ "winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="], "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + + "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], + + "zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="], + + "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], + + "@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="], } } diff --git a/package.json b/package.json index 1e60494..e84e5a8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@elysiajs/cors": "^1.3.3", + "@elysiajs/swagger": "^1.3.0", "@prisma/client": "^6.10.1", "elysia": "latest", "envalid": "^8.0.0", diff --git a/routes/bed.ts b/routes/history.ts similarity index 100% rename from routes/bed.ts rename to routes/history.ts diff --git a/routes/state.ts b/routes/state.ts new file mode 100644 index 0000000..82285c3 --- /dev/null +++ b/routes/state.ts @@ -0,0 +1,172 @@ +import { Elysia, t } from "elysia"; +import { StateManager } from '../services/StateManager'; +import { BedService } from '../services/BedService'; +import { WebSocketMessage, StateUpdateEvent } from '../types/FrontendState'; + +// Define WebSocket client type +interface WSClient { + id: string; + send: (message: string) => void; + readyState: number; +} + +// Singleton instances +let stateManager: StateManager | null = null; +let bedService: BedService | null = null; +const clients = new Map(); + +// Initialize services +async function initializeServices() { + if (!bedService) { + bedService = new BedService(); + await bedService.initialize(); + } + + if (!stateManager) { + stateManager = new StateManager(bedService); + await stateManager.initializeState(); + + // Listen for state updates and broadcast to clients + stateManager.on('stateUpdate', (event: StateUpdateEvent) => { + broadcastStateUpdate(event); + }); + } +} + +// Broadcast state updates to all connected clients +function broadcastStateUpdate(event: StateUpdateEvent) { + const message: WebSocketMessage = { + type: 'STATE_UPDATE', + payload: event, + timestamp: new Date() + }; + + const messageStr = JSON.stringify(message); + const deadClients: string[] = []; + + clients.forEach((ws, id) => { + try { + if (ws.readyState === 1) { // WebSocket.OPEN + ws.send(messageStr); + } else { + deadClients.push(id); + } + } catch (error) { + console.error('Failed to send message to client:', error); + deadClients.push(id); + } + }); + + // Clean up dead clients + deadClients.forEach(id => { + clients.delete(id); + }); + + // Update connection count + if (stateManager) { + stateManager.updateConnectionCount(clients.size); + } +} + +// Send current state to a specific client +async function sendCurrentState(ws: WSClient) { + try { + await initializeServices(); + + if (stateManager) { + const state = stateManager.getState(); + const message: WebSocketMessage = { + type: 'STATE_UPDATE', + payload: { + type: 'FULL_STATE', + timestamp: new Date(), + data: state + }, + timestamp: new Date() + }; + + ws.send(JSON.stringify(message)); + } + } catch (error) { + console.error('Failed to send current state:', error); + } +} + +const stateRouter = new Elysia() + .ws('/ws', { + body: t.Object({ + type: t.Union([ + t.Literal('ACKNOWLEDGE_ALERT'), + t.Literal('SILENCE_ALERT'), + t.Literal('HEARTBEAT') + ]), + payload: t.Optional(t.Object({ + alertId: t.Optional(t.String()) + })) + }), + + open: async (ws) => { + console.log('WebSocket client connected:', ws.id); + clients.set(ws.id, ws); + + // Send current state to new client + await sendCurrentState(ws); + + // Update connection count + await initializeServices(); + if (stateManager) { + stateManager.updateConnectionCount(clients.size); + } + }, + + message: async (ws, message) => { + try { + await initializeServices(); + + switch (message.type) { + case 'ACKNOWLEDGE_ALERT': + if (message.payload?.alertId && stateManager) { + await stateManager.acknowledgeAlert(message.payload.alertId); + } + break; + + case 'SILENCE_ALERT': + if (message.payload?.alertId && stateManager) { + await stateManager.silenceAlert(message.payload.alertId); + } + break; + + case 'HEARTBEAT': + // Respond to heartbeat + ws.send(JSON.stringify({ + type: 'HEARTBEAT', + payload: { message: 'pong' }, + timestamp: new Date() + })); + break; + + default: + console.warn('Unknown WebSocket message type:', message.type); + } + } catch (error) { + console.error('Error handling client message:', error); + ws.send(JSON.stringify({ + type: 'ERROR', + payload: { message: 'Invalid message format' }, + timestamp: new Date() + })); + } + }, + + close: (ws) => { + console.log('WebSocket client disconnected:', ws.id); + clients.delete(ws.id); + + // Update connection count + if (stateManager) { + stateManager.updateConnectionCount(clients.size); + } + } + }); + +export default stateRouter; \ No newline at end of file diff --git a/routes/swagger.ts b/routes/swagger.ts new file mode 100644 index 0000000..4bcca0e --- /dev/null +++ b/routes/swagger.ts @@ -0,0 +1,19 @@ +import swagger from "@elysiajs/swagger"; +import Elysia from "elysia"; + +const swaggerElysia = new Elysia() +swaggerElysia.use(swagger({ + path: '/api/docs', + documentation: { + info: { + title: "Siwat System API Template", + description: "API documentation", + version: "1.0.0", + }, + tags: [ + // Define your tags here + ], + }, +})) + +export default swaggerElysia; \ No newline at end of file diff --git a/services/AlarmManagement.ts b/services/AlarmManagement.ts new file mode 100644 index 0000000..c608ef3 --- /dev/null +++ b/services/AlarmManagement.ts @@ -0,0 +1,148 @@ +import { EventEmitter } from 'events'; +import { AlarmStateStore, VolatileAlert } from '../store/AlarmStateStore'; +import { MeasurementPointState } from '../types/FrontendState'; + +export class AlarmManagement extends EventEmitter { + private alarmStore: AlarmStateStore; + private measurementPoints: Map = new Map(); + + constructor() { + super(); + this.alarmStore = new AlarmStateStore(); + this.setupEventListeners(); + } + + private setupEventListeners(): void { + // Forward alarm store events + this.alarmStore.on('alertCreated', (alert: VolatileAlert) => { + this.emit('alertCreated', alert); + }); + + this.alarmStore.on('alertUpdated', (alert: VolatileAlert) => { + this.emit('alertUpdated', alert); + }); + + this.alarmStore.on('alertRemoved', (alert: VolatileAlert) => { + this.emit('alertRemoved', alert); + }); + } + + // Update measurement points for reference + updateMeasurementPoints(measurementPoints: Record): void { + this.measurementPoints.clear(); + Object.values(measurementPoints).forEach(mp => { + this.measurementPoints.set(mp.id, mp); + }); + } + + // Process sensor reading and check for alerts + processSensorReading(sensorId: string, value: number, timestamp: Date): void { + // Find measurement point by sensorId + const measurementPoint = Array.from(this.measurementPoints.values()) + .find(mp => mp.sensorId === sensorId); + + if (!measurementPoint) { + console.warn(`No measurement point found for sensor: ${sensorId}`); + return; + } + + this.checkAlerts(measurementPoint, value); + } + + private checkAlerts(measurementPoint: MeasurementPointState, value: number): void { + const { id: pointId, warningThreshold, alarmThreshold, warningDelayMs, label, zone } = measurementPoint; + + // Check if value exceeds alarm threshold (immediate alarm) + if (value >= alarmThreshold) { + this.alarmStore.clearWarningTimer(pointId); + this.alarmStore.createAlert( + pointId, + measurementPoint.sensorId, + 'ALARM', + value, + alarmThreshold, + label, + zone + ); + return; + } + + // Check if value exceeds warning threshold + if (value >= warningThreshold) { + const existingAlert = this.alarmStore.getAlertByMeasurementPointId(pointId); + + if (!existingAlert) { + // Create warning alert + this.alarmStore.createAlert( + pointId, + measurementPoint.sensorId, + 'WARNING', + value, + warningThreshold, + label, + zone + ); + + // Set timer for warning to escalate to alarm + const timer = setTimeout(() => { + this.alarmStore.createAlert( + pointId, + measurementPoint.sensorId, + 'ALARM', + value, + warningThreshold, + label, + zone + ); + }, warningDelayMs); + + this.alarmStore.setWarningTimer(pointId, timer); + } + } else { + // Value is below warning threshold, clear any alerts for this point + this.alarmStore.clearWarningTimer(pointId); + this.alarmStore.removeAlertsByMeasurementPointId(pointId); + } + } + + // Get all active alerts + getActiveAlerts(): VolatileAlert[] { + return this.alarmStore.getAllAlerts(); + } + + // Get alerts in frontend state format + getAlertStates(): Record { + return this.alarmStore.toAlertStates(); + } + + // Acknowledge alert + acknowledgeAlert(alertId: string): boolean { + return this.alarmStore.acknowledgeAlert(alertId); + } + + // Silence alert + silenceAlert(alertId: string): boolean { + return this.alarmStore.silenceAlert(alertId); + } + + // Get alert by ID + getAlert(alertId: string): VolatileAlert | undefined { + return this.alarmStore.getAlert(alertId); + } + + // Get statistics + getStats() { + return this.alarmStore.getStats(); + } + + // Clear all alerts (for testing/reset) + clearAllAlerts(): void { + this.alarmStore.clearAll(); + } + + // Cleanup + cleanup(): void { + this.alarmStore.clearAll(); + this.removeAllListeners(); + } +} \ No newline at end of file diff --git a/services/BedService.ts b/services/BedService.ts new file mode 100644 index 0000000..1c02c31 --- /dev/null +++ b/services/BedService.ts @@ -0,0 +1,228 @@ +import { PrismaClient, type MeasurementPoint } from '../generated/prisma'; +import { EventEmitter } from 'events'; +import MQTT from '../adapter/mqtt'; +import { getMQTTClient, BASE_TOPIC } from './mqttService'; +import { AlarmManagement } from './AlarmManagement'; +import { VolatileAlert } from '../store/AlarmStateStore'; + +export interface SensorReading { + sensorId: string; + value: number; + timestamp: Date; +} + +export interface AlertConfig { + warningThreshold: number; + alarmThreshold: number; + warningDelayMs: number; +} + +export class BedService extends EventEmitter { + private prisma: PrismaClient; + private mqtt: MQTT | null = null; + private alarmManagement: AlarmManagement; + private baseTopic = `${BASE_TOPIC}pressure`; + + constructor() { + super(); + this.prisma = new PrismaClient(); + this.alarmManagement = new AlarmManagement(); + this.setupAlarmEventListeners(); + } + + private setupAlarmEventListeners(): void { + // Forward alarm events + this.alarmManagement.on('alertCreated', (alert: VolatileAlert) => { + this.emit('alert', alert); + }); + + this.alarmManagement.on('alertUpdated', (alert: VolatileAlert) => { + this.emit('alert', alert); + }); + + this.alarmManagement.on('alertRemoved', (alert: VolatileAlert) => { + this.emit('alertRemoved', alert); + }); + } + + async initialize(mqttConfig?: { host?: string; port?: number; username?: string; password?: string }): Promise { + try { + // Use mqttService to get initialized MQTT client + this.mqtt = await getMQTTClient(mqttConfig); + + // Subscribe to sensor data topic + await this.mqtt.subscribe(`${this.baseTopic}/+/data`, (topic, message) => { + this.handleSensorData(topic, message); + }); + + console.log('BedService initialized successfully'); + this.emit('initialized'); + } catch (error) { + console.error('Failed to initialize BedService:', error); + throw error; + } + } + + private handleSensorData(topic: string, message: string): void { + try { + // Extract sensor ID from topic: bed/pressure/{sensorId}/data + const sensorId = topic.split('/')[2]; + const data = JSON.parse(message); + + const reading: SensorReading = { + sensorId, + value: data.value, + timestamp: new Date(data.timestamp || Date.now()) + }; + + this.processSensorReading(reading); + } catch (error) { + console.error('Error processing MQTT sensor data:', error); + this.emit('error', error); + } + } + private async processSensorReading(reading: SensorReading): Promise { + try { + // Find measurement point + const measurementPoint = await this.prisma.measurementPoint.findUnique({ + where: { sensorId: reading.sensorId } + }); + + if (!measurementPoint) { + console.warn(`Unknown sensor ID: ${reading.sensorId}`); + return; + } + + // Store sensor data + await this.prisma.measurementPointData.create({ + data: { + measurementPointId: measurementPoint.id, + value: reading.value, + timestamp: reading.timestamp, + time: reading.timestamp.toISOString() + } + }); + + // Let alarm management handle alerts + this.alarmManagement.processSensorReading(reading.sensorId, reading.value, reading.timestamp); + + this.emit('sensorReading', reading); + } catch (error) { + console.error('Error processing sensor reading:', error); + this.emit('error', error); + } + } + // Public API methods + async createMeasurementPoint(data: { + sensorId: string; + label: string; + zone: string; + x: number; + y: number; + pin: number; + warningThreshold: number; + alarmThreshold: number; + warningDelayMs: number; + }): Promise { + return this.prisma.measurementPoint.create({ data }); + } + async getMeasurementPoints(): Promise { + const points = await this.prisma.measurementPoint.findMany({ + orderBy: { zone: 'asc' } + }); + + // Update alarm management with current measurement points + const pointsRecord: Record = {}; + + points.forEach(point => { + pointsRecord[point.id] = { + id: point.id, + sensorId: point.sensorId, + label: point.label, + zone: point.zone, x: point.x, + y: point.y, + pin: point.pin, + warningThreshold: point.warningThreshold, + alarmThreshold: point.alarmThreshold, + warningDelayMs: point.warningDelayMs, + currentValue: 0, + lastUpdateTime: new Date(), + status: 'offline' as const + }; + }); + + this.alarmManagement.updateMeasurementPoints(pointsRecord); + return points; + } + + async getMeasurementPointData(sensorId: string, limit = 100) { + const measurementPoint = await this.prisma.measurementPoint.findUnique({ + where: { sensorId } + }); + + if (!measurementPoint) { + throw new Error(`Measurement point not found for sensor: ${sensorId}`); + } + + return this.prisma.measurementPointData.findMany({ + where: { measurementPointId: measurementPoint.id }, + orderBy: { timestamp: 'desc' }, + take: limit + }); + } + + // Get active alerts from alarm management (volatile) + getActiveAlerts(): VolatileAlert[] { + return this.alarmManagement.getActiveAlerts(); + } + + // Acknowledge alert in volatile store + acknowledgeAlert(alertId: string): boolean { + return this.alarmManagement.acknowledgeAlert(alertId); + } + + // Silence alert in volatile store + silenceAlert(alertId: string): boolean { + return this.alarmManagement.silenceAlert(alertId); + } + + async updateAlertConfig(sensorId: string, config: AlertConfig): Promise { + return this.prisma.measurementPoint.update({ + where: { sensorId }, + data: { + warningThreshold: config.warningThreshold, + alarmThreshold: config.alarmThreshold, + warningDelayMs: config.warningDelayMs + } + }); + } + + async disconnect(): Promise { + // Cleanup alarm management + this.alarmManagement.cleanup(); + + // Disconnect MQTT + if (this.mqtt) { + await this.mqtt.disconnect(); + } + + // Disconnect Prisma + await this.prisma.$disconnect(); + + this.emit('disconnected'); + } +} \ No newline at end of file diff --git a/services/StateManager.ts b/services/StateManager.ts new file mode 100644 index 0000000..abd6029 --- /dev/null +++ b/services/StateManager.ts @@ -0,0 +1,232 @@ +import { EventEmitter } from 'events'; +import { FrontendState, MeasurementPointState, AlertState, SystemStatus, StateUpdateEvent } from '../types/FrontendState'; +import { BedService } from './BedService'; +import { VolatileAlert } from '../store/AlarmStateStore'; +import { PrismaClient } from '../generated/prisma'; + +export class StateManager extends EventEmitter { + private state: FrontendState; + private prisma: PrismaClient; + + constructor(private bedService: BedService) { + super(); + this.prisma = new PrismaClient(); // Initialize empty state + this.state = { + measurementPoints: {}, + alerts: {}, + system: { + mqttConnected: false, + databaseConnected: false, + lastHeartbeat: new Date(), + activeConnections: 0, + totalMeasurementPoints: 0, + activeSensors: 0 + } + }; + + this.setupEventListeners(); + } + + private setupEventListeners(): void { + // Listen to BedService events + this.bedService.on('sensorReading', (reading) => { + this.updateSensorReading(reading.sensorId, reading.value, reading.timestamp); + }); + + this.bedService.on('alert', (alert) => { + this.updateAlert(alert); + }); + + this.bedService.on('initialized', () => { + this.updateSystemStatus({ mqttConnected: true }); + }); + + this.bedService.on('disconnected', () => { + this.updateSystemStatus({ mqttConnected: false }); + }); + } + + // Get current state (read-only) + getState(): Readonly { + return { ...this.state }; + } + + // Initialize state from database + async initializeState(): Promise { + try { // Load measurement points + const measurementPoints = await this.bedService.getMeasurementPoints(); + const measurementPointStates: Record = {}; + + for (const mp of measurementPoints) { measurementPointStates[mp.id] = { + id: mp.id, + sensorId: mp.sensorId, + label: mp.label, + zone: mp.zone, + x: mp.x ?? 0, + y: mp.y ?? 0, + pin: mp.pin ?? 0, + currentValue: 0, + lastUpdateTime: new Date(), + warningThreshold: mp.warningThreshold, + alarmThreshold: mp.alarmThreshold, + warningDelayMs: mp.warningDelayMs, + status: 'offline' + }; + } // Load active alerts + const alerts = await this.bedService.getActiveAlerts(); + const alertStates: Record = {}; + + for (const alert of alerts) { + const measurementPoint = measurementPointStates[alert.measurementPointId]; + alertStates[alert.id] = { + id: alert.id, + measurementPointId: alert.measurementPointId, + type: alert.type, + value: alert.value, + threshold: alert.threshold, + acknowledged: alert.acknowledged, + silenced: alert.silenced, + startTime: alert.startTime, + endTime: alert.endTime ?? undefined, + sensorLabel: measurementPoint?.label || 'Unknown', + zone: measurementPoint?.zone || 'Unknown' + }; + } // Update state + this.state.measurementPoints = measurementPointStates; + this.state.alerts = alertStates; + this.state.system.totalMeasurementPoints = measurementPoints.length; + this.state.system.databaseConnected = true; + + this.emitStateUpdate('FULL_STATE', this.state); + } catch (error) { + console.error('Failed to initialize state:', error); + this.state.system.databaseConnected = false; + } + } + + // Update sensor reading + updateSensorReading(sensorId: string, value: number, timestamp: Date): void { + // Find measurement point by sensorId + const measurementPoint = Object.values(this.state.measurementPoints) + .find(mp => mp.sensorId === sensorId); + + if (!measurementPoint) return; + + // Determine status based on thresholds + let status: 'normal' | 'warning' | 'alarm' | 'offline' = 'normal'; + if (value >= measurementPoint.alarmThreshold) { + status = 'alarm'; + } else if (value >= measurementPoint.warningThreshold) { + status = 'warning'; + } // Update measurement point state + this.state.measurementPoints[measurementPoint.id] = { + ...measurementPoint, + currentValue: value, + lastUpdateTime: timestamp, + status + }; + + // Update system stats + this.updateActiveSensors(); + + this.emitStateUpdate('SENSOR_UPDATE', this.state.measurementPoints[measurementPoint.id]); + } + + // Update alert + updateAlert(alert: Alert & { measurementPoint: MeasurementPoint }): void { + const alertState: AlertState = { + id: alert.id, + measurementPointId: alert.measurementPointId, + type: alert.type, + value: alert.value, + threshold: alert.threshold, + acknowledged: alert.acknowledged, + silenced: alert.silenced, + startTime: alert.startTime, + endTime: alert.endTime || undefined, + sensorLabel: alert.measurementPoint.label, + zone: alert.measurementPoint.zone + }; this.state.alerts[alert.id] = alertState; + + this.emitStateUpdate('ALERT_UPDATE', alertState); + } + // Remove alert (when closed) + removeAlert(alertId: string): void { + if (this.state.alerts[alertId]) { + delete this.state.alerts[alertId]; + this.emitStateUpdate('PARTIAL_UPDATE', { alerts: this.state.alerts }); + } + } + + // Update system status + updateSystemStatus(updates: Partial): void { + this.state.system = { + ...this.state.system, + ...updates, + lastHeartbeat: new Date() + }; + + this.emitStateUpdate('SYSTEM_UPDATE', this.state.system); + } + + // Update active sensors count + private updateActiveSensors(): void { + const now = new Date(); + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); + + const activeSensors = Object.values(this.state.measurementPoints) + .filter(mp => mp.lastUpdateTime > fiveMinutesAgo).length; + + this.state.system.activeSensors = activeSensors; + } + + // Update connection count (for WebSocket clients) + updateConnectionCount(count: number): void { + this.state.system.activeConnections = count; + this.emitStateUpdate('SYSTEM_UPDATE', this.state.system); + } + + // Acknowledge alert + async acknowledgeAlert(alertId: string): Promise { + try { + await this.bedService.acknowledgeAlert(alertId); + + if (this.state.alerts[alertId]) { + this.state.alerts[alertId].acknowledged = true; + this.emitStateUpdate('ALERT_UPDATE', this.state.alerts[alertId]); + } + } catch (error) { + console.error('Failed to acknowledge alert:', error); + } + } + + // Silence alert + async silenceAlert(alertId: string): Promise { + try { + await this.bedService.silenceAlert(alertId); + + if (this.state.alerts[alertId]) { + this.state.alerts[alertId].silenced = true; + this.emitStateUpdate('ALERT_UPDATE', this.state.alerts[alertId]); + } + } catch (error) { + console.error('Failed to silence alert:', error); + } + } + // Emit state update event + private emitStateUpdate(type: StateUpdateEvent['type'], data: StateUpdateEvent['data']): void { + const event: StateUpdateEvent = { + type, + timestamp: new Date(), + data + }; + + this.emit('stateUpdate', event); + } + + // Cleanup + async disconnect(): Promise { + await this.prisma.$disconnect(); + this.removeAllListeners(); + } +} \ No newline at end of file diff --git a/services/mqttService.ts b/services/mqttService.ts new file mode 100644 index 0000000..59c787e --- /dev/null +++ b/services/mqttService.ts @@ -0,0 +1,72 @@ +import MQTT, { MQTTConfig } from '../adapter/mqtt'; + +// Default MQTT configuration for HiveMQ broker +const defaultConfig: MQTTConfig = { + host: 'broker.hivemq.com', + port: 1883, + username: undefined, + password: undefined +}; + +export const BASE_TOPIC = '/Jtkcp2N/pressurebed/'; + +// Singleton MQTT client instance +let mqttInstance: MQTT | null = null; + +export class MQTTService { + private static instance: MQTT | null = null; + + static async initialize(config?: Partial): Promise { + if (!MQTTService.instance) { + const finalConfig = { ...defaultConfig, ...config }; + MQTTService.instance = new MQTT(); + await MQTTService.instance.initialize(finalConfig); + } + return MQTTService.instance; + } + + static getInstance(): MQTT | null { + return MQTTService.instance; + } + + static async disconnect(): Promise { + if (MQTTService.instance) { + await MQTTService.instance.disconnect(); + MQTTService.instance = null; + } + } +} + +// Factory function to get or create MQTT client +export async function getMQTTClient(config?: Partial): Promise { + if (!mqttInstance) { + mqttInstance = new MQTT(); + const finalConfig = { ...defaultConfig, ...config }; + await mqttInstance.initialize(finalConfig); + } + return mqttInstance; +} + +// Export the singleton instance getter +export function getMQTTInstance(): MQTT | null { + return mqttInstance; +} + +// Cleanup function +export async function disconnectMQTT(): Promise { + if (mqttInstance) { + await mqttInstance.disconnect(); + mqttInstance = null; + } +} + +// Export default configured client (lazy initialization) +const mqttService = { + async getClient(config?: Partial): Promise { + return getMQTTClient(config); + }, + getInstance: getMQTTInstance, + disconnect: disconnectMQTT +}; + +export default mqttService; \ No newline at end of file diff --git a/store/AlarmStateStore.ts b/store/AlarmStateStore.ts new file mode 100644 index 0000000..db97394 --- /dev/null +++ b/store/AlarmStateStore.ts @@ -0,0 +1,201 @@ +import { EventEmitter } from 'events'; +import { AlertState } from '../types/FrontendState'; + +export interface VolatileAlert { + id: string; + measurementPointId: string; + sensorId: string; + type: 'WARNING' | 'ALARM'; + value: number; + threshold: number; + acknowledged: boolean; + silenced: boolean; + startTime: Date; + sensorLabel: string; + zone: string; +} + +export class AlarmStateStore extends EventEmitter { + private alerts: Map = new Map(); + private warningTimers: Map = new Map(); + private alertIdCounter = 0; + + // Generate unique alert ID + private generateAlertId(): string { + return `alert_${Date.now()}_${++this.alertIdCounter}`; + } + + // Create a new alert + createAlert( + measurementPointId: string, + sensorId: string, + type: 'WARNING' | 'ALARM', + value: number, + threshold: number, + sensorLabel: string, + zone: string + ): VolatileAlert { + // Check if there's already an active alert for this measurement point + const existingAlert = this.getAlertByMeasurementPointId(measurementPointId); + + if (existingAlert) { + // If upgrading from WARNING to ALARM, update the existing alert + if (existingAlert.type === 'WARNING' && type === 'ALARM') { + existingAlert.type = 'ALARM'; + existingAlert.value = value; + existingAlert.threshold = threshold; + + this.emit('alertUpdated', existingAlert); + return existingAlert; + } + + // If it's the same type or downgrading, return existing + return existingAlert; + } + + const alert: VolatileAlert = { + id: this.generateAlertId(), + measurementPointId, + sensorId, + type, + value, + threshold, + acknowledged: false, + silenced: false, + startTime: new Date(), + sensorLabel, + zone + }; + + this.alerts.set(alert.id, alert); + this.emit('alertCreated', alert); + + return alert; + } + + // Get alert by measurement point ID + getAlertByMeasurementPointId(measurementPointId: string): VolatileAlert | undefined { + return Array.from(this.alerts.values()) + .find(alert => alert.measurementPointId === measurementPointId); + } + + // Remove alert + removeAlert(alertId: string): boolean { + const alert = this.alerts.get(alertId); + if (alert) { + this.alerts.delete(alertId); + this.emit('alertRemoved', alert); + return true; + } + return false; + } + + // Remove alerts by measurement point ID + removeAlertsByMeasurementPointId(measurementPointId: string): void { + const alertsToRemove = Array.from(this.alerts.values()) + .filter(alert => alert.measurementPointId === measurementPointId); + + alertsToRemove.forEach(alert => { + this.alerts.delete(alert.id); + this.emit('alertRemoved', alert); + }); + } + + // Acknowledge alert + acknowledgeAlert(alertId: string): boolean { + const alert = this.alerts.get(alertId); + if (alert) { + alert.acknowledged = true; + this.emit('alertUpdated', alert); + return true; + } + return false; + } + + // Silence alert + silenceAlert(alertId: string): boolean { + const alert = this.alerts.get(alertId); + if (alert) { + alert.silenced = true; + this.emit('alertUpdated', alert); + return true; + } + return false; + } + + // Set warning timer + setWarningTimer(measurementPointId: string, timer: NodeJS.Timeout): void { + // Clear existing timer if any + this.clearWarningTimer(measurementPointId); + this.warningTimers.set(measurementPointId, timer); + } + + // Clear warning timer + clearWarningTimer(measurementPointId: string): void { + const timer = this.warningTimers.get(measurementPointId); + if (timer) { + clearTimeout(timer); + this.warningTimers.delete(measurementPointId); + } + } + + // Get all active alerts + getAllAlerts(): VolatileAlert[] { + return Array.from(this.alerts.values()); + } + + // Get alert by ID + getAlert(alertId: string): VolatileAlert | undefined { + return this.alerts.get(alertId); + } + + // Convert to AlertState format for frontend + toAlertState(alert: VolatileAlert): AlertState { + return { + id: alert.id, + measurementPointId: alert.measurementPointId, + type: alert.type, + value: alert.value, + threshold: alert.threshold, + acknowledged: alert.acknowledged, + silenced: alert.silenced, + startTime: alert.startTime, + endTime: undefined, // Volatile alerts don't have end times + sensorLabel: alert.sensorLabel, + zone: alert.zone + }; + } + + // Convert all alerts to AlertState format + toAlertStates(): Record { + const result: Record = {}; + this.alerts.forEach((alert, id) => { + result[id] = this.toAlertState(alert); + }); + return result; + } + + // Clear all alerts + clearAll(): void { + // Clear all warning timers + this.warningTimers.forEach(timer => clearTimeout(timer)); + this.warningTimers.clear(); + + // Clear all alerts + this.alerts.clear(); + + this.emit('allAlertsCleared'); + } + + // Get statistics + getStats() { + const alerts = this.getAllAlerts(); + return { + total: alerts.length, + warnings: alerts.filter(a => a.type === 'WARNING').length, + alarms: alerts.filter(a => a.type === 'ALARM').length, + acknowledged: alerts.filter(a => a.acknowledged).length, + silenced: alerts.filter(a => a.silenced).length + }; + } +} \ No newline at end of file diff --git a/types/FrontendState.ts b/types/FrontendState.ts new file mode 100644 index 0000000..4740867 --- /dev/null +++ b/types/FrontendState.ts @@ -0,0 +1,63 @@ +export interface MeasurementPointState { + id: string; + sensorId: string; + label: string; + zone: string; + x: number; + y: number; + pin: number; + currentValue: number; + lastUpdateTime: Date; + warningThreshold: number; + alarmThreshold: number; + warningDelayMs: number; + status: 'normal' | 'warning' | 'alarm' | 'offline'; +} + +export interface AlertState { + id: string; + measurementPointId: string; + type: 'WARNING' | 'ALARM'; + value: number; + threshold: number; + acknowledged: boolean; + silenced: boolean; + startTime: Date; + endTime?: Date; + sensorLabel: string; + zone: string; +} + +export interface SystemStatus { + mqttConnected: boolean; + databaseConnected: boolean; + lastHeartbeat: Date; + activeConnections: number; + totalMeasurementPoints: number; + activeSensors: number; +} + +export interface FrontendState { + // Measurement points and sensor data + measurementPoints: Record; + + // Active alerts + alerts: Record; + + // System status + system: SystemStatus; +} + +// State update events +export interface StateUpdateEvent { + type: 'FULL_STATE' | 'PARTIAL_UPDATE' | 'SENSOR_UPDATE' | 'ALERT_UPDATE' | 'SYSTEM_UPDATE'; + timestamp: Date; + data: Partial | MeasurementPointState | AlertState | SystemStatus; +} + +// WebSocket message types +export interface WebSocketMessage { + type: 'STATE_UPDATE' | 'HEARTBEAT' | 'ERROR' | 'ACKNOWLEDGE_ALERT' | 'SILENCE_ALERT'; + payload: StateUpdateEvent | { alertId: string } | { message: string }; + timestamp: Date; +} \ No newline at end of file From 7e5ed7f26b1efd332d6866feb7f1b778dc268f38 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 19:03:14 +0700 Subject: [PATCH 11/14] feat: Add logging for received MQTT messages and remove mock data generation in useBedPressureData --- adapter/mqtt.ts | 1 + hooks/useBedPressureData.ts | 19 +--- services/BedHardwareSerial.ts.disabled | 149 ------------------------- 3 files changed, 3 insertions(+), 166 deletions(-) delete mode 100644 services/BedHardwareSerial.ts.disabled diff --git a/adapter/mqtt.ts b/adapter/mqtt.ts index d007af3..b81be99 100644 --- a/adapter/mqtt.ts +++ b/adapter/mqtt.ts @@ -132,6 +132,7 @@ class MQTT { private onMessage(topic: string, message: Buffer): void { const msg = message.toString(); + console.log(`Received message on topic ${topic}:`, msg); this.subscriptions.forEach(sub => { if (this.matchTopic(sub.topic, topic)) { sub.callback(topic, msg); diff --git a/hooks/useBedPressureData.ts b/hooks/useBedPressureData.ts index a00822f..5518c98 100644 --- a/hooks/useBedPressureData.ts +++ b/hooks/useBedPressureData.ts @@ -1,21 +1,6 @@ import { useEffect } from 'react' import { useBedPressureStore, SensorData } from '@/stores/bedPressureStore' -// Mock data generator -const generateTimeSeriesData = (hours = 24) => { - const data = [] - const now = new Date() - - for (let i = hours * 60; i >= 0; i -= 5) { - const time = new Date(now.getTime() - i * 60 * 1000) - data.push({ - time: time.toLocaleTimeString("en-US", { hour12: false }), - timestamp: time.getTime(), - value: Math.floor(Math.random() * 4096 + Math.sin(i / 60) * 500 + 2000), // 0-4095 range - }) - } - return data -} export function useBedPressureData() { const { @@ -40,8 +25,8 @@ export function useBedPressureData() { sensorConfig.forEach((sensor) => { initialData[sensor.id] = { ...sensor, - currentValue: Math.floor(Math.random() * 1000 + 1000), // Start with baseline analog value (1000-2000) - data: generateTimeSeriesData(), + currentValue: 0, + data: [], status: "normal", } }) diff --git a/services/BedHardwareSerial.ts.disabled b/services/BedHardwareSerial.ts.disabled deleted file mode 100644 index 1bf4537..0000000 --- a/services/BedHardwareSerial.ts.disabled +++ /dev/null @@ -1,149 +0,0 @@ -import { SerialPort } from 'serialport'; -import { ReadlineParser } from '@serialport/parser-readline'; -import { EventEmitter } from 'events'; -import { IBedHardware, PinState, PinChange } from '../types/bedhardware'; - -export class BedHardwareSerial extends EventEmitter implements IBedHardware { - private serialPort: SerialPort | null = null; - private parser: ReadlineParser | null = null; - private pinStates: Map = new Map(); - private connectionState: boolean = false; - - constructor(private portPath: string, private baudRate: number = 9600) { - super(); - } - - async connect(): Promise { - try { - this.serialPort = new SerialPort({ - path: this.portPath, - baudRate: this.baudRate, - autoOpen: false - }); - - this.parser = new ReadlineParser({ delimiter: '\n' }); - this.serialPort.pipe(this.parser); - - // Setup event handlers - this.serialPort.on('open', () => { - this.connectionState = true; - this.emit('connected'); - console.log('Serial port opened'); - }); - - this.serialPort.on('error', (error) => { - this.emit('error', error); - console.error('Serial port error:', error); - }); - - this.serialPort.on('close', () => { - this.connectionState = false; - this.emit('disconnected'); - console.log('Serial port closed'); - }); - - this.parser.on('data', (data: string) => { - this.handleSerialData(data.trim()); - }); - - // Open the port - await new Promise((resolve, reject) => { - this.serialPort!.open((error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - - } catch (error) { - throw new Error(`Failed to connect to ${this.portPath}: ${error}`); - } - } - - async disconnect(): Promise { - if (this.serialPort && this.serialPort.isOpen) { - await new Promise((resolve) => { - this.serialPort!.close(() => { - resolve(); - }); - }); - } - this.serialPort = null; - this.parser = null; - this.connectionState = false; - } - - private handleSerialData(data: string): void { - const parts = data.split(':'); - - if (parts[0] === 'INIT') { - if (parts[1] === 'START') { - this.emit('initialized'); - console.log('Arduino initialization started'); - } else if (parts.length >= 3) { - // INIT:PIN:STATE format - const pin = parseInt(parts[1]); - const state = parseInt(parts[2]); - - if (!isNaN(pin) && !isNaN(state)) { - const pinState: PinState = { - pin, - state, - name: `PIN${pin}`, - timestamp: new Date() - }; - - this.pinStates.set(pin, pinState); - this.emit('pinInitialized', pinState); - } - } - } else if (parts[0] === 'CHANGE' && parts.length >= 4) { - // CHANGE:PIN:PREVIOUS_STATE:CURRENT_STATE format - const pin = parseInt(parts[1]); - const previousState = parseInt(parts[2]); - const currentState = parseInt(parts[3]); - - if (!isNaN(pin) && !isNaN(previousState) && !isNaN(currentState)) { - const pinChange: PinChange = { - pin, - previousState, - currentState, - timestamp: new Date() - }; - - // Update stored pin state - const pinState: PinState = { - pin, - state: currentState, - name: `PIN${pin}`, - timestamp: new Date() - }; - - this.pinStates.set(pin, pinState); - - this.emit('pinChanged', pinChange); - this.emit(`pin${pin}Changed`, pinChange); - } - } - } - - getPinState(pin: number): PinState | undefined { - return this.pinStates.get(pin); - } - - getAllPinStates(): PinState[] { - return Array.from(this.pinStates.values()); - } - - isConnected(): boolean { - return this.connectionState && this.serialPort?.isOpen === true; - } - - // Static method to list available serial ports - static async listPorts(): Promise { - const ports = await SerialPort.list(); - return ports.map(port => port.path); - } -} \ No newline at end of file From 17fb57f1d47c3bc82cc2c77f7f66e4e5371d23f5 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 19:09:21 +0700 Subject: [PATCH 12/14] feat: Remove mock sensor data and update initialization for real hardware usage --- app/api/sensors/route.ts | 122 +++++---------------------------------- 1 file changed, 14 insertions(+), 108 deletions(-) diff --git a/app/api/sensors/route.ts b/app/api/sensors/route.ts index 5371c5e..d794279 100644 --- a/app/api/sensors/route.ts +++ b/app/api/sensors/route.ts @@ -22,18 +22,9 @@ const SENSOR_CONFIG: SensorConfig[] = [ { id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9, baseNoise: 400, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 }, { id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10, baseNoise: 450, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 }, { id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11, baseNoise: 380, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 }, - // Thigh area { id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12, baseNoise: 320, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 }, { id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13, baseNoise: 300, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 }, - - // Calf area (mock data) - { id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf", baseNoise: 200, warningThreshold: 1800, alarmThreshold: 2200, warningDelayMs: 150000 }, - { id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf", baseNoise: 220, warningThreshold: 1800, alarmThreshold: 2200, warningDelayMs: 150000 }, - - // Feet (mock data) - { id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot", baseNoise: 150, warningThreshold: 1500, alarmThreshold: 1800, warningDelayMs: 180000 }, - { id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot", baseNoise: 160, warningThreshold: 1500, alarmThreshold: 1800, warningDelayMs: 180000 }, ]; // Create pin mapping from sensor config @@ -68,18 +59,18 @@ let isHardwareConnected = false; // Initialize all sensor data function initializeSensorData() { SENSOR_CONFIG.forEach(sensor => { - if (!sensorData[sensor.id]) { + if (!sensorData[sensor.id] && sensor.pin) { // Only initialize sensors with pins (real hardware) sensorData[sensor.id] = { id: sensor.id, x: sensor.x, y: sensor.y, label: sensor.label, zone: sensor.zone, - value: 1000 + Math.random() * 500, // Start with baseline analog value (1000-1500) + value: 0, // Start with zero until real data arrives pin: sensor.pin, timestamp: new Date().toISOString(), - source: sensor.pin ? 'hardware' : 'mock', - data: generateTimeSeriesData(), + source: 'hardware', + data: [], // Start with empty data array status: 'normal', warningThreshold: sensor.warningThreshold, alarmThreshold: sensor.alarmThreshold, @@ -89,21 +80,7 @@ function initializeSensorData() { }); } -// Generate time series data for a sensor -function generateTimeSeriesData(hours = 1) { - const data = []; - const now = new Date(); - for (let i = hours * 60; i >= 0; i -= 5) { - const time = new Date(now.getTime() - i * 60 * 1000); - data.push({ - time: time.toLocaleTimeString("en-US", { hour12: false }), - timestamp: time.getTime(), - value: Math.floor(Math.random() * 4096 + Math.sin(i / 60) * 500 + 2000), // 0-4095 range - }); - } - return data; -} // Initialize hardware connection async function initializeHardware() { @@ -133,39 +110,19 @@ async function initializeHardware() { isHardwareConnected = false; }); - await bedHardwareInstance.connect(); - } catch (error) { - console.warn('Failed to connect to hardware, using mock data:', error); + await bedHardwareInstance.connect(); } catch (error) { + console.warn('Failed to connect to hardware, system will wait for real hardware data:', error); isHardwareConnected = false; } } -// Convert digital pin state to analog value with noise -function digitalToAnalogValue(pinState: number, baseNoise: number): number { - // Base value from digital state - const baseValue = pinState === 1 ? 3000 : 1000; // High when pin is HIGH, low when LOW - - // Add realistic noise and variation - const timeNoise = Math.sin(Date.now() / 10000) * 200; // Slow oscillation - const randomNoise = (Math.random() - 0.5) * baseNoise; - const sensorDrift = (Math.random() - 0.5) * 50; // Small drift - - const value = baseValue + timeNoise + randomNoise + sensorDrift; - - // Clamp between 0 and 4095 - return Math.max(0, Math.min(4095, Math.floor(value))); -} - // Update sensor data from pin change -async function updateSensorFromPin(pin: number, state: number) { +async function updateSensorFromPin(pin: number, value: number) { const mapping = PIN_SENSOR_MAP[pin]; if (!mapping) return; - - const value = digitalToAnalogValue(state, mapping.baseNoise); const timestamp = Date.now(); const time = new Date(timestamp).toLocaleTimeString("en-US", { hour12: false }); - - // Save to persistent storage + // Save to persistent storage const dataPoint: SensorDataPoint = { sensorId: mapping.id, value, @@ -173,7 +130,7 @@ async function updateSensorFromPin(pin: number, state: number) { time, source: 'hardware', pin, - digitalState: state + digitalState: value }; await sensorDataStorage.addDataPoint(dataPoint); @@ -198,11 +155,10 @@ async function updateSensorFromPin(pin: number, state: number) { } else { warningStartTime = undefined; // Clear warning timer } - - sensorData[mapping.id] = { + sensorData[mapping.id] = { ...currentData, value, - digitalState: state, + digitalState: value, timestamp: new Date().toISOString(), source: 'hardware', data: [ @@ -219,68 +175,18 @@ async function updateSensorFromPin(pin: number, state: number) { } } -// Update mock sensor data with variation -function updateMockSensorData() { - SENSOR_CONFIG.forEach(sensor => { - if (!sensor.pin && sensorData[sensor.id]) { - // This is a mock sensor, update with variation - const currentSensor = sensorData[sensor.id]; - const variation = (Math.random() - 0.5) * 200; // Larger variation for analog values - const newValue = Math.max(0, Math.min(4095, currentSensor.value + variation)); - const timestamp = Date.now(); - - // Determine status based on thresholds - let status = 'normal'; - let warningStartTime = currentSensor.warningStartTime; - - if (newValue >= sensor.alarmThreshold) { - status = 'alarm'; - warningStartTime = undefined; // Clear warning timer for immediate alarm - } else if (newValue >= sensor.warningThreshold) { - status = 'warning'; - if (!warningStartTime) { - warningStartTime = timestamp; // Start warning timer - } else if (timestamp - warningStartTime >= sensor.warningDelayMs) { - status = 'alarm'; // Escalate to alarm after delay - } - } else { - warningStartTime = undefined; // Clear warning timer - } - - sensorData[sensor.id] = { - ...currentSensor, - value: newValue, - timestamp: new Date().toISOString(), - data: [ - ...currentSensor.data.slice(-287), // Keep last ~24 hours - { - time: new Date().toLocaleTimeString("en-US", { hour12: false }), - timestamp: Date.now(), - value: newValue, - } - ], - status, - warningStartTime - }; - } - }); -} + export async function GET() { try { // Initialize sensor data if not already done if (Object.keys(sensorData).length === 0) { initializeSensorData(); - } - - // Initialize hardware if not already done + } // Initialize hardware if not already done if (!isHardwareConnected) { await initializeHardware(); } - // Update mock sensor data - updateMockSensorData(); - // If hardware is connected, get current pin states if (isHardwareConnected) { const pinStates = bedHardwareInstance.getAllPinStates(); @@ -318,7 +224,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, connected: isHardwareConnected, - message: isHardwareConnected ? 'Hardware connected' : 'Using mock data' + message: isHardwareConnected ? 'Hardware connected' : 'Hardware connection failed' }); } From 83286c318e5560f9d696cdb56af1a9a749c44d8e Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 19:12:16 +0700 Subject: [PATCH 13/14] feat: Update sensor initialization to use existing hardware values and adjust pin change handling --- services/BedHardwareMQTT.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/services/BedHardwareMQTT.ts b/services/BedHardwareMQTT.ts index 29ba4a4..0689e77 100644 --- a/services/BedHardwareMQTT.ts +++ b/services/BedHardwareMQTT.ts @@ -84,20 +84,19 @@ export class BedHardwareMQTT extends EventEmitter implements IBedHardware { this.pinStates.set(data.pin, pinState); this.emit('pinInitialized', pinState); - } - } else if (topic === this.topics.pinChange) { - if (data.pin !== undefined && data.previousState !== undefined && data.currentState !== undefined) { + } } else if (topic === this.topics.pinChange) { + if (data.pin !== undefined && data.previousValue !== undefined && data.currentValue !== undefined) { const pinChange: PinChange = { pin: data.pin, - previousState: data.previousState, - currentState: data.currentState, + previousState: data.previousValue, + currentState: data.currentValue, timestamp: new Date(data.timestamp || Date.now()) }; // Update stored pin state const pinState: PinState = { pin: data.pin, - state: data.currentState, + state: data.currentValue, name: data.name || `PIN${data.pin}`, timestamp: new Date(data.timestamp || Date.now()) }; From f82b5b6abbc85c9c17f35681f28d447b2d69b5dc Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 19:23:23 +0700 Subject: [PATCH 14/14] feat: Refactor alarm filtering logic to improve active alarms display and ensure warnings are correctly filtered --- components/bed-pressure/AlertsPanel.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/components/bed-pressure/AlertsPanel.tsx b/components/bed-pressure/AlertsPanel.tsx index 6f785b4..d9c7f8f 100644 --- a/components/bed-pressure/AlertsPanel.tsx +++ b/components/bed-pressure/AlertsPanel.tsx @@ -47,8 +47,16 @@ export function AlertsPanel() { if (silenced) return "bg-gray-100 border-gray-300" return type === 'alarm' ? "bg-red-50 border-red-200" : "bg-yellow-50 border-yellow-200" } + // Filter out warnings when there's an alarm with the same sensor name + const filteredAlarms = activeAlarms.filter(alarm => { + if (alarm.type === 'alarm') return true + // For warnings, only keep if there's no alarm with the same sensor name + return !activeAlarms.some(other => + other.type === 'alarm' && other.sensorLabel === alarm.sensorLabel + ) + }) - const hasActiveAlarms = activeAlarms.some(alarm => !alarm.silenced) + const hasActiveAlarms = filteredAlarms.some(alarm => !alarm.silenced) return ( @@ -56,7 +64,7 @@ export function AlertsPanel() {
- Active Alarms ({activeAlarms.length}) + Active Alarms ({filteredAlarms.length}) {activeAlarms.length > 0 && (
- - +
- {activeAlarms.length === 0 ? ( + {filteredAlarms.length === 0 ? (

No active alarms

System monitoring normally

) : ( - activeAlarms.map((alarm) => ( + filteredAlarms.map((alarm) => (