From 6473855f788f07220c71334d9332ad7244efeb17 Mon Sep 17 00:00:00 2001 From: qzshch Date: Fri, 18 Sep 2026 15:28:25 +0800 Subject: [PATCH] feat: add Breeze Controls BC107 LoRaWAN fan-coil thermostat codec - 26-byte basic uplink frame (T/H/setpoint/CO2/valve+fan status/timezone/firmware) - downlink encoder for 8 commands (on/off, fan, mode, setpoint, lock, pin, schedule, switching diff) + raw_downlink passthrough - system_mode uplink/downlink code asymmetry translated in encoder - validated: codec-validator clean, decoder e2e matches codec.json values --- vendors.json | 9 + vendors/Breeze/bc107/bc107-codec.json | 440 ++++++++++++++++++++++++++ vendors/Breeze/bc107/bc107-decoder.js | 80 +++++ vendors/Breeze/bc107/bc107-encoder.js | 141 +++++++++ vendors/Breeze/devices.json | 19 ++ 5 files changed, 689 insertions(+) create mode 100644 vendors/Breeze/bc107/bc107-codec.json create mode 100644 vendors/Breeze/bc107/bc107-decoder.js create mode 100644 vendors/Breeze/bc107/bc107-encoder.js create mode 100644 vendors/Breeze/devices.json diff --git a/vendors.json b/vendors.json index c998945b..6e5564fc 100644 --- a/vendors.json +++ b/vendors.json @@ -134,6 +134,15 @@ "description": "UK-based manufacturer of LoRaWAN environmental and air quality sensors under the enLink brand.", "oui": "", "devices": "vendors/Synetica/devices.json" + }, + { + "id": "breeze", + "name": "Breeze Controls", + "logo": "", + "website": "https://www.breezecontrols.com", + "description": "HVAC controls brand of Beijing Breeze Technology, offering multi-protocol smart thermostats and fan coil controls including LoRaWAN models (BC107 series).", + "oui": "", + "devices": "vendors/Breeze/devices.json" } ] } \ No newline at end of file diff --git a/vendors/Breeze/bc107/bc107-codec.json b/vendors/Breeze/bc107/bc107-codec.json new file mode 100644 index 00000000..86342c7a --- /dev/null +++ b/vendors/Breeze/bc107/bc107-codec.json @@ -0,0 +1,440 @@ +{ + "version": "1.0.0", + "bytes": "00DA002600FF010A010101020304000320006C00000301028301", + "object": [ + { + "id": "room_temperature", + "name": "Room Temperature", + "value": "21.8", + "unit": "°C", + "access_mode": "R", + "data_type": "NUMBER", + "value_type": "FLOAT", + "bacnet_type": "analog_input_object", + "bacnet_unit_type_id": 62, + "bacnet_unit_type": "UNITS_DEGREES_CELSIUS" + }, + { + "id": "room_humidity", + "name": "Room Humidity", + "value": "38", + "unit": "%", + "access_mode": "R", + "data_type": "NUMBER", + "value_type": "FLOAT", + "bacnet_type": "analog_input_object", + "bacnet_unit_type_id": 98, + "bacnet_unit_type": "UNITS_PERCENT" + }, + { + "id": "setpoint", + "name": "Setpoint", + "value": "25.5", + "unit": "°C", + "access_mode": "RW", + "data_type": "NUMBER", + "value_type": "FLOAT", + "bacnet_type": "analog_value_object", + "bacnet_unit_type_id": 62, + "bacnet_unit_type": "UNITS_DEGREES_CELSIUS" + }, + { + "id": "system_mode", + "name": "System Mode", + "value": "1", + "unit": "", + "access_mode": "RW", + "data_type": "ENUM", + "value_type": "UINT8", + "values": [ + { + "value": 0, + "name": "Heat" + }, + { + "value": 1, + "name": "Cool" + }, + { + "value": 2, + "name": "Vent" + }, + { + "value": 3, + "name": "Dehumidify" + }, + { + "value": 4, + "name": "Auto" + }, + { + "value": 5, + "name": "Off" + } + ], + "bacnet_type": "multistate_value_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "switching_diff", + "name": "Switching Differential", + "value": "1", + "unit": "°C", + "access_mode": "RW", + "data_type": "NUMBER", + "value_type": "FLOAT", + "bacnet_type": "analog_value_object", + "bacnet_unit_type_id": 62, + "bacnet_unit_type": "UNITS_DEGREES_CELSIUS" + }, + { + "id": "away", + "name": "Away Mode", + "value": "1", + "unit": "", + "access_mode": "R", + "data_type": "ENUM", + "value_type": "UINT8", + "values": [ + { + "value": 0, + "name": "Home" + }, + { + "value": 1, + "name": "Away" + } + ], + "bacnet_type": "multistate_input_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "lock_out_status", + "name": "Lock Out Status", + "value": "1", + "unit": "", + "access_mode": "RW", + "data_type": "ENUM", + "value_type": "UINT8", + "values": [ + { + "value": 0, + "name": "Disabled" + }, + { + "value": 1, + "name": "Enabled" + } + ], + "bacnet_type": "multistate_value_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "lock_pin_number", + "name": "Lock Pin Number", + "value": "1234", + "unit": "", + "access_mode": "RW", + "data_type": "TEXT", + "value_type": "STRING", + "max_length": 4, + "bacnet_type": "character_string_value_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "temperature_reading_format", + "name": "Temperature Reading Format", + "value": "0", + "unit": "", + "access_mode": "R", + "data_type": "ENUM", + "value_type": "UINT8", + "values": [ + { + "value": 0, + "name": "Celsius" + }, + { + "value": 1, + "name": "Fahrenheit" + } + ], + "bacnet_type": "multistate_input_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "fan", + "name": "Fan Speed", + "value": "3", + "unit": "", + "access_mode": "RW", + "data_type": "ENUM", + "value_type": "UINT8", + "values": [ + { + "value": 0, + "name": "High" + }, + { + "value": 1, + "name": "Med" + }, + { + "value": 2, + "name": "Low" + }, + { + "value": 3, + "name": "Auto" + } + ], + "bacnet_type": "multistate_value_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "timezone", + "name": "Timezone", + "value": "", + "unit": "", + "access_mode": "R", + "data_type": "ENUM", + "value_type": "UINT8", + "values": [ + { + "value": 0, + "name": "UTC+0" + }, + { + "value": 1, + "name": "UTC+1" + }, + { + "value": 2, + "name": "UTC+2" + }, + { + "value": 3, + "name": "UTC+3" + }, + { + "value": 4, + "name": "UTC+4" + }, + { + "value": 5, + "name": "UTC+5" + }, + { + "value": 6, + "name": "UTC+6" + }, + { + "value": 7, + "name": "UTC+7" + }, + { + "value": 8, + "name": "UTC+8" + }, + { + "value": 9, + "name": "UTC+9" + }, + { + "value": 10, + "name": "UTC+10" + }, + { + "value": 11, + "name": "UTC+11" + }, + { + "value": 12, + "name": "UTC+12" + }, + { + "value": 13, + "name": "UTC-12" + }, + { + "value": 14, + "name": "UTC-11" + }, + { + "value": 15, + "name": "UTC-10 (Hawaii)" + }, + { + "value": 16, + "name": "UTC-9 (Alaska)" + }, + { + "value": 17, + "name": "UTC-8 (Pacific)" + }, + { + "value": 18, + "name": "UTC-7 (Mountain)" + }, + { + "value": 19, + "name": "UTC-6 (Central)" + }, + { + "value": 20, + "name": "UTC-5 (Eastern)" + }, + { + "value": 21, + "name": "UTC-4" + }, + { + "value": 22, + "name": "UTC-3" + }, + { + "value": 23, + "name": "UTC-2" + }, + { + "value": 24, + "name": "UTC-1" + } + ], + "bacnet_type": "multistate_input_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "firmware", + "name": "Firmware Version", + "value": "108", + "unit": "", + "access_mode": "R", + "data_type": "NUMBER", + "value_type": "UINT16", + "bacnet_type": "analog_input_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "valve_status_cool", + "name": "Valve Status (Cool / 2-Pipe)", + "value": "0", + "unit": "%", + "access_mode": "R", + "data_type": "NUMBER", + "value_type": "UINT8", + "bacnet_type": "analog_input_object", + "bacnet_unit_type_id": 98, + "bacnet_unit_type": "UNITS_PERCENT" + }, + { + "id": "valve_status_heat", + "name": "Valve Status (Heat / 4-Pipe)", + "value": "0", + "unit": "%", + "access_mode": "R", + "data_type": "NUMBER", + "value_type": "UINT8", + "bacnet_type": "analog_input_object", + "bacnet_unit_type_id": 98, + "bacnet_unit_type": "UNITS_PERCENT" + }, + { + "id": "fan_status", + "name": "Fan Status", + "value": "3", + "unit": "%", + "access_mode": "R", + "data_type": "NUMBER", + "value_type": "UINT8", + "bacnet_type": "analog_input_object", + "bacnet_unit_type_id": 98, + "bacnet_unit_type": "UNITS_PERCENT" + }, + { + "id": "schedule_mode", + "name": "Schedule Mode", + "value": "1", + "unit": "", + "access_mode": "RW", + "data_type": "ENUM", + "value_type": "UINT8", + "values": [ + { + "value": 0, + "name": "Weekday/Weekend" + }, + { + "value": 1, + "name": "7 Days" + }, + { + "value": 2, + "name": "24 Hrs" + }, + { + "value": 3, + "name": "None" + } + ], + "bacnet_type": "multistate_value_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "co2", + "name": "CO2", + "value": "643", + "unit": "ppm", + "access_mode": "R", + "data_type": "NUMBER", + "value_type": "UINT16", + "bacnet_type": "analog_input_object", + "bacnet_unit_type_id": 96, + "bacnet_unit_type": "UNITS_PARTS_PER_MILLION" + }, + { + "id": "on_off_status", + "name": "On/Off Status", + "value": "1", + "unit": "", + "access_mode": "RW", + "data_type": "ENUM", + "value_type": "UINT8", + "values": [ + { + "value": 0, + "name": "Off" + }, + { + "value": 1, + "name": "On" + } + ], + "bacnet_type": "multistate_value_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + }, + { + "id": "raw_downlink", + "name": "Raw Downlink Payload", + "value": "", + "unit": "", + "access_mode": "W", + "data_type": "TEXT", + "value_type": "STRING", + "max_length": 242, + "bacnet_type": "character_string_value_object", + "bacnet_unit_type_id": 95, + "bacnet_unit_type": "UNITS_NO_UNITS" + } + ] +} \ No newline at end of file diff --git a/vendors/Breeze/bc107/bc107-decoder.js b/vendors/Breeze/bc107/bc107-decoder.js new file mode 100644 index 00000000..f67c6697 --- /dev/null +++ b/vendors/Breeze/bc107/bc107-decoder.js @@ -0,0 +1,80 @@ +// BC107 / MC6-162 fan-coil thermostat LoRaWAN uplink decoder +// Source: vendor protocol doc "MC6-162-LoRawan protocal-26byes-20260917.pdf" +// (26-byte basic frame; two-valve byte layout confirmed by this revision — the +// 20260513 revision carried a stale example payload, since corrected by the vendor). +// Covers BC107-4M / 4EM / 4AM / 4AEM, which share the same payload. +// +// Basic frame, 26 bytes, big-endian: +// [0..1] room temperature, 0.1 degC/LSB +// [2..3] room humidity, 1 %RH/LSB +// [4..5] setpoint, 0.1 degC/LSB +// [6] system mode: 0=Heat 1=Cool 2=Vent 3=Dehumidify 4=Auto 5=Off +// (uplink table; downlink wire codes differ, see encoder) +// [7] switching differential, 0.1 degC/LSB (raw 5/10/15/20 = 0.5/1.0/1.5/2.0 degC) +// [8] away: 0=Home 1=Away +// [9] lock out status: 0=disabled 1=enabled +// [10..13] lock pin, one decimal digit (0-9) per byte, e.g. 01 02 03 04 -> "1234" +// [14] temperature reading format: 0=Celsius 1=Fahrenheit +// [15] fan setting: 0=High 1=Med 2=Low 3=Auto +// [16] timezone index: 0..12 = UTC+0..UTC+12, 13..24 = UTC-12..UTC-1, +// value > 24 = timezone disabled +// [17..18] firmware version, raw number (e.g. 0x006C = 108) +// [19] valve status, cool valve (4-pipe) / single valve (2-pipe): +// on/off models: 0=off 1=2-pipe valve on 2=4-pipe heat valve on +// 3=4-pipe cool valve on; modulating (0-10V) models: 0-100 = 0-100% output +// [20] valve status, heat valve (4-pipe) / heat output (E-heat, HW): +// same coding as byte 19 +// [21] fan status: on/off fan 0=off 1=Low 2=Med 3=High; +// EC fan 0-100 = 0-100% output; heating-only models: 1 = heat relay +// ON, 0 = heat relay OFF +// [22] schedule mode: 0=Weekday/Weekend 1=7 Days 2=24 Hrs 3=None +// [23..24] CO2, 1 ppm/LSB +// [25] on/off status: 0=Off 1=On +// +// Schedule uplink frames are sent as separate batches (~50 s apart) but their +// format is not documented; any frame whose length is not 26 decodes to an empty +// object until the vendor publishes the schedule frame definition. After a +// successful downlink the device re-uplinks the basic frame (~10 s, no schedule). + +function decodeUplink(input) { + var bytes = input ? input.bytes : null; + var data = {}; + if (!bytes || bytes.length !== 26) { + return { data: data }; + } + + data.room_temperature = ((bytes[0] << 8) | bytes[1]) / 10; + data.room_humidity = (bytes[2] << 8) | bytes[3]; + data.setpoint = ((bytes[4] << 8) | bytes[5]) / 10; + data.system_mode = bytes[6]; + data.switching_diff = bytes[7] / 10; + data.away = bytes[8]; + data.lock_out_status = bytes[9]; + data.lock_pin_number = "" + bytes[10] + bytes[11] + bytes[12] + bytes[13]; + data.temperature_reading_format = bytes[14]; + data.fan = bytes[15]; + data.timezone = bytes[16]; + data.firmware = (bytes[17] << 8) | bytes[18]; + data.valve_status_cool = bytes[19]; + data.valve_status_heat = bytes[20]; + data.fan_status = bytes[21]; + data.schedule_mode = bytes[22]; + data.co2 = (bytes[23] << 8) | bytes[24]; + data.on_off_status = bytes[25]; + + return { data: data }; +} + +// ChirpStack v3 / Milesight gateway codec +function Decode(fPort, bytes) { + return decodeUplink({ bytes: bytes, fPort: fPort }).data; +} + +// ChirpStack v3 (alternate signature) / TTN +function Decoder(bytes, fPort) { + return decodeUplink({ bytes: bytes, fPort: fPort }).data; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { decodeUplink: decodeUplink, Decode: Decode, Decoder: Decoder }; +} diff --git a/vendors/Breeze/bc107/bc107-encoder.js b/vendors/Breeze/bc107/bc107-encoder.js new file mode 100644 index 00000000..184d8ea1 --- /dev/null +++ b/vendors/Breeze/bc107/bc107-encoder.js @@ -0,0 +1,141 @@ +// BC107 / MC6-162 fan-coil thermostat LoRaWAN downlink encoder +// Source: vendor protocol doc "MC6-162-LoRawan protocal-26byes-20260917.pdf", section +// "2. Downlink data to thermostat". Frame = header(1) + length(1) + data. +// On successful receipt the device re-uplinks the basic frame after ~10 s. +// +// header len data +// 0x00 1 on/off: 0=Off 1=On +// 0x01 1 fan speed: 0=High 1=Med 2=Low 3=Auto +// 0x02 1 work mode: 0=Off 1=Cool 2=Heat 3=Vent 4=Dehumidify 5=Auto (wire codes) +// 0x03 2 setpoint: 0.1 degC/LSB, big-endian +// 0x04 1 lock status: 0=Unlocked 1=Locked +// 0x05 4 lock pin: one decimal digit per byte +// 0x07 1 schedule mode: 0=Weekday/Weekend 1=7 Days 2=24 Hrs 3=None +// 0x08 1 switch diff: 0.1 degC/LSB, raw 5/10/15/20 = 0.5/1.0/1.5/2.0 degC +// +// Input object keys use the uplink/codec value tables. system_mode is the one field +// whose wire coding differs between directions (uplink 0=Heat 5=Off vs downlink +// 0=Off 5=Auto), so this encoder translates the codec value (0=Heat 1=Cool 2=Vent +// 3=Dehumidify 4=Auto 5=Off) to the downlink wire code. +// +// Several keys in one object are concatenated into one payload (frames are +// self-delimiting via the length byte), but multi-command downlinks are not covered +// by the vendor doc — prefer one command per downlink until the vendor confirms. +// +// raw_downlink bypasses all of the above: "030200F0" sends those hex bytes as-is, +// an optional ":port" suffix sets fPort ("030200F0:2", default 1). + +function encodePin(value) { + var s = String(value); + if (!/^\d{4}$/.test(s)) { + throw new Error("lock_pin_number must be exactly 4 digits, got: " + s); + } + var out = [0x05, 0x04]; + for (var i = 0; i < 4; i++) { + out.push(parseInt(s.charAt(i), 10)); + } + return out; +} + +// codec value (uplink table) -> downlink wire code +var SYSTEM_MODE_WIRE = { 0: 0x02, 1: 0x01, 2: 0x03, 3: 0x04, 4: 0x05, 5: 0x00 }; + +function deviceEncode(obj) { + var bytes = []; + if (!obj) { + return bytes; + } + if ("on_off_status" in obj) { + bytes = bytes.concat([0x00, 0x01, obj.on_off_status ? 1 : 0]); + } + if ("fan" in obj) { + bytes = bytes.concat([0x01, 0x01, obj.fan & 0xFF]); + } + if ("system_mode" in obj) { + var wire = SYSTEM_MODE_WIRE[obj.system_mode]; + if (wire === undefined) { + throw new Error("system_mode must be 0..5 (0=Heat 1=Cool 2=Vent 3=Dehumidify 4=Auto 5=Off), got: " + obj.system_mode); + } + bytes = bytes.concat([0x02, 0x01, wire]); + } + if ("setpoint" in obj) { + var raw = Math.round(Number(obj.setpoint) * 10); + if (raw < 0 || raw > 0xFFFF) { + throw new Error("setpoint out of range: " + obj.setpoint); + } + bytes = bytes.concat([0x03, 0x02, (raw >> 8) & 0xFF, raw & 0xFF]); + } + if ("lock_out_status" in obj) { + bytes = bytes.concat([0x04, 0x01, obj.lock_out_status ? 1 : 0]); + } + if ("lock_pin_number" in obj) { + bytes = bytes.concat(encodePin(obj.lock_pin_number)); + } + if ("schedule_mode" in obj) { + bytes = bytes.concat([0x07, 0x01, obj.schedule_mode & 0xFF]); + } + if ("switching_diff" in obj) { + var diff = Math.round(Number(obj.switching_diff) * 10); + if (diff !== 5 && diff !== 10 && diff !== 15 && diff !== 20) { + throw new Error("switching_diff must be 0.5, 1, 1.5 or 2 (degC), got: " + obj.switching_diff); + } + bytes = bytes.concat([0x08, 0x01, diff]); + } + return bytes; +} + +function hexToBytes(hex) { + hex = String(hex).replace(/[^0-9a-fA-F]/g, ""); + if (hex.length % 2) { + hex = "0" + hex; + } + var out = []; + for (var i = 0; i < hex.length; i += 2) { + out.push(parseInt(hex.substr(i, 2), 16)); + } + return out; +} + +function encodeRaw(obj) { + var raw = obj && (obj.raw_downlink !== undefined ? obj.raw_downlink : obj); + if (typeof raw !== "string" || !raw) { + return { bytes: [], fPort: 1 }; + } + var parts = raw.split(":"); + return { + bytes: hexToBytes(parts[0]), + fPort: parts[1] ? parseInt(parts[1], 10) : 1 + }; +} + +function encodeInput(input) { + var obj = input && input.data !== undefined ? input.data : input; + if (obj && typeof obj === "object" && "raw_downlink" in obj) { + return encodeRaw(obj); + } + return { bytes: deviceEncode(obj), fPort: input && input.fPort ? Number(input.fPort) : 1 }; +} + +// ChirpStack v3 / Milesight gateway codec +function Encode(fPort, obj) { + return encodeInput({ data: obj, fPort: fPort }).bytes; +} + +// TTN / ChirpStack v3 (alternate signature) +function Encoder(obj, port) { + return encodeInput({ data: obj, fPort: port }).bytes; +} + +// ChirpStack v4 +function encodeDownlink(input) { + return encodeInput(input); +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { + deviceEncode: deviceEncode, + encodeDownlink: encodeDownlink, + Encode: Encode, + Encoder: Encoder + }; +} diff --git a/vendors/Breeze/devices.json b/vendors/Breeze/devices.json new file mode 100644 index 00000000..02c86d25 --- /dev/null +++ b/vendors/Breeze/devices.json @@ -0,0 +1,19 @@ +{ + "version": "1.0.0", + "devices": [ + { + "id": "bc107", + "name": "Breeze - BC107", + "description": "LoRaWAN fan-coil thermostat for 2/4-pipe FCU systems with room temperature, humidity, CO2, valve and fan status reporting (BC107-4M/4EM/4AM/4AEM).", + "catalog": "bc107", + "sn": "", + "deveui": "", + "device_profile": [ + "ClassA-OTAA" + ], + "codec": "vendors/Breeze/bc107/bc107-codec.json", + "decoder_script": "vendors/Breeze/bc107/bc107-decoder.js", + "encoder_script": "vendors/Breeze/bc107/bc107-encoder.js" + } + ] +}