From 6b8bdf01100fb4e995d001af044fae360b53f961 Mon Sep 17 00:00:00 2001 From: Ars_Codicis <100168416+arscodicis@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:51:49 -0400 Subject: [PATCH 1/2] milesight: add am308 device profile to vendor.toml --- vendors/milesight/codecs/am308.js | 488 ++++++++++++++++++ .../milesight/codecs/test_decode_am308.json | 55 ++ .../milesight/codecs/test_encode_am308.json | 1 + .../devices/milesight-am308-915m.toml | 16 + vendors/milesight/vendor.toml | 1 + 5 files changed, 561 insertions(+) create mode 100644 vendors/milesight/codecs/am308.js create mode 100644 vendors/milesight/codecs/test_decode_am308.json create mode 100644 vendors/milesight/codecs/test_encode_am308.json create mode 100644 vendors/milesight/devices/milesight-am308-915m.toml diff --git a/vendors/milesight/codecs/am308.js b/vendors/milesight/codecs/am308.js new file mode 100644 index 0000000..58b7c9b --- /dev/null +++ b/vendors/milesight/codecs/am308.js @@ -0,0 +1,488 @@ +/** + * Payload Decoder + * + * Copyright 2025 Milesight IoT + * + * @product AM308(v2) + */ +var RAW_VALUE = 0x00; + +/* eslint no-redeclare: "off" */ +/* eslint-disable */ +// Chirpstack v4 +function decodeUplink(input) { + var decoded = milesightDeviceDecode(input.bytes); + return { data: decoded }; +} + +// Chirpstack v3 +function Decode(fPort, bytes) { + return milesightDeviceDecode(bytes); +} + +// The Things Network +function Decoder(bytes, port) { + return milesightDeviceDecode(bytes); +} +/* eslint-enable */ + +function milesightDeviceDecode(bytes) { + var decoded = {}; + + for (var i = 0; i < bytes.length; ) { + var channel_id = bytes[i++]; + var channel_type = bytes[i++]; + + // IPSO VERSION + if (channel_id === 0xff && channel_type === 0x01) { + decoded.ipso_version = readProtocolVersion(bytes[i]); + i += 1; + } + // HARDWARE VERSION + else if (channel_id === 0xff && channel_type === 0x09) { + decoded.hardware_version = readHardwareVersion(bytes.slice(i, i + 2)); + i += 2; + } + // FIRMWARE VERSION + else if (channel_id === 0xff && channel_type === 0x0a) { + decoded.firmware_version = readFirmwareVersion(bytes.slice(i, i + 2)); + i += 2; + } + // DEVICE STATUS + else if (channel_id === 0xff && channel_type === 0x0b) { + decoded.device_status = readDeviceStatus(bytes[i]); + i += 1; + } + // LORAWAN CLASS + else if (channel_id === 0xff && channel_type === 0x0f) { + decoded.lorawan_class = readLoRaWANClass(bytes[i]); + i += 1; + } + // PRODUCT SERIAL NUMBER + else if (channel_id === 0xff && channel_type === 0x16) { + decoded.sn = readSerialNumber(bytes.slice(i, i + 8)); + i += 8; + } + // TSL VERSION + else if (channel_id === 0xff && channel_type === 0xff) { + decoded.tsl_version = readTslVersion(bytes.slice(i, i + 2)); + i += 2; + } + // BATTERY + else if (channel_id === 0x01 && channel_type === 0x75) { + decoded.battery = readUInt8(bytes[i]); + i += 1; + } + // TEMPERATURE + else if (channel_id === 0x03 && channel_type === 0x67) { + // °C + decoded.temperature = readInt16LE(bytes.slice(i, i + 2)) / 10; + i += 2; + } + // HUMIDITY + else if (channel_id === 0x04 && channel_type === 0x68) { + decoded.humidity = readUInt8(bytes[i]) / 2; + i += 1; + } + // PIR + else if (channel_id === 0x05 && channel_type === 0x00) { + decoded.pir = readPIRStatus(bytes[i]); + i += 1; + } + // LIGHT + else if (channel_id === 0x06 && channel_type === 0xcb) { + decoded.light_level = readUInt8(bytes[i]); + i += 1; + } + // CO2 + else if (channel_id === 0x07 && channel_type === 0x7d) { + decoded.co2 = readUInt16LE(bytes.slice(i, i + 2)); + i += 2; + } + // TVOC (iaq) + else if (channel_id === 0x08 && channel_type === 0x7d) { + decoded.tvoc = readUInt16LE(bytes.slice(i, i + 2)) / 100; + i += 2; + } + // TVOC (µg/m³) + else if (channel_id === 0x08 && channel_type === 0xe6) { + decoded.tvoc = readUInt16LE(bytes.slice(i, i + 2)); + i += 2; + } + // PRESSURE + else if (channel_id === 0x09 && channel_type === 0x73) { + decoded.pressure = readUInt16LE(bytes.slice(i, i + 2)) / 10; + i += 2; + } + // PM2.5 + else if (channel_id === 0x0b && channel_type === 0x7d) { + decoded.pm2_5 = readUInt16LE(bytes.slice(i, i + 2)); + i += 2; + } + // PM10 + else if (channel_id === 0x0c && channel_type === 0x7d) { + decoded.pm10 = readUInt16LE(bytes.slice(i, i + 2)); + i += 2; + } + // BEEP + else if (channel_id === 0x0e && channel_type === 0x01) { + decoded.buzzer_status = readBuzzerStatus(bytes[i]); + i += 1; + } + // HISTORY DATA (AM308) + else if (channel_id === 0x20 && channel_type === 0xce) { + var data = {}; + data.timestamp = readUInt32LE(bytes.slice(i, i + 4)); + data.temperature = readInt16LE(bytes.slice(i + 4, i + 6)) / 10; + data.humidity = readUInt16LE(bytes.slice(i + 6, i + 8)) / 2; + data.pir = readPIRStatus(bytes[i + 8]); + data.light_level = readUInt8(bytes[i + 9]); + data.co2 = readUInt16LE(bytes.slice(i + 10, i + 12)); + // unit: iaq + data.tvoc = readUInt16LE(bytes.slice(i + 12, i + 14)) / 100; + data.pressure = readUInt16LE(bytes.slice(i + 14, i + 16)) / 10; + data.pm2_5 = readUInt16LE(bytes.slice(i + 16, i + 18)); + data.pm10 = readUInt16LE(bytes.slice(i + 18, i + 20)); + i += 20; + + decoded.history = decoded.history || []; + decoded.history.push(data); + } + // HISTORY DATA (AM308) with tvoc unit: µg/m³ + else if (channel_id === 0x21 && channel_type === 0xce) { + var data = {}; + data.timestamp = readUInt32LE(bytes.slice(i, i + 4)); + data.temperature = readInt16LE(bytes.slice(i + 4, i + 6)) / 10; + data.humidity = readUInt16LE(bytes.slice(i + 6, i + 8)) / 2; + data.pir = readPIRStatus(bytes[i + 8]); + data.light_level = readUInt8(bytes[i + 9]); + data.co2 = readUInt16LE(bytes.slice(i + 10, i + 12)); + // unit: µg/m³ + data.tvoc = readUInt16LE(bytes.slice(i + 12, i + 14)); + data.pressure = readUInt16LE(bytes.slice(i + 14, i + 16)) / 10; + data.pm2_5 = readUInt16LE(bytes.slice(i + 16, i + 18)); + data.pm10 = readUInt16LE(bytes.slice(i + 18, i + 20)); + i += 20; + + decoded.history = decoded.history || []; + decoded.history.push(data); + } + // RESPONSE DATA + else if (channel_id === 0xfe || channel_id === 0xff) { + var result = handle_downlink_response(channel_type, bytes, i); + decoded = Object.assign(decoded, result.data); + i = result.offset; + } else { + break; + } + } + + return decoded; +} + +function handle_downlink_response(channel_type, bytes, offset) { + var decoded = {}; + + switch (channel_type) { + case 0x03: + decoded.report_interval = readUInt16LE(bytes.slice(offset, offset + 2)); + offset += 2; + break; + case 0x10: + decoded.reboot = readYesNoStatus(1); + offset += 1; + break; + case 0x17: + decoded.time_zone = readTimeZone(readInt16LE(bytes.slice(offset, offset + 2))); + offset += 2; + break; + case 0x1a: + var mode_value = readUInt8(bytes[offset]); + decoded.co2_calibration_settings = {}; + decoded.co2_calibration_settings.mode = readCalibrationMode(mode_value); + if (mode_value === 2) { + decoded.co2_calibration_settings.calibration_value = readUInt16LE(bytes.slice(offset + 1, offset + 3)); + offset += 3; + } else { + offset += 1; + } + break; + case 0x25: + decoded.child_lock_settings = readChildLockSettings(bytes[offset]); + offset += 1; + break; + case 0x27: + decoded.clear_history = readYesNoStatus(1); + offset += 1; + break; + case 0x2c: + decoded.query_status = readYesNoStatus(1); + offset += 1; + break; + case 0x2d: + decoded.screen_display_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x2e: + decoded.led_indicator_mode = readLedIndicatorMode(bytes[offset]); + offset += 1; + break; + case 0xeb: + decoded.tvoc_unit = readTVOCUnit(bytes[offset]); + offset += 1; + break; + case 0x39: + decoded.co2_abc_calibration_enable = readEnableStatus(bytes[offset]); + // skip 4 bytes + offset += 5; + break; + case 0x3a: + decoded.report_interval = readUInt16LE(bytes.slice(offset, offset + 2)); + offset += 2; + break; + case 0x3b: + decoded.time_sync_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x3c: + decoded.screen_display_pattern = bytes[offset]; + offset += 1; + break; + case 0x3d: + decoded.stop_buzzer = readYesNoStatus(1); + offset += 1; + break; + case 0x3e: + decoded.buzzer_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x65: + decoded.pm2_5_collection_interval = readUInt16LE(bytes.slice(offset, offset + 2)); + offset += 2; + break; + case 0x66: + decoded.screen_display_alarm_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x68: + decoded.history_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x69: + decoded.retransmit_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x6a: + var interval_type = readUInt8(bytes[offset]); + if (interval_type === 0) { + decoded.retransmit_interval = readUInt16LE(bytes.slice(offset + 1, offset + 3)); + } else if (interval_type === 1) { + decoded.resend_interval = readUInt16LE(bytes.slice(offset + 1, offset + 3)); + } + offset += 3; + break; + case 0x6d: + decoded.stop_transmit = readYesNoStatus(1); + offset += 1; + break; + case 0xf0: + decoded.screen_display_element_settings = readScreenDisplayElementSettings(bytes.slice(offset, offset + 4)); + offset += 4; + break; + case 0xf4: + decoded.co2_calibration_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + default: + throw new Error("unknown downlink response"); + } + + return { data: decoded, offset: offset }; +} + +function readProtocolVersion(bytes) { + var major = (bytes & 0xf0) >> 4; + var minor = bytes & 0x0f; + return "v" + major + "." + minor; +} + +function readHardwareVersion(bytes) { + var major = (bytes[0] & 0xff).toString(16); + var minor = (bytes[1] & 0xff) >> 4; + return "v" + major + "." + minor; +} + +function readFirmwareVersion(bytes) { + var major = (bytes[0] & 0xff).toString(16); + var minor = (bytes[1] & 0xff).toString(16); + return "v" + major + "." + minor; +} + +function readTslVersion(bytes) { + var major = bytes[0] & 0xff; + var minor = bytes[1] & 0xff; + return "v" + major + "." + minor; +} + +function readDeviceStatus(type) { + var device_status_map = { 0: "off", 1: "on" }; + return getValue(device_status_map, type); +} + +function readSerialNumber(bytes) { + var temp = []; + for (var idx = 0; idx < bytes.length; idx++) { + temp.push(("0" + (bytes[idx] & 0xff).toString(16)).slice(-2)); + } + return temp.join(""); +} + +function readLoRaWANClass(type) { + var lorawan_class_map = { + 0: "Class A", + 1: "Class B", + 2: "Class C", + 3: "Class CtoB", + }; + return getValue(lorawan_class_map, type); +} + +function readPIRStatus(type) { + var pir_status_map = { 0: "idle", 1: "trigger" }; + return getValue(pir_status_map, type); +} + +function readBuzzerStatus(type) { + var buzzer_status_map = { 0: "off", 1: "on" }; + return getValue(buzzer_status_map, type); +} + +function readYesNoStatus(status) { + var yes_no_map = { 0: "no", 1: "yes" }; + return getValue(yes_no_map, status); +} + +function readEnableStatus(status) { + var status_map = { 0: "disable", 1: "enable" }; + return getValue(status_map, status); +} + +function readTimeZone(time_zone) { + var timezone_map = { "-120": "UTC-12", "-110": "UTC-11", "-100": "UTC-10", "-95": "UTC-9:30", "-90": "UTC-9", "-80": "UTC-8", "-70": "UTC-7", "-60": "UTC-6", "-50": "UTC-5", "-40": "UTC-4", "-35": "UTC-3:30", "-30": "UTC-3", "-20": "UTC-2", "-10": "UTC-1", 0: "UTC", 10: "UTC+1", 20: "UTC+2", 30: "UTC+3", 35: "UTC+3:30", 40: "UTC+4", 45: "UTC+4:30", 50: "UTC+5", 55: "UTC+5:30", 57: "UTC+5:45", 60: "UTC+6", 65: "UTC+6:30", 70: "UTC+7", 80: "UTC+8", 90: "UTC+9", 95: "UTC+9:30", 100: "UTC+10", 105: "UTC+10:30", 110: "UTC+11", 120: "UTC+12", 127: "UTC+12:45", 130: "UTC+13", 140: "UTC+14" }; + return getValue(timezone_map, time_zone); +} + +function readTVOCUnit(status) { + var tvoc_unit_map = { 0: "iaq", 1: "µg/m³" }; + return getValue(tvoc_unit_map, status); +} + +function readCalibrationMode(status) { + var calibration_mode_map = { 0: "factory", 1: "abc", 2: "manual", 3: "background", 4: "zero" }; + return getValue(calibration_mode_map, status); +} + +function readLedIndicatorMode(status) { + var led_indicator_mode_map = { 0: "off", 1: "on", 2: "blink" }; + return getValue(led_indicator_mode_map, status); +} + +function readScreenDisplayElementSettings(bytes) { + var mask = readUInt16LE(bytes.slice(0, 2)); + var data = readUInt16LE(bytes.slice(2, 4)); + + var settings = {}; + var sensor_bit_offset = { temperature: 0, humidity: 1, co2: 2, light: 3, tvoc: 4, smile: 5, letter: 6, pm2_5: 7, pm10: 8 }; + for (var key in sensor_bit_offset) { + if ((mask >>> sensor_bit_offset[key]) & 0x01) { + settings[key] = readEnableStatus((data >> sensor_bit_offset[key]) & 0x01); + } + } + return settings; +} + +function readChildLockSettings(data) { + var button_bit_offset = { off_button: 0, on_button: 1, collection_button: 2 }; + + var settings = {}; + for (var key in button_bit_offset) { + settings[key] = readEnableStatus((data >> button_bit_offset[key]) & 0x01); + } + return settings; +} + +/* eslint-disable */ +function readUInt8(bytes) { + return bytes & 0xff; +} + +function readInt8(bytes) { + var ref = readUInt8(bytes); + return ref > 0x7f ? ref - 0x100 : ref; +} + +function readUInt16LE(bytes) { + var value = (bytes[1] << 8) + bytes[0]; + return value & 0xffff; +} + +function readInt16LE(bytes) { + var ref = readUInt16LE(bytes); + return ref > 0x7fff ? ref - 0x10000 : ref; +} + +function readUInt32LE(bytes) { + var value = (bytes[3] << 24) + (bytes[2] << 16) + (bytes[1] << 8) + bytes[0]; + return (value & 0xffffffff) >>> 0; +} + +function readInt32LE(bytes) { + var ref = readUInt32LE(bytes); + return ref > 0x7fffffff ? ref - 0x100000000 : ref; +} + +function getValue(map, key) { + if (RAW_VALUE) return key; + + var value = map[key]; + if (!value) value = "unknown"; + return value; +} + +//if (!Object.assign) { + Object.defineProperty(Object, "assign", { + enumerable: false, + configurable: true, + writable: true, + value: function (target) { + "use strict"; + if (target == null) { + throw new TypeError("Cannot convert first argument to object"); + } + + var to = Object(target); + for (var i = 1; i < arguments.length; i++) { + var nextSource = arguments[i]; + if (nextSource == null) { + continue; + } + nextSource = Object(nextSource); + + var keysArray = Object.keys(Object(nextSource)); + for (var nextIndex = 0, len = keysArray.length; nextIndex < len; nextIndex++) { + var nextKey = keysArray[nextIndex]; + var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey); + if (desc !== undefined && desc.enumerable) { + // concat array + if (Array.isArray(to[nextKey]) && Array.isArray(nextSource[nextKey])) { + to[nextKey] = to[nextKey].concat(nextSource[nextKey]); + } else { + to[nextKey] = nextSource[nextKey]; + } + } + } + } + return to; + }, + }); +//} diff --git a/vendors/milesight/codecs/test_decode_am308.json b/vendors/milesight/codecs/test_decode_am308.json new file mode 100644 index 0000000..ddb6de8 --- /dev/null +++ b/vendors/milesight/codecs/test_decode_am308.json @@ -0,0 +1,55 @@ +[ + { + "name": "Test decode Milesight AM308", + "input": { + "bytes": [ + 3, + 103, + 238, + 0, + 4, + 104, + 124, + 5, + 0, + 1, + 6, + 203, + 2, + 7, + 125, + 168, + 3, + 8, + 125, + 37, + 0, + 9, + 115, + 102, + 39, + 11, + 125, + 32, + 0, + 12, + 125, + 48, + 0 + ] + }, + "expected": { + "data": { + "temperature": 23.8, + "humidity": 62, + "pir": "trigger", + "light_level": 2, + "co2": 936, + "tvoc": 0.37, + "pressure": 1008.6, + "pm2_5": 32, + "pm10": 48 + } + } + } +] diff --git a/vendors/milesight/codecs/test_encode_am308.json b/vendors/milesight/codecs/test_encode_am308.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/vendors/milesight/codecs/test_encode_am308.json @@ -0,0 +1 @@ +[] diff --git a/vendors/milesight/devices/milesight-am308-915m.toml b/vendors/milesight/devices/milesight-am308-915m.toml new file mode 100644 index 0000000..e1b34a9 --- /dev/null +++ b/vendors/milesight/devices/milesight-am308-915m.toml @@ -0,0 +1,16 @@ +[device] +id = "584870cc-91f9-4ef7-8ca7-2c09d70ae308" +name = "Milesight AM308-915M" +description = "Indoor Ambiance Sensor (9-in-1)" + +[[device.firmware]] +version = "1.3" +profiles = [ + "AU915-1_0_3.toml", + "US915-1_0_3.toml", +] +codec = "am308.js" + +[device.metadata] +product_url = "https://www.milesight.com/iot/product/lorawan-sensor/am300" +documentation_url = "https://www.milesight.com/iot/product/lorawan-sensor/am300" diff --git a/vendors/milesight/vendor.toml b/vendors/milesight/vendor.toml index 0a3a740..73461bb 100644 --- a/vendors/milesight/vendor.toml +++ b/vendors/milesight/vendor.toml @@ -6,6 +6,7 @@ ouis = ["24e124"] devices = [ "milesight-am102-l.toml", "milesight-am102.toml", + "milesight-am308-915m.toml", "milesight-em300-cl.toml", "milesight-em300-di.toml", "milesight-em300-mcs.toml", From 2c2165273e8e919cfcf6f218bc2b535481b43933 Mon Sep 17 00:00:00 2001 From: Ars_Codicis <100168416+arscodicis@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:35:57 -0400 Subject: [PATCH 2/2] elsys: add ERS Eye device profile and corresponding decode test case --- vendors/elsys/codecs/test_decode_elsys.json | 33 +++++++++++++++++++++ vendors/elsys/devices/ers-eye-lna.toml | 15 ++++++++++ vendors/elsys/profiles/US915-1_0_3.toml | 25 ++++++++++++++++ vendors/elsys/vendor.toml | 1 + 4 files changed, 74 insertions(+) create mode 100644 vendors/elsys/devices/ers-eye-lna.toml create mode 100644 vendors/elsys/profiles/US915-1_0_3.toml diff --git a/vendors/elsys/codecs/test_decode_elsys.json b/vendors/elsys/codecs/test_decode_elsys.json index 9ce063e..b99b2d4 100644 --- a/vendors/elsys/codecs/test_decode_elsys.json +++ b/vendors/elsys/codecs/test_decode_elsys.json @@ -101,5 +101,38 @@ "pulse1": 42 } } + }, + { + "name": "Test decode ERS Eye occupancy payload", + "input": { + "fPort": 5, + "bytes": [ + 1, + 0, + 234, + 2, + 55, + 4, + 0, + 39, + 5, + 6, + 7, + 13, + 98, + 17, + 2 + ] + }, + "expected": { + "data": { + "temperature": 23.4, + "humidity": 55, + "light": 39, + "motion": 6, + "vdd": 3426, + "occupancy": 2 + } + } } ] diff --git a/vendors/elsys/devices/ers-eye-lna.toml b/vendors/elsys/devices/ers-eye-lna.toml new file mode 100644 index 0000000..fbb4586 --- /dev/null +++ b/vendors/elsys/devices/ers-eye-lna.toml @@ -0,0 +1,15 @@ +[device] +id = "0a154169-0fa8-4b37-9d4e-42f6d63efadc" +name = "ERS Eye LNA" +description = "Indoor occupancy and climate sensor with temperature, humidity, light, PIR motion, and Grid-Eye occupancy sensing." + +[[device.firmware]] +version = "1.0" +profiles = [ + "US915-1_0_3.toml", +] +codec = "elsys.js" + +[device.metadata] +product_url = "https://www.elsys.se/en/ers-eye/" +documentation_url = "https://elsys.se/public/datasheets/ERS_Eye_datasheet.pdf" diff --git a/vendors/elsys/profiles/US915-1_0_3.toml b/vendors/elsys/profiles/US915-1_0_3.toml new file mode 100644 index 0000000..e259e1b --- /dev/null +++ b/vendors/elsys/profiles/US915-1_0_3.toml @@ -0,0 +1,25 @@ +[profile] +id = "d678f9cf-95cb-4aa5-b73e-3117697c2a5c" +vendor_profile_id = 0 +region = "US915" +mac_version = "1.0.3" +reg_params_revision = "A" +supports_otaa = true +supports_class_b = false +supports_class_c = false +max_eirp = 20 + +[profile.abp] +rx1_delay = 0 +rx1_dr_offset = 0 +rx2_dr = 0 +rx2_freq = 0 + +[profile.class_b] +timeout_secs = 0 +ping_slot_nb_k = 0 +ping_slot_dr = 0 +ping_slot_freq = 0 + +[profile.class_c] +timeout_secs = 0 diff --git a/vendors/elsys/vendor.toml b/vendors/elsys/vendor.toml index ed87fe8..7a7dbe7 100644 --- a/vendors/elsys/vendor.toml +++ b/vendors/elsys/vendor.toml @@ -3,6 +3,7 @@ id = "a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6" name = "Elsys" ouis = ["fc0fee"] devices = [ + "ers-eye-lna.toml", "elt-2-hp.toml", ]