UNPKG

matterbridge

Version:
365 lines • 16.4 kB
/** * @description This file contains the class Coap. * @file src/dgram/coap.ts * @author Luca Liguori * @created 2025-03-22 * @version 1.0.0 * @license Apache-2.0 * * Copyright 2025, 2026, 2027 Luca Liguori. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // AnsiLogger imports import { BLUE, db, GREEN, MAGENTA, nf } from 'node-ansi-logger'; // Net imports import { COAP_MULTICAST_IPV4_ADDRESS, COAP_MULTICAST_PORT, Multicast } from './multicast.js'; export const COAP_OPTION_URI_PATH = 11; export const COIOT_OPTION_DEVID = 3332; export const COIOT_OPTION_VALIDITY = 3412; export const COIOT_OPTION_SERIAL = 3420; export const COIOT_REQUEST_STATUS_ID = 56831; export const COIOT_REQUEST_DESCRIPTION_ID = 56832; export class Coap extends Multicast { constructor(name, multicastAddress, multicastPort, socketType, reuseAddr = true, interfaceName, interfaceAddress) { super(name, multicastAddress, multicastPort, socketType, reuseAddr, interfaceName, interfaceAddress); } onCoapMessage(message, rinfo) { this.log.debug(`Coap message received from ${BLUE}${rinfo.family}${db} ${BLUE}${rinfo.address}${db}:${BLUE}${rinfo.port}${db}`); } onMessage(msg, rinfo) { this.log.info(`Dgram multicast socket received a CoAP message from ${BLUE}${rinfo.family}${nf} ${BLUE}${rinfo.address}${nf}:${BLUE}${rinfo.port}${nf}`); try { const result = this.decodeCoapMessage(msg); this.onCoapMessage(result, rinfo); this.logCoapMessage(result); } catch (error) { this.log.error(`Error decoding CoAP message: ${error instanceof Error ? error.message : error}`); } } /** * Decodes a CoAP message from a Buffer. * * @param {Buffer} msg - The Buffer containing the raw CoAP message. * @returns {CoapMessage} A parsed CoAP message object. * @throws Error if the message is malformed. */ decodeCoapMessage(msg) { // A valid CoAP message must have at least 4 bytes for the header. if (msg.length < 4) { throw new Error('Message too short to be a valid CoAP message'); } // Parse the 4-byte fixed header. const version = (msg[0] & 0xc0) >> 6; // Bits 7-6: Version const type = (msg[0] & 0x30) >> 4; // Bits 5-4: Type const tokenLength = msg[0] & 0x0f; // Bits 3-0: Token Length const code = msg[1]; // 8-bit Code const messageId = msg.readUInt16BE(2); // 16-bit Message ID let offset = 4; // Parse token if present. let token = Buffer.alloc(0); if (tokenLength > 0) { if (msg.length < offset + tokenLength) { throw new Error('Message too short for the token length specified'); } token = msg.slice(offset, offset + tokenLength); offset += tokenLength; } // Parse options. const options = []; let currentOptionNumber = 0; while (offset < msg.length) { // Payload marker: 0xFF indicates the beginning of the payload. if (msg[offset] === 0xff) { offset++; // Skip the marker. break; } // Read the option header byte. const optionHeader = msg[offset++]; let delta = (optionHeader & 0xf0) >> 4; // High nibble: option delta. let length = optionHeader & 0x0f; // Low nibble: option length. // Extended delta handling. if (delta === 13) { if (offset >= msg.length) { throw new Error('Invalid extended option delta'); } delta = msg[offset++] + 13; } else if (delta === 14) { if (offset + 1 >= msg.length) { throw new Error('Invalid extended option delta'); } delta = msg.readUInt16BE(offset) + 269; offset += 2; } else if (delta === 15) { throw new Error('Reserved option delta value encountered'); } // Extended length handling. if (length === 13) { if (offset >= msg.length) { throw new Error('Invalid extended option length'); } length = msg[offset++] + 13; } else if (length === 14) { if (offset + 1 >= msg.length) { throw new Error('Invalid extended option length'); } length = msg.readUInt16BE(offset) + 269; offset += 2; } else if (length === 15) { throw new Error('Reserved option length value encountered'); } // Calculate the actual option number. currentOptionNumber += delta; // Ensure that the option's value fits in the remaining message. if (offset + length > msg.length) { throw new Error('Option length exceeds message length'); } const optionValue = msg.slice(offset, offset + length); offset += length; options.push({ number: currentOptionNumber, value: optionValue, }); } // Parse payload if any remains. const payload = offset < msg.length ? msg.slice(offset) : undefined; return { version, type, tokenLength, code, messageId, token, options, payload, }; } /** * Encodes a CoAP message into a Buffer. * * @param {CoapMessage} msg - The CoAP message to encode. * @returns {Buffer} A Buffer representing the encoded CoAP message. * @throws Error if the message is malformed. */ encodeCoapMessage(msg) { const parts = []; // Determine token length (ensure it's consistent with msg.token) const token = msg.token || Buffer.alloc(0); const tokenLength = token.length; if (tokenLength > 8) { throw new Error('Token length cannot exceed 8 bytes'); } // Create header (4 bytes): // Byte 0: Version (2 bits), Type (2 bits), Token Length (4 bits) // Byte 1: Code (8 bits) // Bytes 2-3: Message ID (16 bits) const header = Buffer.alloc(4); header[0] = ((msg.version & 0x03) << 6) | ((msg.type & 0x03) << 4) | (tokenLength & 0x0f); header[1] = msg.code; header.writeUInt16BE(msg.messageId, 2); parts.push(header); // Append token if present. if (tokenLength > 0) { parts.push(token); } // Sort options by option number in ascending order. const sortedOptions = msg.options.slice().sort((a, b) => a.number - b.number); let previousOptionNumber = 0; // Encode each option. for (const option of sortedOptions) { const optionDelta = option.number - previousOptionNumber; const optionValueLength = option.value.length; let deltaNibble; let deltaExtended = null; let lengthNibble; let lengthExtended = null; // Determine option delta nibble and extended delta field. if (optionDelta < 13) { deltaNibble = optionDelta; } else if (optionDelta < 269) { deltaNibble = 13; deltaExtended = Buffer.from([optionDelta - 13]); } else { deltaNibble = 14; deltaExtended = Buffer.alloc(2); deltaExtended.writeUInt16BE(optionDelta - 269, 0); } // Determine option length nibble and extended length field. if (optionValueLength < 13) { lengthNibble = optionValueLength; } else if (optionValueLength < 269) { lengthNibble = 13; lengthExtended = Buffer.from([optionValueLength - 13]); } else { lengthNibble = 14; lengthExtended = Buffer.alloc(2); lengthExtended.writeUInt16BE(optionValueLength - 269, 0); } // Option header byte: high nibble is delta, low nibble is length. const optionHeader = Buffer.alloc(1); optionHeader[0] = (deltaNibble << 4) | (lengthNibble & 0x0f); parts.push(optionHeader); // Append extended delta if needed. if (deltaExtended) { parts.push(deltaExtended); } // Append extended length if needed. if (lengthExtended) { parts.push(lengthExtended); } // Append the option value. parts.push(option.value); // Update previous option number. previousOptionNumber = option.number; } // If a payload is present, add the payload marker (0xFF) followed by the payload. if (msg.payload && msg.payload.length > 0) { parts.push(Buffer.from([0xff])); // Payload marker. parts.push(msg.payload); } return Buffer.concat(parts); } /** * Converts a CoAP message type numeric value to its string representation. * * CoAP message types are: * - 0: Confirmable (CON) * - 1: Non-confirmable (NON) * - 2: Acknowledgement (ACK) * - 3: Reset (RST) * * @param {number} type - The numeric CoAP message type. * @returns {string} The string representation of the message type. */ coapTypeToString(type) { switch (type) { case 0: return 'Confirmable (CON)'; case 1: return 'Non-confirmable (NON)'; case 2: return 'Acknowledgement (ACK)'; case 3: return 'Reset (RST)'; default: return `Unknown Type (${type})`; } } /** * Converts a CoAP code numeric value to its string representation. * * The CoAP code is split into a 3-bit class and a 5-bit detail. * For example: * - 0.01: GET * - 0.02: POST * - 0.03: PUT * - 0.04: DELETE * - 2.05: Content (success response) * - 4.04: Not Found (client error) * - 5.00: Internal Server Error (server error) * * @param {number} code - The numeric CoAP code. * @returns {string} The string representation of the code. */ coapCodeToString(code) { // Split the code: the upper 3 bits form the class and the lower 5 bits form the detail. const cls = code >> 5; const detail = code & 0x1f; const codeStr = `${cls}.${detail.toString().padStart(2, '0')}`; return codeStr; } sendRequest(messageId, options, // eslint-disable-next-line @typescript-eslint/no-explicit-any payload, token, address, port) { const coapMessage = { version: 1, type: 1, // e.g., Confirmable tokenLength: token ? Buffer.from(token).length : 0, code: 1, // e.g., GET messageId, token: token ? Buffer.from(token) : Buffer.alloc(0), options, payload: payload ? Buffer.from(JSON.stringify(payload)) : undefined, }; const encodedBuffer = this.encodeCoapMessage(coapMessage); this.send(encodedBuffer, address ?? COAP_MULTICAST_IPV4_ADDRESS, port ?? COAP_MULTICAST_PORT); } logCoapMessage(msg) { this.log.info(`Decoded CoAP message: version ${MAGENTA}${msg.version}${nf} type ${MAGENTA}${this.coapTypeToString(msg.type)}${nf} ` + `code ${MAGENTA}${this.coapCodeToString(msg.code)}${nf} messageId ${MAGENTA}${msg.messageId}${nf} ` + `tokenLength ${MAGENTA}${msg.tokenLength}${nf} token ${MAGENTA}${msg.tokenLength == 0 ? 'no token' : msg.token.toString('hex')}${nf} ` + `options ${MAGENTA}${msg.options.length}${nf} payload ${MAGENTA}${msg.payload ? msg.payload.length + ' bytes' : undefined}${nf}`); msg.options.forEach((option) => { if (option.number === COAP_OPTION_URI_PATH) { this.log.info(`Option: COAP_OPTION_URI_PATH => ${GREEN}${option.value}${nf}`); } else if (option.number === COIOT_OPTION_DEVID) { /* * First defined option is COIOT_OPTION_GLOBAL_DEVID. This is a global option and must be present in all CoIoT packages with request or payload. * Its value is a string in format <devtype>#<devid>#<version> for example SHSEN-1#4B3F9E#1 The whole option should be less than 50 bytes. */ const parts = option.value.toString().split('#'); const deviceModel = parts[0]; const deviceMac = parts[1]; const protocolRevision = parts[2]; this.log.info(`Option: COIOT_OPTION_DEVID => ${option.value} => Model: ${GREEN}${deviceModel}${nf}, MAC: ${GREEN}${deviceMac}${nf}, Protocol: ${GREEN}${protocolRevision}${nf}`); } else if (option.number === COIOT_OPTION_SERIAL) { /* * This option is mandatory in status response and publishes. It is a uint16_t in network byte order which indicates a change in the status report. * When a new status report is handled, all payload processing can be skipped if the serial number does not change from the last processed payload. * The value 0 is reserved and should not be sent. This allows easy initialization in the receiving devices. */ const serial = option.value.readUInt16BE(0); this.log.info(`Option: COIOT_OPTION_SERIAL => 0x${option.value.toString('hex')} => serial: ${GREEN}${serial}${nf}`); } else if (option.number === COIOT_OPTION_VALIDITY) { /* * COIOT_OPTION_STATUS_VALIDITY is a uint16_t in network byte order (big endian) that states the maximal time between this and the next status publish. * This way a device can state its report interval. If a report is not received from this device after the interval has passed the device should be considered offline. * The LS bit of this option controls how the value is scaled: * - If the LS bit is 0, the value is number of 1/10 of seconds in the validity period, so 2 is 0.2 seconds, 10 is a second, 600 is a minute, 65534 is 109 minutes and 13 seconds. * - If the LS bit is 1, the value is number of 4 seconds interval in the whole interval. So 3 is 12 seconds, 11 is 44 seconds and 65535 is more than 3 days. */ const validity = option.value.readUInt16BE(0); let validFor = 0; if ((validity & 0x1) === 0) { validFor = Math.floor(validity / 10); } else { validFor = validity * 4; } this.log.info(`Option: COIOT_OPTION_VALIDITY => 0x${option.value.toString('hex')} => valid for: ${GREEN}${validFor}${nf} seconds`); } else this.log.info(`Option: ${option.number} - ${option.value .toString('hex') .match(/.{1,2}/g) ?.join(' ')}`); // Split into pairs of hex characters }); if (msg.payload && msg.payload.length > 0) this.log.info(`Payload:`, JSON.parse(msg.payload.toString()), '\n'); else this.log.info(`No payload`, '\n'); } } //# sourceMappingURL=coap.js.map