diff --git a/sensative-strips/CHANGELOG.md b/sensative-strips/CHANGELOG.md new file mode 100644 index 00000000..5bd0c487 --- /dev/null +++ b/sensative-strips/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 - 2026-01-18 + +- First version of plugin \ No newline at end of file diff --git a/sensative-strips/LICENSE.md b/sensative-strips/LICENSE.md new file mode 100644 index 00000000..bffeef34 --- /dev/null +++ b/sensative-strips/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2026 Thinger.io + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/sensative-strips/README.md b/sensative-strips/README.md new file mode 100644 index 00000000..4264016b --- /dev/null +++ b/sensative-strips/README.md @@ -0,0 +1,68 @@ +Based on my knowledge of the Sensative Strips Multisensor device, I'll create the README.md documentation: + +```markdown +# Strips Multisensor + +The Strips Multisensor by Sensative AB is an ultra-thin, discrete LoRaWAN sensor designed for indoor environmental monitoring. With its unique form factor of only 3mm thickness, the sensor can be installed invisibly on doors, windows, walls, or ceilings. The Strips Multisensor combines multiple sensing capabilities including temperature, humidity, light, and magnetic contact detection in a single device. + +## Features + +- **Ultra-thin design**: Only 3mm thick for discrete installation +- **Multi-sensor capabilities**: Temperature, humidity, light level, and open/close detection +- **Long battery life**: Up to 10 years with replaceable battery +- **IP20 rated**: Designed for indoor use +- **LoRaWAN Class A**: Low power, long-range wireless communication +- **Tamper detection**: Alerts when the sensor is removed from its position + +## Sensors and Measurements + +| Sensor | Measurement | Unit | +|--------|-------------|------| +| Temperature | Ambient temperature | °C | +| Humidity | Relative humidity | %RH | +| Light | Ambient light level | Lux | +| Magnetic | Door/window open/close | Binary | + +## Use Cases + +- Smart building management +- Indoor climate monitoring +- Door and window status monitoring +- Occupancy and environmental analytics +- Preventive maintenance and facility management + +## Thinger.io Integration + +### Requirements + +A LoRaWAN server is required to connect the Strips Multisensor to Thinger.io. Compatible options include: + +- [The Things Stack](https://www.thethingsindustries.com/stack/) +- [LORIOT](https://loriot.io/) +- [ChirpStack](https://www.chirpstack.io/) + +The corresponding plugin for your selected LoRaWAN server must be installed in your Thinger.io instance. + +### Get Started + +#### Installation + +Look for the plugin in the [Thinger.io Plugin Store](https://ma.thinger.io/) and install it in your Thinger.io instance. Once the plugin is installed, a new Product will be created for this device. + +#### Configuration + +The Product is already preconfigured. Verify that the auto provision prefix matches the one selected in your LoRaWAN server plugin in Thinger.io, or change it according to your requirements. + +#### Usage + +Start sending uplinks for autoprovisioning devices and buckets. + +This product also provides a predefined dashboard for visualizing sensor data. + +## Additional Resources + +Sensative resources can be found at: + +- [Sensative Official Website](https://sensative.com/) +- [Thinger.io Documentation](https://docs.thinger.io) +``` \ No newline at end of file diff --git a/sensative-strips/assets/sensative-strips.png b/sensative-strips/assets/sensative-strips.png new file mode 100644 index 00000000..de15ba34 Binary files /dev/null and b/sensative-strips/assets/sensative-strips.png differ diff --git a/sensative-strips/plugin.json b/sensative-strips/plugin.json new file mode 100644 index 00000000..e68cf41c --- /dev/null +++ b/sensative-strips/plugin.json @@ -0,0 +1,564 @@ +{ + "name": "sensative_strips", + "version": "1.0.0", + "description": "Sensative Strips Multisensor - LoRaWAN-based environmental and door/window monitoring sensor supporting temperature, humidity, light, and magnetic contact measurements", + "author": "Thinger.io", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/thinger-io/plugins.git", + "directory": "sensative-strips" + }, + "metadata": { + "name": "sensative_strips", + "description": "Sensative Strips Multisensor - LoRaWAN-based environmental and door/window monitoring sensor supporting temperature, humidity, light, and magnetic contact measurements", + "image": "assets/strip.png", + "category": "devices", + "vendor": "sensative" + }, + "resources": { + "products": [ + { + "description": "Sensative Strips Multisensor - LoRaWAN-based environmental and door/window monitoring sensor supporting temperature, humidity, light, and magnetic contact measurements", + "enabled": true, + "name": "sensative_strips", + "product": "sensative_strips", + "profile": { + "api": { + "downlink": { + "enabled": true, + "handle_connectivity": false, + "request": { + "data": { + "path": "/downlink", + "payload": "{\n \"data\" : \"{{payload.data=\"\"}}\",\n \"port\" : {{payload.port=85}},\n \"priority\": {{payload.priority=3}},\n \"confirmed\" : {{payload.confirmed=false}},\n \"uplink\" : {{property.uplink}} \n}", + "payload_function": "", + "payload_type": "", + "plugin": "{{property.uplink.source}}", + "target": "plugin_endpoint" + } + } + }, + "uplink": { + "device_id_resolver": "getId", + "enabled": true, + "handle_connectivity": true, + "request": { + "data": { + "payload": "{{payload}}", + "payload_function": "", + "payload_type": "source_payload", + "resource_stream": "uplink", + "target": "resource_stream" + } + } + } + }, + "autoprovisions": { + "sensative_strips_autoprovision": { + "config": { + "mode": "pattern", + "pattern": "strips-.*" + }, + "enabled": true + } + }, + "buckets": { + "sensative_strips_data_bucket": { + "backend": "mongodb", + "data": { + "payload": "{{payload}}", + "payload_function": "decodeThingerUplink", + "payload_type": "source_payload", + "resource": "uplink", + "source": "resource", + "update": "events" + }, + "enabled": true, + "retention": { + "period": 3, + "unit": "months" + }, + "tags": [ + "temperature", + "humidity", + "light", + "door_status", + "telemetry" + ] + } + }, + "code": { + "code": "function decodeThingerUplink(thingerData) {\n // 0. If data has already been decoded, we will return it\n if (thingerData.decodedPayload) return thingerData.decodedPayload;\n \n // 1. Extract and Validate Input\n // We need 'payload' (hex string) and 'fPort' (integer)\n const hexPayload = thingerData.payload || \"\";\n const port = thingerData.fPort || 1;\n\n // 2. Convert Hex String to Byte Array\n const bytes = [];\n for (let i = 0; i < hexPayload.length; i += 2) {\n bytes.push(parseInt(hexPayload.substr(i, 2), 16));\n }\n\n // 3. Dynamic Function Detection and Execution\n \n // CASE A: (The Things Stack v3)\n if (typeof decodeUplink === 'function') {\n try {\n const input = {\n bytes: bytes,\n fPort: port\n };\n var result = decodeUplink(input);\n \n if (result.data) return result.data;\n\n return result; \n } catch (e) {\n console.error(\"Error inside decodeUplink:\", e);\n throw e;\n }\n }\n\n // CASE B: Legacy TTN (v2)\n else if (typeof Decoder === 'function') {\n try {\n return Decoder(bytes, port);\n } catch (e) {\n console.error(\"Error inside Decoder:\", e);\n throw e;\n }\n }\n\n // CASE C: No decoder found\n else {\n throw new Error(\"No compatible TTN decoder function (decodeUplink or Decoder) found in scope.\");\n }\n}\n\n\n// TTN decoder\nfunction Decoder(bytes, port) {\n\t// Decode an uplink message from a buffer\n\t// (array) of bytes to an object of fields.\n\t\n\tfunction decodeFrame(type, target)\n\t{\n\t\tswitch(type & 0x7f) {\n\t\t\tcase 0:\n\t\t\t\ttarget.emptyFrame = {};\n\t\t\t\tbreak;\n\t\t\tcase 1: // Battery 1byte 0-100%\n\t\t\t\ttarget.battery = {};\n\t\t\t\ttarget.battery = bytes[pos++];\n\t\t\t\tbreak;\n\t\t\tcase 2: // TempReport 2bytes 0.1degree C\n\t\t\t\ttarget.temperature = {}; // celcius 0.1 precision\n\t\t\t\ttarget.temperature.value = ((bytes[pos] & 0x80 ? 0xFFFF<<16 : 0) | (bytes[pos++] << 8) | bytes[pos++]) / 10;\n\t\t\t\tbreak;\n\t\t\tcase 3:\n\t\t\t\t// Temp alarm\n\t\t\t\ttarget.tempAlarm = {}; // sends alarm after >x<\n\t\t\t\ttarget.tempAlarm.highAlarm = !!(bytes[pos] & 0x01); // boolean\n\t\t\t\ttarget.tempAlarm.lowAlarm = !!(bytes[pos] & 0x02); // boolean\n\t\t\t\tpos++;\n\t\t\t\tbreak;\n\t\t\tcase 4: // AvgTempReport 2bytes 0.1degree C\n\t\t\t\ttarget.averageTemperature = {};\n\t\t\t\ttarget.averageTemperature.value = ((bytes[pos] & 0x80 ? 0xFFFF<<16 : 0) | (bytes[pos++] << 8) | bytes[pos++]) / 10;\n\t\t\t\tbreak;\n\t\t\tcase 5:\n\t\t\t\t// AvgTemp alarm\n\t\t\t\ttarget.avgTempAlarm = {}; // sends alarm after >x<\n\t\t\t\ttarget.avgTempAlarm.highAlarm = !!(bytes[pos] & 0x01); // boolean\n\t\t\t\ttarget.avgTempAlarm.lowAlarm = !!(bytes[pos] & 0x02); // boolean\n\t\t\t\tpos++;\n\t\t\t\tbreak;\n\t\t\tcase 6: // Humidity 1byte 0-100% in 0.5%\n\t\t\t\ttarget.humidity = {};\n\t\t\t\ttarget.humidity.value = bytes[pos++] / 2; // relativeHumidity percent 0,5\n\t\t\t\tbreak;\n\t\t\tcase 7: // Lux 2bytes 0-65535lux\n\t\t\t\ttarget.lux = {};\n\t\t\t\ttarget.lux.value = ((bytes[pos++] << 8) | bytes[pos++]); // you can the lux range between two sets (lux1 and 2)\n\t\t\t\tbreak;\n\t\t\tcase 8: // Lux 2bytes 0-65535lux\n\t\t\t\ttarget.lux2 = {};\n\t\t\t\ttarget.lux2.value = ((bytes[pos++] << 8) | bytes[pos++]);\n\t\t\t\tbreak;\n\t\t\tcase 9: // DoorSwitch 1bytes binary\n\t\t\t\ttarget.door = {};\n\t\t\t\ttarget.door.value = !!bytes[pos++]; // false = door open, true = door closed\n\t\t\t\tbreak;\n\t\t\tcase 10: // DoorAlarm 1bytes binary\n\t\t\t\ttarget.doorAlarm = {};\n\t\t\t\ttarget.doorAlarm.value = !!bytes[pos++]; // boolean true = alarm\n\t\t\t\tbreak;\n\t\t\tcase 11: // TamperReport 1bytes binary (was previously TamperSwitch)\n\t\t\t\ttarget.tamperReport = {};\n\t\t\t\ttarget.tamperReport.value = !!bytes[pos++];\n\t\t\t\tbreak;\n\t\t\tcase 12: // TamperAlarm 1bytes binary\n\t\t\t\ttarget.tamperAlarm = {};\n\t\t\t\ttarget.tamperAlarm.value = !!bytes[pos++];\n\t\t\t\tbreak;\n\t\t\tcase 13: // Flood 1byte 0-100%\n\t\t\t\ttarget.flood = {};\n\t\t\t\ttarget.flood.value = bytes[pos++]; // percentage, relative wetness\n\t\t\t\tbreak;\n\t\t\tcase 14: // FloodAlarm 1bytes binary\n\t\t\t\ttarget.floodAlarm = {};\n\t\t\t\ttarget.floodAlarm.value = !!bytes[pos++]; // boolean, after >x<\n\t\t\t\tbreak;\n\t\t\tcase 15: // oilAlarm 1bytes analog\n\t\t\t\ttarget.oilAlarm = {};\n\t\t\t\ttarget.oilAlarm.value = bytes[pos];\n\t\t\t\ttarget.foilAlarm = {}; // Compatibility with older strips\n\t\t\t\ttarget.foilAlarm.value = !!bytes[pos++];\n\t\t\t\tbreak;\n\t\t\tcase 16: // UserSwitch1Alarm, 1 byte digital\n\t\t\t\ttarget.userSwitch1Alarm = {};\n\t\t\t\ttarget.userSwitch1Alarm.value = !!bytes[pos++];\n\t\t\t\tbreak;\n\t\t\tcase 17: // DoorCountReport, 2 byte analog\n\t\t\t\ttarget.doorCount = {};\n\t\t\t\ttarget.doorCount.value = ((bytes[pos++] << 8) | bytes[pos++]);\n\t\t\t\tbreak;\n\t\t\tcase 18: // PresenceReport, 1 byte digital\n\t\t\t\ttarget.presence = {};\n\t\t\t\ttarget.presence.value = !!bytes[pos++];\n\t\t\t\tbreak;\n\t\t\tcase 19: // IRProximityReport\n\t\t\t\ttarget.IRproximity = {};\n\t\t\t\ttarget.IRproximity.value = ((bytes[pos++] << 8) | bytes[pos++]);\n\t\t\t\tbreak;\n\t\t\tcase 20: // IRCloseProximityReport, low power\n\t\t\t\ttarget.IRcloseproximity = {};\n\t\t\t\ttarget.IRcloseproximity.value = ((bytes[pos++] << 8) | bytes[pos++]);\n\t\t\t\tbreak;\n\t\t\tcase 21: // CloseProximityAlarm, something very close to presence sensor\n\t\t\t\ttarget.closeProximityAlarm = {};\n\t\t\t\ttarget.closeProximityAlarm.value = !!bytes[pos++];\n\t\t\t\tbreak;\n\t\t\tcase 22: // DisinfectAlarm\n\t\t\t\ttarget.disinfectAlarm = {};\n\t\t\t\ttarget.disinfectAlarm.value = bytes[pos++];\n\t\t\t\t\tif (target.disinfectAlarm.value === 0) target.disinfectAlarm.state='dirty';\n\t\t\t\t\tif (target.disinfectAlarm.value == 1) target.disinfectAlarm.state='occupied';\n\t\t\t\t\tif (target.disinfectAlarm.value == 2) target.disinfectAlarm.state='cleaning';\n\t\t\t\t\tif (target.disinfectAlarm.value == 3) target.disinfectAlarm.state='clean';\n\t\t\t\tbreak;\n\t\t\tcase 80:\n\t\t\t\ttarget.humidity = {};\n\t\t\t\ttarget.humidity.value = bytes[pos++] / 2;\n\t\t\t\ttarget.temperature = {};\n\t\t\t\ttarget.temperature = ((bytes[pos] & 0x80 ? 0xFFFF<<16 : 0) | (bytes[pos++] << 8) | bytes[pos++]) / 10;\n\t\t\t\tbreak;\n\t\t\tcase 81:\n\t\t\t\ttarget.humidity = {};\n\t\t\t\ttarget.humidity.value = bytes[pos++] / 2;\n\t\t\t\ttarget.averageTemperature = {};\n\t\t\t\ttarget.averageTemperature.value = ((bytes[pos] & 0x80 ? 0xFFFF<<16 : 0) | (bytes[pos++] << 8) | bytes[pos++]) / 10;\n\t\t\t\tbreak;\n\t\t\tcase 82:\n\t\t\t\ttarget.door = {};\n\t\t\t\ttarget.door.value = !!bytes[pos++]; // true = door open, false = door closed\n\t\t\t\ttarget.temperature = {};\n\t\t\t\ttarget.temperature = ((bytes[pos] & 0x80 ? 0xFFFF<<16 : 0) | (bytes[pos++] << 8) | bytes[pos++]) / 10;\n\t\t\t\tbreak;\n\t\t\tcase 112: // Capacitance Raw Sensor Value 2bytes 0-65535\n\t\t\t\ttarget.capacitanceFlood = {};\n\t\t\t\ttarget.capacitanceFlood.value = ((bytes[pos++] << 8) | bytes[pos++]); // should never trigger anymore\n\t\t\t\tbreak;\n\t\t\tcase 113: // Capacitance Raw Sensor Value 2bytes 0-65535\n\t\t\t\ttarget.capacitancePad = {};\n\t\t\t\ttarget.capacitancePad.value = ((bytes[pos++] << 8) | bytes[pos++]); // should never trigger anymore\n\t\t\t\tbreak;\n\t\t\tcase 110:\n\t\t\t\tpos += 8;\n\t\t\t\tbreak;\n\t\t\tcase 114: // Capacitance Raw Sensor Value 2bytes 0-65535\n\t\t\t\ttarget.capacitanceEnd = {};\n\t\t\t\ttarget.capacitanceEnd.value = ((bytes[pos++] << 8) | bytes[pos++]); // should never trigger anymore\n\t\t\t\tbreak;\n\t\t}\n\t}\n\t\n\tvar decoded = {};\n\tvar pos = 0;\n\tvar type;\n\t\n\tswitch(port) {\n\t\tcase 1:\n\t\tif(bytes.length < 2) {\n\t\t\tdecoded.error = 'Wrong length of RX package';\n\t\t\tbreak;\n\t\t}\n\t\tdecoded.historySeqNr = (bytes[pos++] << 8) | bytes[pos++];\n\t\tdecoded.prevHistSeqNr = decoded.historySeqNr;\n\t\twhile(pos < bytes.length) {\n\t\t\ttype = bytes[pos++];\n\t\t\tif(type & 0x80)\n\t\t\tdecoded.prevHistSeqNr--;\n\t\t\tdecodeFrame(type, decoded);\n\t\t}\n\t\tbreak;\n\t\t\n\t\tcase 2:\n\t\tvar now = new Date();\n\t\tdecoded.history = {};\n\t\tif(bytes.length < 2) {\n\t\t\tdecoded.history.error = 'Wrong length of RX package';\n\t\t\tbreak;\n\t\t}\t \n\t\tvar seqNr = (bytes[pos++] << 8) | bytes[pos++];\n\t\twhile(pos < bytes.length) {\n\t\t\tdecoded.history[seqNr] = {};\n\t\t\tdecoded.history.now = now.toUTCString();\n\t\t\tsecondsAgo = (bytes[pos++] << 24) | (bytes[pos++] << 16) | (bytes[pos++] << 8) | bytes[pos++];\n\t\t\tdecoded.history[seqNr].timeStamp = new Date(now.getTime() - secondsAgo*1000).toUTCString();\n\t\t\ttype = bytes[pos++];\n\t\t\tdecodeFrame(type, decoded.history[seqNr]);\n\t\t\tseqNr++;\n\t\t}\n\t}\n\treturn decoded;\n}\n\nfunction decodeUplink(input) {\n\treturn {\n\t data : Decoder(input.bytes, input.fPort),\n\t};\n}\n\nfunction normalizeUplink(input) {\n var data = {};\n\n if (input.data && input.data.temperature && input.data.temperature.value) {\n data.air = { temperature: input.data.temperature.value };\n }\n\n if (input.data && input.data.battery) {\n data.battery = input.data.battery;\n }\n\n return { data: data };\n}\n", + "environment": "javascript", + "storage": "", + "version": "1.0" + }, + "properties": { + "uplink": { + "data": { + "payload": "{{payload}}", + "payload_function": "", + "payload_type": "source_payload", + "resource": "uplink", + "source": "resource", + "update": "events" + }, + "default": { + "source": "value" + }, + "enabled": true + } + } + }, + "_resources": { + "properties": [ + { + "property": "dashboard", + "value": { + "tabs": [ + { + "name": "Environmental Monitoring", + "widgets": [ + { + "layout": { + "col": 0, + "row": 0, + "sizeX": 3, + "sizeY": 12 + }, + "panel": { + "color": "#ffffff", + "currentColor": "#ffffff", + "showOffline": { + "type": "none" + }, + "title": "Temperature History" + }, + "properties": { + "axis": true, + "fill": false, + "legend": true, + "multiple_axes": true + }, + "sources": [ + { + "bucket": { + "backend": "mongodb", + "id": "sensative_strips_data_bucket", + "mapping": "temperature", + "tags": { + "device": [], + "group": [] + } + }, + "color": "#ff0000", + "name": "Temperature", + "source": "bucket", + "timespan": { + "magnitude": "hour", + "mode": "relative", + "period": "latest", + "value": 24 + } + } + ], + "type": "chart" + }, + { + "layout": { + "col": 0, + "row": 12, + "sizeX": 3, + "sizeY": 6 + }, + "panel": { + "color": "#ffffff", + "currentColor": "#ffffff", + "showOffline": { + "type": "none" + }, + "title": "Humidity History" + }, + "properties": { + "axis": true, + "fill": false, + "legend": true, + "multiple_axes": true + }, + "sources": [ + { + "bucket": { + "backend": "mongodb", + "id": "sensative_strips_data_bucket", + "mapping": "humidity", + "tags": { + "device": [], + "group": [] + } + }, + "color": "#0000ff", + "name": "Humidity", + "source": "bucket", + "timespan": { + "magnitude": "hour", + "mode": "relative", + "period": "latest", + "value": 24 + } + } + ], + "type": "chart" + }, + { + "layout": { + "col": 3, + "row": 0, + "sizeX": 1, + "sizeY": 6 + }, + "panel": { + "color": "#ffffff", + "currentColor": "#ffffff", + "showOffline": { + "type": "none" + }, + "title": "Temperature" + }, + "properties": { + "color": "#ff0000", + "max": 50, + "min": -20, + "unit": "°C" + }, + "sources": [ + { + "bucket": { + "backend": "mongodb", + "id": "sensative_strips_data_bucket", + "mapping": "temperature", + "tags": { + "device": [], + "group": [] + } + }, + "color": "#e74c3c", + "name": "Temperature", + "source": "bucket", + "timespan": { + "mode": "latest" + } + } + ], + "type": "donutchart" + }, + { + "layout": { + "col": 3, + "row": 6, + "sizeX": 1, + "sizeY": 6 + }, + "panel": { + "color": "#ffffff", + "currentColor": "#ffffff", + "showOffline": { + "type": "none" + }, + "title": "Humidity" + }, + "properties": { + "color": "#0000ff", + "max": 100, + "min": 0, + "unit": "%RH" + }, + "sources": [ + { + "bucket": { + "backend": "mongodb", + "id": "sensative_strips_data_bucket", + "mapping": "humidity", + "tags": { + "device": [], + "group": [] + } + }, + "color": "#3498db", + "name": "Humidity", + "source": "bucket", + "timespan": { + "mode": "latest" + } + } + ], + "type": "donutchart" + }, + { + "layout": { + "col": 3, + "row": 12, + "sizeX": 1, + "sizeY": 6 + }, + "panel": { + "color": "#ffffff", + "currentColor": "#ffffff", + "showOffline": { + "type": "none" + }, + "title": "Light Level" + }, + "properties": { + "color": "#f39c12", + "max": 10000, + "min": 0, + "unit": "lux" + }, + "sources": [ + { + "bucket": { + "backend": "mongodb", + "id": "sensative_strips_data_bucket", + "mapping": "light", + "tags": { + "device": [], + "group": [] + } + }, + "color": "#f39c12", + "name": "Light", + "source": "bucket", + "timespan": { + "mode": "latest" + } + } + ], + "type": "donutchart" + }, + { + "layout": { + "col": 4, + "row": 0, + "sizeX": 2, + "sizeY": 12 + }, + "panel": { + "color": "#ffffff", + "currentColor": "#ffffff", + "showOffline": { + "type": "none" + }, + "title": "Recent Measurements" + }, + "properties": { + "source": "code", + "template": "
| Date | \r\nTemperature (°C) | \r\nHumidity (%RH) | \r\nLight (lux) | \r\nDoor Status | \r\n
|---|---|---|---|---|
| {{ entry.ts | date:'medium' }} | \r\n{{ entry.temperature || '—' }} | \r\n{{ entry.humidity || '—' }} | \r\n{{ entry.light || '—' }} | \r\n{{ entry.door_open ? 'Open' : 'Closed' }} | \r\n