From 40fd23ff1fc28635cd98a69c0ad1c981aa58d026 Mon Sep 17 00:00:00 2001 From: Phil Rae Date: Wed, 4 Mar 2026 09:33:08 +0000 Subject: [PATCH 1/4] Explicitly force the platform within the `docker pull` action of the Makefile (fixes MacOS issue). --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7fec7a1..9373196 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test nix-start nix-test serve: - docker pull chirpstack/chirpstack-device-profiles:latest + docker pull --platform=linux/amd64 chirpstack/chirpstack-device-profiles:latest docker run --rm -u $(id -u):$(id -g) -p 8090:8090 -v '$(shell pwd):/chirpstack-device-profiles' chirpstack/chirpstack-device-profiles:latest -p /chirpstack-device-profiles build: From 098becd4719308f440854f59ae3de48b61890fad Mon Sep 17 00:00:00 2001 From: Phil Rae Date: Wed, 4 Mar 2026 09:48:33 +0000 Subject: [PATCH 2/4] Added Milesight AM102 and AM102L devices. --- vendors/milesight/codecs/am102-l.js | 366 ++++++++++++++++ vendors/milesight/codecs/am102.js | 400 ++++++++++++++++++ .../milesight/codecs/test_decode_am102-l.json | 1 + .../milesight/codecs/test_decode_am102.json | 1 + .../milesight/codecs/test_encode_am102-l.json | 1 + .../milesight/codecs/test_encode_am102.json | 1 + .../milesight/devices/milesight-am102-l.toml | 18 + .../milesight/devices/milesight-am102.toml | 18 + 8 files changed, 806 insertions(+) create mode 100644 vendors/milesight/codecs/am102-l.js create mode 100644 vendors/milesight/codecs/am102.js create mode 100644 vendors/milesight/codecs/test_decode_am102-l.json create mode 100644 vendors/milesight/codecs/test_decode_am102.json create mode 100644 vendors/milesight/codecs/test_encode_am102-l.json create mode 100644 vendors/milesight/codecs/test_encode_am102.json create mode 100644 vendors/milesight/devices/milesight-am102-l.toml create mode 100644 vendors/milesight/devices/milesight-am102.toml diff --git a/vendors/milesight/codecs/am102-l.js b/vendors/milesight/codecs/am102-l.js new file mode 100644 index 0000000..c847b2e --- /dev/null +++ b/vendors/milesight/codecs/am102-l.js @@ -0,0 +1,366 @@ +/** + * Payload Decoder + * + * Copyright 2025 Milesight IoT + * + * @product AM102L + */ +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; + } + // TSL VERSION + else if (channel_id === 0xff && channel_type === 0xff) { + decoded.tsl_version = readTslVersion(bytes.slice(i, i + 2)); + i += 2; + } + // SERIAL NUMBER + else if (channel_id === 0xff && channel_type === 0x16) { + decoded.sn = readSerialNumber(bytes.slice(i, i + 8)); + i += 8; + } + // LORAWAN CLASS TYPE + else if (channel_id === 0xff && channel_type === 0x0f) { + decoded.lorawan_class = readLoRaWANClass(bytes[i]); + i += 1; + } + // RESET EVENT + else if (channel_id === 0xff && channel_type === 0xfe) { + decoded.reset_event = readResetEvent(1); + i += 1; + } + // DEVICE STATUS + else if (channel_id === 0xff && channel_type === 0x0b) { + decoded.device_status = readDeviceStatus(1); + i += 1; + } + + // 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; + } + // HISTORY DATA + 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 = readUInt8(bytes[i + 6]) / 2; + i += 7; + decoded.history = decoded.history || []; + decoded.history.push(data); + } + // SENSOR ENABLE + else if (channel_id === 0xff && channel_type === 0x18) { + // skip 1 byte + var data = readUInt8(bytes[i + 1]); + var sensor_bit_offset = { temperature: 0, humidity: 1 }; + decoded.sensor_enable = {}; + for (var key in sensor_bit_offset) { + decoded.sensor_enable[key] = readEnableStatus((data >> sensor_bit_offset[key]) & 0x01); + } + i += 2; + } + // DOWNLINK RESPONSE + 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 0x06: + decoded.temperature_alarm_config = {}; + var condition = readUInt8(bytes[offset]); + decoded.temperature_alarm_config.condition = readMathCondition(condition & 0x07); + decoded.temperature_alarm_config.threshold_min = readInt16LE(bytes.slice(offset + 1, offset + 3)) / 10; + decoded.temperature_alarm_config.threshold_max = readInt16LE(bytes.slice(offset + 3, offset + 5)) / 10; + // skip 4 bytes + offset += 9; + break; + case 0x10: + decoded.reboot = readYesNoStatus(1); + offset += 1; + break; + case 0x11: + decoded.timestamp = readUInt32LE(bytes.slice(offset, offset + 4)); + offset += 4; + break; + case 0x17: + decoded.time_zone = readTimeZone(readInt16LE(bytes.slice(offset, offset + 2))); + offset += 2; + break; + case 0x27: + decoded.clear_history = readYesNoStatus(1); + offset += 1; + break; + case 0x2f: + decoded.led_indicator_mode = readLedIndicatorStatus(bytes[offset]); + offset += 1; + break; + case 0x3a: + var num = readUInt8(bytes[offset]); + offset += 1; + for (var i = 0; i < num; i++) { + var report_schedule_config = {}; + report_schedule_config.start_time = readUInt8(bytes[offset]) / 10; + report_schedule_config.end_time = readUInt8(bytes[offset + 1]) / 10; + report_schedule_config.report_interval = readUInt16LE(bytes.slice(offset + 2, offset + 4)); + // skip 1 byte + report_schedule_config.collection_interval = readUInt8(bytes[offset + 5]); + offset += 6; + decoded.report_schedule_config = decoded.report_schedule_config || []; + decoded.report_schedule_config.push(report_schedule_config); + } + break; + case 0x3b: + decoded.time_sync_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x57: + decoded.clear_report_schedule = readYesNoStatus(1); + offset += 1; + break; + case 0x59: + decoded.reset_battery = readYesNoStatus(1); + 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; + 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 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 class_map = { + 0: "Class A", + 1: "Class B", + 2: "Class C", + 3: "Class CtoB", + }; + return getValue(class_map, type); +} + +function readResetEvent(status) { + var status_map = { 0: "normal", 1: "reset" }; + return getValue(status_map, status); +} + +function readDeviceStatus(status) { + var status_map = { 0: "off", 1: "on" }; + return getValue(status_map, status); +} + +function readYesNoStatus(status) { + var status_map = { 0: "no", 1: "yes" }; + return getValue(status_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 readLedIndicatorStatus(status) { + var status_map = { 0: "off", 2: "blink" }; + return getValue(status_map, status); +} + +function readMathCondition(type) { + var condition_map = { 0: "disable", 1: "below", 2: "above", 3: "between", 4: "outside" }; + return getValue(condition_map, type); +} + +/* 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/am102.js b/vendors/milesight/codecs/am102.js new file mode 100644 index 0000000..49b6c46 --- /dev/null +++ b/vendors/milesight/codecs/am102.js @@ -0,0 +1,400 @@ +/** + * Payload Decoder + * + * Copyright 2025 Milesight IoT + * + * @product AM102 + */ +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; + } + // TSL VERSION + else if (channel_id === 0xff && channel_type === 0xff) { + decoded.tsl_version = readTslVersion(bytes.slice(i, i + 2)); + i += 2; + } + // SERIAL NUMBER + else if (channel_id === 0xff && channel_type === 0x16) { + decoded.sn = readSerialNumber(bytes.slice(i, i + 8)); + i += 8; + } + // LORAWAN CLASS TYPE + else if (channel_id === 0xff && channel_type === 0x0f) { + decoded.lorawan_class = readLoRaWANClass(bytes[i]); + i += 1; + } + // RESET EVENT + else if (channel_id === 0xff && channel_type === 0xfe) { + decoded.reset_event = readResetEvent(1); + i += 1; + } + // DEVICE STATUS + else if (channel_id === 0xff && channel_type === 0x0b) { + decoded.device_status = readDeviceStatus(1); + i += 1; + } + + // 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; + } + // HISTORY DATA + 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 = readUInt8(bytes[i + 6]) / 2; + i += 7; + decoded.history = decoded.history || []; + decoded.history.push(data); + } + // SENSOR ENABLE + else if (channel_id === 0xff && channel_type === 0x18) { + // skip 1 byte + var data = readUInt8(bytes[i + 1]); + var sensor_bit_offset = { temperature: 0, humidity: 1 }; + decoded.sensor_enable = {}; + for (var key in sensor_bit_offset) { + decoded.sensor_enable[key] = readEnableStatus((data >> sensor_bit_offset[key]) & 0x01); + } + i += 2; + } + // DOWNLINK RESPONSE + 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 0x06: + decoded.temperature_alarm_config = {}; + var condition = readUInt8(bytes[offset]); + decoded.temperature_alarm_config.condition = readMathCondition(condition & 0x07); + decoded.temperature_alarm_config.threshold_min = readInt16LE(bytes.slice(offset + 1, offset + 3)) / 10; + decoded.temperature_alarm_config.threshold_max = readInt16LE(bytes.slice(offset + 3, offset + 5)) / 10; + // skip 4 bytes + offset += 9; + break; + case 0x10: + decoded.reboot = readYesNoStatus(1); + offset += 1; + break; + case 0x11: + decoded.timestamp = readUInt32LE(bytes.slice(offset, offset + 4)); + offset += 4; + break; + case 0x17: + decoded.time_zone = readTimeZone(readInt16LE(bytes.slice(offset, offset + 2))); + offset += 2; + break; + case 0x27: + decoded.clear_history = readYesNoStatus(1); + offset += 1; + break; + case 0x2d: + decoded.screen_display_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x2f: + decoded.led_indicator_mode = readLedIndicatorStatus(bytes[offset]); + offset += 1; + break; + case 0x3a: + var num = readUInt8(bytes[offset]); + offset += 1; + for (var i = 0; i < num; i++) { + var report_schedule_config = {}; + report_schedule_config.start_time = readUInt8(bytes[offset]) / 10; + report_schedule_config.end_time = readUInt8(bytes[offset + 1]) / 10; + report_schedule_config.report_interval = readUInt16LE(bytes.slice(offset + 2, offset + 4)); + // skip 1 byte + report_schedule_config.collection_interval = readUInt8(bytes[offset + 5]); + offset += 6; + decoded.report_schedule_config = decoded.report_schedule_config || []; + decoded.report_schedule_config.push(report_schedule_config); + } + break; + case 0x3b: + decoded.time_sync_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x56: + decoded.screen_intelligent_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x57: + decoded.clear_report_schedule = readYesNoStatus(1); + offset += 1; + break; + case 0x59: + decoded.reset_battery = readYesNoStatus(1); + offset += 1; + break; + case 0x5a: + decoded.screen_refresh_interval = readUInt16LE(bytes.slice(offset, offset + 2)); + offset += 2; + 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 0x75: + decoded.hibernate_config = {}; + decoded.hibernate_config.enable = readEnableStatus(bytes[offset]); + decoded.hibernate_config.lora_uplink_enable = readEnableStatus(bytes[offset + 1]); + decoded.hibernate_config.start_time = readUInt16LE(bytes.slice(offset + 2, offset + 4)); + decoded.hibernate_config.end_time = readUInt16LE(bytes.slice(offset + 4, offset + 6)); + decoded.hibernate_config.weekdays = {}; + var data = readUInt8(bytes[offset + 6]); + var weekday_bit_offset = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7 }; + for (var key in weekday_bit_offset) { + decoded.hibernate_config.weekdays[key] = readEnableStatus((data >> weekday_bit_offset[key]) & 0x01); + } + offset += 7; + break; + case 0x85: + decoded.screen_display_time_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x86: + decoded.screen_last_refresh_interval = readUInt8(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 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 class_map = { + 0: "Class A", + 1: "Class B", + 2: "Class C", + 3: "Class CtoB", + }; + return getValue(class_map, type); +} + +function readResetEvent(status) { + var status_map = { 0: "normal", 1: "reset" }; + return getValue(status_map, status); +} + +function readDeviceStatus(status) { + var status_map = { 0: "off", 1: "on" }; + return getValue(status_map, status); +} + +function readYesNoStatus(status) { + var status_map = { 0: "no", 1: "yes" }; + return getValue(status_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 readLedIndicatorStatus(status) { + var status_map = { 0: "off", 2: "blink" }; + return getValue(status_map, status); +} + +function readMathCondition(type) { + var condition_map = { 0: "disable", 1: "below", 2: "above", 3: "between", 4: "outside" }; + return getValue(condition_map, type); +} + +/* 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_am102-l.json b/vendors/milesight/codecs/test_decode_am102-l.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/vendors/milesight/codecs/test_decode_am102-l.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/vendors/milesight/codecs/test_decode_am102.json b/vendors/milesight/codecs/test_decode_am102.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/vendors/milesight/codecs/test_decode_am102.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/vendors/milesight/codecs/test_encode_am102-l.json b/vendors/milesight/codecs/test_encode_am102-l.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/vendors/milesight/codecs/test_encode_am102-l.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/vendors/milesight/codecs/test_encode_am102.json b/vendors/milesight/codecs/test_encode_am102.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/vendors/milesight/codecs/test_encode_am102.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/vendors/milesight/devices/milesight-am102-l.toml b/vendors/milesight/devices/milesight-am102-l.toml new file mode 100644 index 0000000..51f3c0d --- /dev/null +++ b/vendors/milesight/devices/milesight-am102-l.toml @@ -0,0 +1,18 @@ +[device] +id = "45f48720-e4f4-401c-9681-ba063f6c978e" +name = "Milesight AM102L" +description = "2-in-1 IAQ Sensor (Temperature & Humidity)" + +[[device.firmware]] +version = "1.3" +profiles = [ + "AS923-1_0_3.toml", + "AU915-1_0_3.toml", + "EU868-1_0_3.toml", + "US915-1_0_3.toml", +] +codec = "am102-l.js" + +[device.metadata] +product_url = "https://www.milesight.com/iot/product/lorawan-sensor/am102" +documentation_url = "https://www.milesight.com/iot/product/lorawan-sensor/am102" diff --git a/vendors/milesight/devices/milesight-am102.toml b/vendors/milesight/devices/milesight-am102.toml new file mode 100644 index 0000000..d06a614 --- /dev/null +++ b/vendors/milesight/devices/milesight-am102.toml @@ -0,0 +1,18 @@ +[device] +id = "b7bfeca6-1fba-4c72-b8d0-30f5bf143d96" +name = "Milesight AM102" +description = "2-in-1 IAQ Sensor (Temperature & Humidity)" + +[[device.firmware]] +version = "1.3" +profiles = [ + "AS923-1_0_3.toml", + "AU915-1_0_3.toml", + "EU868-1_0_3.toml", + "US915-1_0_3.toml", +] +codec = "am102.js" + +[device.metadata] +product_url = "https://www.milesight.com/iot/product/lorawan-sensor/am102" +documentation_url = "https://www.milesight.com/iot/product/lorawan-sensor/am102" From 7ac1f66f1a57420a105ccf7927850700b2c44e56 Mon Sep 17 00:00:00 2001 From: Phil Rae Date: Tue, 24 Mar 2026 22:14:52 +0000 Subject: [PATCH 3/4] Added Milesight EM400-TLD --- vendors/milesight/codecs/em400-tld.js | 356 ++++++++++++++++++ .../codecs/test_decode_em400-tld.json | 1 + .../codecs/test_encode_em400-tld.json | 1 + .../devices/milesight-em400-tld.toml | 18 + 4 files changed, 376 insertions(+) create mode 100644 vendors/milesight/codecs/em400-tld.js create mode 100644 vendors/milesight/codecs/test_decode_em400-tld.json create mode 100644 vendors/milesight/codecs/test_encode_em400-tld.json create mode 100644 vendors/milesight/devices/milesight-em400-tld.toml diff --git a/vendors/milesight/codecs/em400-tld.js b/vendors/milesight/codecs/em400-tld.js new file mode 100644 index 0000000..6ef7f47 --- /dev/null +++ b/vendors/milesight/codecs/em400-tld.js @@ -0,0 +1,356 @@ +/** + * Payload Decoder + * + * Copyright 2024 Milesight IoT + * + * @product EM400-TLD + */ +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; + } + // TSL VERSION + else if (channel_id === 0xff && channel_type === 0xff) { + decoded.tsl_version = readTslVersion(bytes.slice(i, i + 2)); + i += 2; + } + // SERIAL NUMBER + else if (channel_id === 0xff && channel_type === 0x16) { + decoded.sn = readSerialNumber(bytes.slice(i, i + 8)); + i += 8; + } + // LORAWAN CLASS TYPE + else if (channel_id === 0xff && channel_type === 0x0f) { + decoded.lorawan_class = readLoRaWANClass(bytes[i]); + i += 1; + } + // RESET EVENT + else if (channel_id === 0xff && channel_type === 0xfe) { + decoded.reset_event = readResetEvent(1); + i += 1; + } + // DEVICE STATUS + else if (channel_id === 0xff && channel_type === 0x0b) { + decoded.device_status = readDeviceStatus(1); + i += 1; + } + + // 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) { + decoded.temperature = readInt16LE(bytes.slice(i, i + 2)) / 10; + i += 2; + } + // DISTANCE + else if (channel_id === 0x04 && channel_type === 0x82) { + decoded.distance = readUInt16LE(bytes.slice(i, i + 2)); + i += 2; + } + // POSITION + else if (channel_id === 0x05 && channel_type === 0x00) { + decoded.position = readPositionType(bytes[i]); + i += 1; + } + // TEMPERATURE WITH ABNORMAL + else if (channel_id === 0x83 && channel_type === 0x67) { + decoded.temperature = readInt16LE(bytes.slice(i, i + 2)) / 10; + decoded.temperature_alarm = readAlarmType(bytes[i + 2]); + i += 3; + } + // DISTANCE WITH ALARMING + else if (channel_id === 0x84 && channel_type === 0x82) { + decoded.distance = readUInt16LE(bytes.slice(i, i + 2)); + decoded.distance_alarm = readAlarmType(bytes[i + 2]); + i += 3; + } + // DOWNLINK RESPONSE + 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 0x02: + decoded.collection_interval = readUInt16LE(bytes.slice(offset, offset + 2)); + offset += 2; + break; + case 0x03: + decoded.report_interval = readUInt16LE(bytes.slice(offset, offset + 2)); + offset += 2; + break; + case 0x06: + var value = readUInt8(bytes[offset]); + var alarm_type = (value >>> 3) & 0x07; + var condition_type = value & 0x07; + var config = {}; + config.condition = readMathConditionType(condition_type); + config.alarm_release_enable = readEnableStatus((value >>> 7) & 0x01); + config.threshold_min = readUInt16LE(bytes.slice(offset + 1, offset + 3)); + config.threshold_max = readUInt16LE(bytes.slice(offset + 3, offset + 5)); + // skip 4 bytes + offset += 9; + if (alarm_type === 1) { + decoded.standard_mode_alarm_config = config; + } else if (alarm_type === 2) { + decoded.bin_mode_alarm_config = config; + } + break; + case 0x10: + decoded.reboot = readYesNoStatus(1); + offset += 1; + break; + case 0x13: + decoded.install_height_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x1c: + decoded.recollection_config = {}; + decoded.recollection_config.counts = bytes[offset]; + decoded.recollection_config.interval = bytes[offset + 1]; + offset += 2; + break; + case 0x28: + decoded.query_device_status = readYesNoStatus(1); + offset += 1; + break; + case 0x3e: + decoded.tilt_linkage_distance_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x4a: + decoded.sync_time = readYesNoStatus(1); + offset += 1; + break; + case 0x56: + decoded.tof_detection_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x70: + decoded.people_existing_height = readUInt16LE(bytes.slice(offset, offset + 2)); + offset += 2; + break; + case 0x71: + decoded.working_mode = readWorkingMode(bytes[offset]); + offset += 1; + break; + case 0x77: + decoded.install_height = readUInt16LE(bytes.slice(offset, offset + 2)); + offset += 2; + 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 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 class_map = { + 0: "Class A", + 1: "Class B", + 2: "Class C", + 3: "Class CtoB", + }; + return getValue(class_map, type); +} + +function readResetEvent(status) { + var status_map = { 0: "normal", 1: "reset" }; + return getValue(status_map, status); +} + +function readDeviceStatus(status) { + var status_map = { 0: "off", 1: "on" }; + return getValue(status_map, status); +} + +function readYesNoStatus(status) { + var status_map = { 0: "no", 1: "yes" }; + return getValue(status_map, status); +} + +function readEnableStatus(status) { + var status_map = { 0: "disable", 1: "enable" }; + return getValue(status_map, status); +} + +function readPositionType(type) { + var type_map = { 0: "normal", 1: "tilt" }; + return getValue(type_map, type); +} + +function readAlarmType(type) { + var type_map = { 0: "threshold_alarm_release", 1: "threshold_alarm" }; + return getValue(type_map, type); +} + +function readMathConditionType(type) { + var type_map = { 0: "disable", 1: "below", 2: "above", 3: "between", 4: "outside" }; + return getValue(type_map, type); +} + +function readWorkingMode(type) { + var type_map = { 0: "standard", 1: "bin" }; + return getValue(type_map, type); +} + +/* 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_em400-tld.json b/vendors/milesight/codecs/test_decode_em400-tld.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/vendors/milesight/codecs/test_decode_em400-tld.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/vendors/milesight/codecs/test_encode_em400-tld.json b/vendors/milesight/codecs/test_encode_em400-tld.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/vendors/milesight/codecs/test_encode_em400-tld.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/vendors/milesight/devices/milesight-em400-tld.toml b/vendors/milesight/devices/milesight-em400-tld.toml new file mode 100644 index 0000000..bc6e459 --- /dev/null +++ b/vendors/milesight/devices/milesight-em400-tld.toml @@ -0,0 +1,18 @@ +[device] +id = "92b180ce-3d9e-4f10-a617-d441a46a509e" +name = "Milesight EM400-TLD" +description = "ToF Laser Distance Sensor" + +[[device.firmware]] +version = "1.2" +profiles = [ + "EU868-1_0_3.toml", + "US915-1_0_3.toml", + "AU915-1_0_3.toml", + "AS923-1_0_3.toml", +] +codec = "em400-tld.js" + +[device.metadata] +product_url = "https://www.milesight.com/iot/product/lorawan-sensor/em400-tld" +documentation_url = "https://www.milesight.com/iot/product/lorawan-sensor/em400-tld" From 0990586ac33d8e4973e90f365024da91a8b8c013 Mon Sep 17 00:00:00 2001 From: Phil Rae Date: Tue, 24 Mar 2026 22:20:58 +0000 Subject: [PATCH 4/4] Updating vendor.toml. --- vendors/milesight/vendor.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/vendors/milesight/vendor.toml b/vendors/milesight/vendor.toml index 6929c60..7bd3aad 100644 --- a/vendors/milesight/vendor.toml +++ b/vendors/milesight/vendor.toml @@ -4,14 +4,17 @@ name = "Milesight" vendor_id = 601 ouis = ["24e124"] devices = [ + "milesight-am102-l.toml", + "milesight-am102.toml", + "milesight-em300-cl.toml", "milesight-em300-di.toml", "milesight-em300-mcs.toml", - "milesight-em300-th.toml", - "milesight-em300-cl.toml", "milesight-em300-mld.toml", "milesight-em300-sld-zld.toml", + "milesight-em300-th.toml", "milesight-em310-tilt.toml", "milesight-em320-th.toml", + "milesight-em400-tld.toml", ] [vendor.metadata]