diff --git a/vendors/milesight/codecs/test_decode_ws303.json b/vendors/milesight/codecs/test_decode_ws303.json new file mode 100644 index 0000000..6bda9cb --- /dev/null +++ b/vendors/milesight/codecs/test_decode_ws303.json @@ -0,0 +1,27 @@ +[ + { + "name": "Test decode periodic uplink (battery)", + "input": { "bytes": [1, 117, 100] }, + "expected": { "data": { "battery": 100 } } + }, + { + "name": "Test decode leakage alarm", + "input": { "bytes": [3, 0, 1] }, + "expected": { "data": { "leakage_status": "leak" } } + }, + { + "name": "Test decode leakage cleared", + "input": { "bytes": [3, 0, 0] }, + "expected": { "data": { "leakage_status": "normal" } } + }, + { + "name": "Test decode battery and leakage status", + "input": { "bytes": [1, 117, 92, 3, 0, 0] }, + "expected": { "data": { "battery": 92, "leakage_status": "normal" } } + }, + { + "name": "Test decode device status", + "input": { "bytes": [255, 11, 1] }, + "expected": { "data": { "device_status": "on" } } + } +] diff --git a/vendors/milesight/codecs/test_encode_ws303.json b/vendors/milesight/codecs/test_encode_ws303.json new file mode 100644 index 0000000..041c274 --- /dev/null +++ b/vendors/milesight/codecs/test_encode_ws303.json @@ -0,0 +1,12 @@ +[ + { + "name": "Test encode reboot", + "input": { "data": { "reboot": "yes" } }, + "expected": { "bytes": [255, 16, 255] } + }, + { + "name": "Test encode report interval", + "input": { "data": { "report_interval": 3600 } }, + "expected": { "bytes": [255, 3, 16, 14] } + } +] diff --git a/vendors/milesight/codecs/ws303.js b/vendors/milesight/codecs/ws303.js new file mode 100644 index 0000000..2360104 --- /dev/null +++ b/vendors/milesight/codecs/ws303.js @@ -0,0 +1,696 @@ +/** + * Payload Decoder + * + * Copyright 2024 Milesight IoT + * + * @product WS303 + */ +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; + } + // WATER LEAK + else if (channel_id === 0x03 && channel_type === 0x00) { + decoded.leakage_status = readLeakageStatus(bytes[i]); + i += 1; + } + // 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 0x10: + decoded.reboot = readYesNoStatus(1); + offset += 1; + break; + case 0x28: + decoded.query_device_status = readYesNoStatus(1); + offset += 1; + break; + case 0x3d: + decoded.stop_alarming = readYesNoStatus(1); + offset += 1; + break; + case 0x3e: + decoded.buzzer_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + + case 0x7e: + decoded.leakage_alarm_config = {}; + decoded.leakage_alarm_config.enable = readEnableStatus(bytes[offset]); + decoded.leakage_alarm_config.alarm_interval = readUInt16LE(bytes.slice(offset + 1, offset + 3)); + decoded.leakage_alarm_config.alarm_count = readUInt16LE(bytes.slice(offset + 3, offset + 5)); + offset += 5; + break; + case 0x7f: + decoded.find_device_enable = readEnableStatus(bytes[offset]); + offset += 1; + break; + case 0x80: + decoded.find_device_time = readUInt16LE(bytes.slice(offset, offset + 2)); + offset += 2; + break; + case 0x81: + var report_data = readUInt8(bytes[offset + 1]); + decoded.lora_report_settings = {}; + decoded.lora_report_settings.lora_uplink_enable = readEnableStatus((report_data >>> 0) & 0x01); + decoded.lora_report_settings.d2d_uplink_enable = readEnableStatus((report_data >>> 1) & 0x01); + offset += 2; + break; + case 0x83: + var d2d_master_config = {}; + d2d_master_config.mode = readEnableStatus(bytes[offset]); + d2d_master_config.d2d_command = readD2DCommand(bytes.slice(offset + 1, offset + 3)); + offset += 3; + decoded.d2d_master_config = decoded.d2d_master_config || []; + decoded.d2d_master_config.push(d2d_master_config); + break; + case 0x84: + decoded.d2d_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 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 readLeakageStatus(status) { + var status_map = { 0: "normal", 1: "leak" }; + 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 readD2DCommand(bytes) { + return ("0" + (bytes[1] & 0xff).toString(16)).slice(-2) + ("0" + (bytes[0] & 0xff).toString(16)).slice(-2); +} + +/* 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; + }, + }); +//} + +/* ---------- downlink encoder ---------- */ +/* eslint-disable */ +// Chirpstack v4 +function encodeDownlink(input) { + var encoded = milesightDeviceEncode(input.data); + return { bytes: encoded }; +} + +// Chirpstack v3 +function Encode(fPort, obj) { + return milesightDeviceEncode(obj); +} + +// The Things Network +function Encoder(obj, port) { + return milesightDeviceEncode(obj); +} +/* eslint-enable */ + +function milesightDeviceEncode(payload) { + var encoded = []; + + if ("reboot" in payload) { + encoded = encoded.concat(reboot(payload.reboot)); + } + if ("query_device_status" in payload) { + encoded = encoded.concat(queryDeviceStatus(payload.query_device_status)); + } + if ("report_interval" in payload) { + encoded = encoded.concat(setReportInterval(payload.report_interval)); + } + if ("leakage_alarm_config" in payload) { + encoded = encoded.concat(setLeakageAlarmSettings(payload.leakage_alarm_config)); + } + if ("buzzer_enable" in payload) { + encoded = encoded.concat(setBuzzerEnable(payload.buzzer_enable)); + } + if ("find_device_enable" in payload) { + encoded = encoded.concat(findDeviceEnable(payload.find_device_enable)); + } + if ("find_device_time" in payload) { + encoded = encoded.concat(setFindDeviceTime(payload.find_device_time)); + } + if ("stop_alarming" in payload) { + encoded = encoded.concat(stopAlarming(payload.stop_alarming)); + } + if ("lora_report_settings" in payload) { + encoded = encoded.concat(setLoRaReportSettings(payload.lora_report_settings)); + } + if ("d2d_enable" in payload) { + encoded = encoded.concat(setD2DEnable(payload.d2d_enable)); + } + if ("d2d_master_config" in payload) { + for (var i = 0; i < payload.d2d_master_config.length; i++) { + encoded = encoded.concat(setD2DMasterSettings(payload.d2d_master_config[i])); + } + } + + return encoded; +} + +/** + * reboot + * @param {number} reboot values: (0: no, 1: yes) + * @example { "reboot": 1 } + */ +function reboot(reboot) { + var yes_no_map = { 0: "no", 1: "yes" }; + var reboot_values = getValues(yes_no_map); + if (reboot_values.indexOf(reboot) === -1) { + throw new Error("reboot must be one of " + reboot_values.join(", ")); + } + + if (getEncodeValue(yes_no_map, reboot) === 0) { + return []; + } + return [0xff, 0x10, 0xff]; +} + +/** + * query device status + * @param {number} query_device_status values: (0: no, 1: yes) + * @example { "query_device_status": 1 } + */ +function queryDeviceStatus(query_device_status) { + var yes_no_map = { 0: "no", 1: "yes" }; + var yes_no_values = getValues(yes_no_map); + if (yes_no_values.indexOf(query_device_status) === -1) { + throw new Error("query_device_status must be one of " + yes_no_values.join(", ")); + } + + if (getEncodeValue(yes_no_map, query_device_status) === 0) { + return []; + } + return [0xff, 0x28, 0xff]; +} + +/** + * set report interval + * @param {number} report_interval unit: second, range: [60, 64800] + * @example { "report_interval": 300 } + */ +function setReportInterval(report_interval) { + if (report_interval < 60 || report_interval > 64800) { + throw new Error("report_interval must be between 60 and 64800"); + } + + var buffer = new Buffer(4); + buffer.writeUInt8(0xff); + buffer.writeUInt8(0x03); + buffer.writeUInt16LE(report_interval); + return buffer.toBytes(); +} + +/** + * set leakage alarm settings + * @param {object} leakage_alarm_config + * @param {number} leakage_alarm_config.enable values: (0: disable, 1: enable) + * @param {number} leakage_alarm_config.alarm_interval unit: second, range: [60, 64800] + * @param {number} leakage_alarm_config.alarm_count unit: count, range: [2, 1000] + * @example { "leakage_alarm_config": { "enable": 1, "alarm_interval": 60, "alarm_count": 2 } } + */ +function setLeakageAlarmSettings(leakage_alarm_config) { + var enable = leakage_alarm_config.enable; + var alarm_interval = leakage_alarm_config.alarm_interval; + var alarm_count = leakage_alarm_config.alarm_count; + + var enable_map = { 0: "disable", 1: "enable" }; + var enable_values = getValues(enable_map); + if (enable_values.indexOf(enable) === -1) { + throw new Error("leakage_alarm_config.enable must be one of " + enable_values.join(", ")); + } + if (alarm_interval < 60 || alarm_interval > 64800) { + throw new Error("leakage_alarm_config.alarm_interval must be between 60 and 64800"); + } + if (alarm_count < 2 || alarm_count > 1000) { + throw new Error("leakage_alarm_config.alarm_count must be between 2 and 1000"); + } + + var buffer = new Buffer(7); + buffer.writeUInt8(0xff); + buffer.writeUInt8(0x7e); + buffer.writeUInt8(getEncodeValue(enable_map, enable)); + buffer.writeUInt16LE(alarm_interval); + buffer.writeUInt16LE(alarm_count); + return buffer.toBytes(); +} + +/** + * set buzzer enable + * @param {number} buzzer_enable values: (0: disable, 1: enable) + * @example { "buzzer_enable": 1 } + */ +function setBuzzerEnable(buzzer_enable) { + var enable_map = { 0: "disable", 1: "enable" }; + var enable_values = getValues(enable_map); + if (enable_values.indexOf(buzzer_enable) === -1) { + throw new Error("buzzer_enable must be one of " + enable_values.join(", ")); + } + + var buffer = new Buffer(3); + buffer.writeUInt8(0xff); + buffer.writeUInt8(0x3e); + buffer.writeUInt8(getEncodeValue(enable_map, buzzer_enable)); + return buffer.toBytes(); +} + +/** + * set find device enable + * @param {number} find_device_enable values: (0: disable, 1: enable) + * @example { "find_device_enable": 1 } + */ +function findDeviceEnable(find_device_enable) { + var enable_map = { 0: "disable", 1: "enable" }; + var enable_values = getValues(enable_map); + if (enable_values.indexOf(find_device_enable) === -1) { + throw new Error("find_device_enable must be one of " + enable_values.join(", ")); + } + + var buffer = new Buffer(3); + buffer.writeUInt8(0xff); + buffer.writeUInt8(0x7f); + buffer.writeUInt8(getEncodeValue(enable_map, find_device_enable)); + return buffer.toBytes(); +} + +/** + * set find device time + * @param {number} find_device_time unit: second, range: [60, 64800] + * @example { "find_device_time": 10 } + */ +function setFindDeviceTime(find_device_time) { + if (find_device_time < 60 || find_device_time > 64800) { + throw new Error("find_device_time must be between 60 and 64800"); + } + + var buffer = new Buffer(4); + buffer.writeUInt8(0xff); + buffer.writeUInt8(0x80); + buffer.writeUInt16LE(find_device_time); + return buffer.toBytes(); +} + +/** + * stop alarming + * @param {number} stop_alarming values: (0: no, 1: yes) + * @example { "stop_alarming": 1 } + */ +function stopAlarming(stop_alarming) { + var yes_no_map = { 0: "no", 1: "yes" }; + var yes_no_values = getValues(yes_no_map); + if (yes_no_values.indexOf(stop_alarming) === -1) { + throw new Error("stop_alarming must be one of " + yes_no_values.join(", ")); + } + + if (getEncodeValue(yes_no_map, stop_alarming) === 0) { + return []; + } + return [0xff, 0x3d, 0xff]; +} + +/** + * set LoRa report settings + * @param {object} lora_report_settings + * @param {number} lora_report_settings.lora_uplink_enable values: (0: disable, 1: enable) + * @param {number} lora_report_settings.d2d_uplink_enable values: (0: disable, 1: enable) + * @example { "lora_report_settings": { "lora_uplink_enable": 1, "d2d_uplink_enable": 1 } } + */ +function setLoRaReportSettings(lora_report_settings) { + var enable_map = { 0: "disable", 1: "enable" }; + var enable_values = getValues(enable_map); + if (enable_values.indexOf(lora_report_settings.lora_uplink_enable) === -1) { + throw new Error("lora_report_settings.lora_uplink_enable must be one of " + enable_values.join(", ")); + } + if (enable_values.indexOf(lora_report_settings.d2d_uplink_enable) === -1) { + throw new Error("lora_report_settings.d2d_uplink_enable must be one of " + enable_values.join(", ")); + } + + var data = 0x00; + data |= getEncodeValue(enable_map, lora_report_settings.lora_uplink_enable) << 0; + data |= getEncodeValue(enable_map, lora_report_settings.d2d_uplink_enable) << 1; + + var buffer = new Buffer(4); + buffer.writeUInt8(0xff); + buffer.writeUInt8(0x81); + buffer.writeUInt8(0x01); // leakage alarm + buffer.writeUInt8(data); + return buffer.toBytes(); +} + +/** + * set D2D enable + * @param {number} d2d_enable values: (0: disable, 1: enable) + * @example { "d2d_enable": 1 } + */ +function setD2DEnable(d2d_enable) { + var enable_map = { 0: "disable", 1: "enable" }; + var enable_values = getValues(enable_map); + if (enable_values.indexOf(d2d_enable) === -1) { + throw new Error("d2d_enable must be one of " + enable_values.join(", ")); + } + + var buffer = new Buffer(3); + buffer.writeUInt8(0xff); + buffer.writeUInt8(0x84); + buffer.writeUInt8(getEncodeValue(enable_map, d2d_enable)); + return buffer.toBytes(); +} + +/** + * set D2D master settings + * @param {object} d2d_master_config + * @param {number} d2d_master_config.mode values: (0: normal, 1: leakage) + * @param {string} d2d_master_config.d2d_command + * @example { "d2d_master_config": { "mode": 1, "d2d_command": "0000" } } + */ +function setD2DMasterSettings(d2d_master_config) { + var mode = d2d_master_config.mode; + var d2d_command = d2d_master_config.d2d_command; + + var mode_map = { 0: "normal", 1: "leakage" }; + var mode_values = getValues(mode_map); + if (mode_values.indexOf(mode) === -1) { + throw new Error("d2d_master_config.mode must be one of " + mode_values.join(", ")); + } + + var buffer = new Buffer(5); + buffer.writeUInt8(0xff); + buffer.writeUInt8(0x83); + buffer.writeUInt8(getEncodeValue(mode_map, mode)); + buffer.writeD2DCommand(d2d_command, "0000"); + return buffer.toBytes(); +} + +function getValues(map) { + var values = []; + for (var key in map) { + values.push(RAW_VALUE ? parseInt(key) : map[key]); + } + return values; +} + +function getEncodeValue(map, value) { + if (RAW_VALUE) return value; + + for (var key in map) { + if (map[key] === value) { + return parseInt(key); + } + } + + throw new Error("not match in " + JSON.stringify(map)); +} + +class Buffer { + constructor(size) { + this.buffer = new Array(size).fill(0); + this.offset = 0; + } +} + +Buffer.prototype._write = function (value, byteLength, isLittleEndian) { + var offset = 0; + for (var index = 0; index < byteLength; index++) { + offset = isLittleEndian ? index << 3 : (byteLength - 1 - index) << 3; + this.buffer[this.offset + index] = (value >> offset) & 0xff; + } +}; + +Buffer.prototype.writeUInt8 = function (value) { + this._write(value, 1, true); + this.offset += 1; +}; + +Buffer.prototype.writeInt8 = function (value) { + this._write(value < 0 ? value + 0x100 : value, 1, true); + this.offset += 1; +}; + +Buffer.prototype.writeUInt16LE = function (value) { + this._write(value, 2, true); + this.offset += 2; +}; + +Buffer.prototype.writeInt16LE = function (value) { + this._write(value < 0 ? value + 0x10000 : value, 2, true); + this.offset += 2; +}; + +Buffer.prototype.writeUInt24LE = function (value) { + this._write(value, 3, true); + this.offset += 3; +}; + +Buffer.prototype.writeInt24LE = function (value) { + this._write(value < 0 ? value + 0x1000000 : value, 3, true); + this.offset += 3; +}; + +Buffer.prototype.writeUInt32LE = function (value) { + this._write(value, 4, true); + this.offset += 4; +}; + +Buffer.prototype.writeInt32LE = function (value) { + this._write(value < 0 ? value + 0x100000000 : value, 4, true); + this.offset += 4; +}; + +Buffer.prototype.writeD2DCommand = function (value, defaultValue) { + if (typeof value !== "string") { + value = defaultValue; + } + if (value.length !== 4) { + throw new Error("d2d_cmd length must be 4"); + } + this.buffer[this.offset] = parseInt(value.substr(2, 2), 16); + this.buffer[this.offset + 1] = parseInt(value.substr(0, 2), 16); + this.offset += 2; +}; + +Buffer.prototype.toBytes = function () { + return this.buffer; +}; diff --git a/vendors/milesight/devices/milesight-ws303.toml b/vendors/milesight/devices/milesight-ws303.toml new file mode 100644 index 0000000..dcce4c8 --- /dev/null +++ b/vendors/milesight/devices/milesight-ws303.toml @@ -0,0 +1,18 @@ +[device] +id = "7fce7699-6b16-4d73-88ad-003120c2656f" +name = "Milesight WS303" +description = "Mini Leak Detection Sensor" + +[[device.firmware]] +version = "1.5" +profiles = [ + "AS923-1_0_3.toml", + "AU915-1_0_3.toml", + "EU868-1_0_3.toml", + "US915-1_0_3.toml", +] +codec = "ws303.js" + +[device.metadata] +product_url = "https://www.milesight.com/iot/product/lorawan-sensor/ws303" +documentation_url = "https://www.milesight.com/iot/product/lorawan-sensor/ws303" diff --git a/vendors/milesight/vendor.toml b/vendors/milesight/vendor.toml index 73461bb..cc0caaa 100644 --- a/vendors/milesight/vendor.toml +++ b/vendors/milesight/vendor.toml @@ -17,6 +17,7 @@ devices = [ "milesight-em320-th.toml", "milesight-em400-tld.toml", "milesight-em500-pt100.toml", + "milesight-ws303.toml", ] [vendor.metadata]