diff --git a/build/CoapClient.d.ts b/build/CoapClient.d.ts index 8d5bab5d..4cb19268 100644 --- a/build/CoapClient.d.ts +++ b/build/CoapClient.d.ts @@ -12,6 +12,8 @@ export interface RequestOptions { confirmable?: boolean; /** Whether this message will be retransmitted on loss */ retransmit?: boolean; + /** The preferred block size of partial responses */ + preferredBlockSize?: number; } export interface CoapResponse { code: MessageCode; @@ -40,12 +42,18 @@ export declare class CoapClient { private static pendingRequestsByUrl; /** Queue of the messages waiting to be sent */ private static sendQueue; - /** Number of message we expect an answer for */ - private static concurrency; + /** Default values for request options */ + private static defaultRequestOptions; /** * Sets the security params to be used for the given hostname */ static setSecurityParams(hostname: string, params: SecurityParameters): void; + /** + * Sets the default options for requests + * @param defaults The default options to use for requests when no options are given + */ + static setDefaultRequestOptions(defaults: RequestOptions): void; + private static getRequestOptions(options?); /** * Closes and forgets about connections, useful if DTLS session is reset on remote end * @param originOrHostname - Origin (protocol://hostname:port) or Hostname to reset, @@ -60,6 +68,11 @@ export declare class CoapClient { * @param options - Various options to control the request. */ static request(url: string | nodeUrl.Url, method: RequestMethod, payload?: Buffer, options?: RequestOptions): Promise; + /** + * Creates a RetransmissionInfo to use for retransmission of lost packets + * @param messageId The message id of the corresponding request + */ + private static createRetransmissionInfo(messageId); /** * Pings a CoAP endpoint to check if it is alive * @param target - The target to be pinged. Must be a string, NodeJS.Url or Origin and has to contain the protocol, host and port. @@ -73,6 +86,11 @@ export declare class CoapClient { private static retransmit(msgID); private static getRetransmissionInterval(); private static stopRetransmission(request); + /** + * When the server responds with block-wise responses, this requests the next block. + * @param request The original request which resulted in a block-wise response + */ + private static requestNextBlock(request); /** * Observes a CoAP resource * @param url - The URL to be requested. Must start with coap:// or coaps:// @@ -102,7 +120,7 @@ export declare class CoapClient { * @param message The message to send * @param highPriority Whether the message should be prioritized */ - private static send(connection, message, highPriority?); + private static send(connection, message, priority?); private static workOffSendQueue(); /** * Does the actual sending of a message and starts concurrency/retransmission handling diff --git a/build/CoapClient.js b/build/CoapClient.js index ce4a97e7..7588ef28 100644 --- a/build/CoapClient.js +++ b/build/CoapClient.js @@ -21,6 +21,7 @@ const Message_1 = require("./Message"); const Option_1 = require("./Option"); // initialize debugging const debugPackage = require("debug"); +const LogMessage_1 = require("./lib/LogMessage"); const debug = debugPackage("node-coap-client"); // print version info // tslint:disable-next-line:no-var-requires @@ -86,14 +87,16 @@ function incrementToken(token) { function incrementMessageID(msgId) { return (++msgId > 0xffff) ? 1 : msgId; } -function findOption(opts, name) { - for (const opt of opts) { - if (opt.name === name) - return opt; - } -} -function findOptions(opts, name) { - return opts.filter(opt => opt.name === name); +function validateBlockSize(size) { + // block size is represented as 2**(4 + X) where X is an integer from 0..6 + const exp = Math.log2(size) - 4; + // is the exponent an integer? + if (exp % 1 !== 0) + return false; + // is the exponent in the range of 0..6? + if (exp < 0 || exp > 6) + return false; + return true; } /** * provides methods to access CoAP server resources @@ -105,6 +108,43 @@ class CoapClient { static setSecurityParams(hostname, params) { CoapClient.dtlsParams[hostname] = params; } + /** + * Sets the default options for requests + * @param defaults The default options to use for requests when no options are given + */ + static setDefaultRequestOptions(defaults) { + if (defaults.confirmable != null) + this.defaultRequestOptions.confirmable = defaults.confirmable; + if (defaults.keepAlive != null) + this.defaultRequestOptions.keepAlive = defaults.keepAlive; + if (defaults.retransmit != null) + this.defaultRequestOptions.retransmit = defaults.retransmit; + if (defaults.preferredBlockSize != null) { + if (!validateBlockSize(defaults.preferredBlockSize)) { + throw new Error(`${defaults.preferredBlockSize} is not a valid block size. The value must be a power of 2 between 16 and 1024`); + } + this.defaultRequestOptions.preferredBlockSize = defaults.preferredBlockSize; + } + } + static getRequestOptions(options) { + // ensure we have options and set the default params + options = options || {}; + if (options.confirmable == null) + options.confirmable = this.defaultRequestOptions.confirmable; + if (options.keepAlive == null) + options.keepAlive = this.defaultRequestOptions.keepAlive; + if (options.retransmit == null) + options.retransmit = this.defaultRequestOptions.retransmit; + if (options.preferredBlockSize == null) { + options.preferredBlockSize = this.defaultRequestOptions.preferredBlockSize; + } + else { + if (!validateBlockSize(options.preferredBlockSize)) { + throw new Error(`${options.preferredBlockSize} is not a valid block size. The value must be a power of 2 between 16 and 1024`); + } + } + return options; + } /** * Closes and forgets about connections, useful if DTLS session is reset on remote end * @param originOrHostname - Origin (protocol://hostname:port) or Hostname to reset, @@ -175,28 +215,18 @@ class CoapClient { url = nodeUrl.parse(url); } // ensure we have options and set the default params - options = options || {}; - if (options.confirmable == null) - options.confirmable = true; - if (options.keepAlive == null) - options.keepAlive = true; - if (options.retransmit == null) - options.retransmit = true; + options = this.getRequestOptions(options); // retrieve or create the connection we're going to use const origin = Origin_1.Origin.fromUrl(url); - const originString = origin.toString(); const connection = yield CoapClient.getConnection(origin); // find all the message parameters const type = options.confirmable ? Message_1.MessageType.CON : Message_1.MessageType.NON; const code = Message_1.MessageCodes.request[method]; const messageId = connection.lastMsgId = incrementMessageID(connection.lastMsgId); const token = connection.lastToken = incrementToken(connection.lastToken); - const tokenString = token.toString("hex"); payload = payload || Buffer.from([]); // create message options, be careful to order them by code, no sorting is implemented yet const msgOptions = []; - //// [6] observe or not? - // msgOptions.push(Options.Observe(options.observe)) // [11] path of the request let pathname = url.pathname || ""; while (pathname.startsWith("/")) { @@ -209,6 +239,10 @@ class CoapClient { msgOptions.push(...pathParts.map(part => Option_1.Options.UriPath(part))); // [12] content format msgOptions.push(Option_1.Options.ContentFormat(ContentFormats_1.ContentFormats.application_json)); + // [23] Block2 (preferred response block size) + if (options.preferredBlockSize != null) { + msgOptions.push(Option_1.Options.Block2(0, true, options.preferredBlockSize)); + } // create the promise we're going to return const response = DeferredPromise_1.createDeferredPromise(); // create the message we're going to send @@ -216,13 +250,7 @@ class CoapClient { // create the retransmission info let retransmit; if (options.retransmit && type === Message_1.MessageType.CON) { - const timeout = CoapClient.getRetransmissionInterval(); - retransmit = { - timeout, - action: () => CoapClient.retransmit(messageId), - jsTimeout: null, - counter: 0, - }; + retransmit = CoapClient.createRetransmissionInfo(messageId); } // remember the request const req = new PendingRequest({ @@ -243,6 +271,18 @@ class CoapClient { return response; }); } + /** + * Creates a RetransmissionInfo to use for retransmission of lost packets + * @param messageId The message id of the corresponding request + */ + static createRetransmissionInfo(messageId) { + return { + timeout: CoapClient.getRetransmissionInterval(), + action: () => CoapClient.retransmit(messageId), + jsTimeout: null, + counter: 0, + }; + } /** * Pings a CoAP endpoint to check if it is alive * @param target - The target to be pinged. Must be a string, NodeJS.Url or Origin and has to contain the protocol, host and port. @@ -329,7 +369,7 @@ class CoapClient { } debug(`retransmitting message ${msgID.toString(16)}, try #${request.retransmit.counter + 1}`); // resend the message - CoapClient.send(request.connection, request.originalMessage, true); + CoapClient.send(request.connection, request.originalMessage, "immediate"); // and increase the params request.retransmit.counter++; request.retransmit.timeout *= 2; @@ -345,6 +385,30 @@ class CoapClient { clearTimeout(request.retransmit.jsTimeout); request.retransmit = null; } + /** + * When the server responds with block-wise responses, this requests the next block. + * @param request The original request which resulted in a block-wise response + */ + static requestNextBlock(request) { + const message = request.originalMessage; + const connection = request.connection; + // requests for the next block are a new message with a new message id + const oldMsgID = message.messageId; + message.messageId = connection.lastMsgId = incrementMessageID(connection.lastMsgId); + // this means we have to update the dictionaries aswell, so the request is still found + CoapClient.pendingRequestsByMsgID[message.messageId] = request; + delete CoapClient.pendingRequestsByMsgID[oldMsgID]; + // even if the original request was an observe, the partial requests are not + message.options = message.options.filter(o => o.name !== "Observe"); + // Change the Block2 option, so the server knows which block to send + const block2Opt = Option_1.findOption(message.options, "Block2"); + block2Opt.isLastBlock = true; // not sure if that's necessary, but better be safe + block2Opt.blockNumber++; + // enable retransmission for this updated request + request.retransmit = CoapClient.createRetransmissionInfo(message.messageId); + // and enqueue it for sending + CoapClient.send(connection, message, "high"); + } /** * Observes a CoAP resource * @param url - The URL to be requested. Must start with coap:// or coaps:// @@ -359,23 +423,15 @@ class CoapClient { url = nodeUrl.parse(url); } // ensure we have options and set the default params - options = options || {}; - if (options.confirmable == null) - options.confirmable = true; - if (options.keepAlive == null) - options.keepAlive = true; - if (options.retransmit == null) - options.retransmit = true; + options = this.getRequestOptions(options); // retrieve or create the connection we're going to use const origin = Origin_1.Origin.fromUrl(url); - const originString = origin.toString(); const connection = yield CoapClient.getConnection(origin); // find all the message parameters const type = options.confirmable ? Message_1.MessageType.CON : Message_1.MessageType.NON; const code = Message_1.MessageCodes.request[method]; const messageId = connection.lastMsgId = incrementMessageID(connection.lastMsgId); const token = connection.lastToken = incrementToken(connection.lastToken); - const tokenString = token.toString("hex"); payload = payload || Buffer.from([]); // create message options, be careful to order them by code, no sorting is implemented yet const msgOptions = []; @@ -393,20 +449,14 @@ class CoapClient { msgOptions.push(...pathParts.map(part => Option_1.Options.UriPath(part))); // [12] content format msgOptions.push(Option_1.Options.ContentFormat(ContentFormats_1.ContentFormats.application_json)); - // create the promise we're going to return - const response = DeferredPromise_1.createDeferredPromise(); + // In contrast to requests, we don't work with a deferred promise when observing + // Instead, we invoke a callback for *every* response. // create the message we're going to send const message = CoapClient.createMessage(type, code, messageId, token, msgOptions, payload); // create the retransmission info let retransmit; if (options.retransmit && type === Message_1.MessageType.CON) { - const timeout = CoapClient.getRetransmissionInterval(); - retransmit = { - timeout, - action: () => CoapClient.retransmit(messageId), - jsTimeout: null, - counter: 0, - }; + retransmit = CoapClient.createRetransmissionInfo(messageId); } // remember the request const req = new PendingRequest({ @@ -442,7 +492,7 @@ class CoapClient { static onMessage(origin, message, rinfo) { // parse the CoAP message const coapMsg = Message_1.Message.parse(message); - debug(`received message: ID=0x${coapMsg.messageId.toString(16)}${(coapMsg.token && coapMsg.token.length) ? (", token=" + coapMsg.token.toString("hex")) : ""}`); + LogMessage_1.logMessage(coapMsg); if (coapMsg.code.isEmpty()) { // ACK or RST // see if we have a request for this message id @@ -478,7 +528,6 @@ class CoapClient { // ignore them } else if (coapMsg.code.isResponse()) { - debug(`response with payload: ${coapMsg.payload.toString("utf8")}`); // this is a response, find out what to do with it if (coapMsg.token && coapMsg.token.length) { // this message has a token, check which request it belongs to @@ -489,17 +538,46 @@ class CoapClient { if (coapMsg.type === Message_1.MessageType.ACK) { debug(`received ACK for message 0x${coapMsg.messageId.toString(16)}, stopping retransmission...`); CoapClient.stopRetransmission(request); - // reduce the request's concurrency, since it was handled on the server - request.concurrency = 0; } // parse options let contentFormat = null; if (coapMsg.options && coapMsg.options.length) { // see if the response contains information about the content format - const optCntFmt = findOption(coapMsg.options, "Content-Format"); + const optCntFmt = Option_1.findOption(coapMsg.options, "Content-Format"); if (optCntFmt) contentFormat = optCntFmt.value; } + let responseIsComplete = true; + if (coapMsg.isPartialMessage()) { + // Check if we expect more blocks + const blockOption = Option_1.findOption(coapMsg.options, "Block2"); // we know this is != null + // TODO: check for outdated partial responses + // assemble the partial blocks + if (request.partialResponse == null) { + request.partialResponse = coapMsg; + } + else { + // extend the stored buffer + // TODO: we might have to check if we got the correct fragment + request.partialResponse.payload = Buffer.concat([request.partialResponse.payload, coapMsg.payload]); + } + if (blockOption.isLastBlock) { + // override the message payload with the assembled partial payload + // so the full payload gets returned to the listeners + coapMsg.payload = request.partialResponse.payload; + } + else { + CoapClient.requestNextBlock(request); + responseIsComplete = false; + } + } + // Now that we have a response, also reduce the request's concurrency, + // so other requests can be fired off + if (coapMsg.type === Message_1.MessageType.ACK) + request.concurrency = 0; + // while we only have a partial response, we cannot return it to the caller yet + if (!responseIsComplete) + return; // prepare the response const response = { code: coapMsg.code, @@ -520,7 +598,7 @@ class CoapClient { if (coapMsg.type === Message_1.MessageType.CON) { debug(`sending ACK for message 0x${coapMsg.messageId.toString(16)}`); const ACK = CoapClient.createMessage(Message_1.MessageType.ACK, Message_1.MessageCodes.empty, coapMsg.messageId); - CoapClient.send(request.connection, ACK, true); + CoapClient.send(request.connection, ACK, "immediate"); } } else { @@ -532,7 +610,7 @@ class CoapClient { // and send the reset debug(`sending RST for message 0x${coapMsg.messageId.toString(16)}`); const RST = CoapClient.createMessage(Message_1.MessageType.RST, Message_1.MessageCodes.empty, coapMsg.messageId); - CoapClient.send(connection, RST, true); + CoapClient.send(connection, RST, "immediate"); } } // request != null? } // (coapMsg.token && coapMsg.token.length) @@ -557,24 +635,36 @@ class CoapClient { * @param message The message to send * @param highPriority Whether the message should be prioritized */ - static send(connection, message, highPriority = false) { + static send(connection, message, priority = "normal") { const request = CoapClient.findRequest({ msgID: message.messageId }); - if (highPriority) { - // Send high-prio messages immediately - debug(`sending high priority message 0x${message.messageId.toString(16)}`); - CoapClient.doSend(connection, request, message); - } - else { - // Put the message in the queue - CoapClient.sendQueue.push({ connection, message }); - debug(`added message to send queue, new length = ${CoapClient.sendQueue.length}`); + switch (priority) { + case "immediate": { + // Send high-prio messages immediately + // This is for ACKs, RSTs and retransmissions + debug(`sending high priority message 0x${message.messageId.toString(16)}`); + CoapClient.doSend(connection, request, message); + break; + } + case "normal": { + // Put the message in the queue + CoapClient.sendQueue.push({ connection, message }); + debug(`added message to the send queue with normal priority, new length = ${CoapClient.sendQueue.length}`); + break; + } + case "high": { + // Put the message in the queue (in first position) + // This is for subsequent requests to blockwise resources + CoapClient.sendQueue.unshift({ connection, message }); + debug(`added message to the send queue with high priority, new length = ${CoapClient.sendQueue.length}`); + break; + } } // if there's a request for this message, listen for concurrency changes if (request != null) { // and continue working off the queue when it drops request.on("concurrencyChanged", (req) => { debug(`request 0x${message.messageId.toString(16)}: concurrency changed => ${req.concurrency}`); - if (request.concurrency === 0) + if (req.concurrency === 0) CoapClient.workOffSendQueue(); }); } @@ -733,7 +823,6 @@ class CoapClient { target = Origin_1.Origin.fromUrl(target); } // retrieve or create the connection we're going to use - const originString = target.toString(); try { yield CoapClient.getConnection(target); return true; @@ -876,6 +965,11 @@ CoapClient.pendingRequestsByMsgID = {}; CoapClient.pendingRequestsByUrl = {}; /** Queue of the messages waiting to be sent */ CoapClient.sendQueue = []; -/** Number of message we expect an answer for */ -CoapClient.concurrency = 0; +/** Default values for request options */ +CoapClient.defaultRequestOptions = { + confirmable: true, + keepAlive: true, + retransmit: true, + preferredBlockSize: null, +}; exports.CoapClient = CoapClient; diff --git a/build/Message.d.ts b/build/Message.d.ts index 501fe610..068257a0 100644 --- a/build/Message.d.ts +++ b/build/Message.d.ts @@ -36,6 +36,7 @@ export declare const MessageCodes: Readonly<{ valid: MessageCode; changed: MessageCode; content: MessageCode; + continue: MessageCode; }; clientError: { __major: number; @@ -46,6 +47,7 @@ export declare const MessageCodes: Readonly<{ notFound: MessageCode; methodNotAllowed: MessageCode; notAcceptable: MessageCode; + requestEntityIncomplete: MessageCode; preconditionFailed: MessageCode; requestEntityTooLarge: MessageCode; unsupportedContentFormat: MessageCode; @@ -81,4 +83,8 @@ export declare class Message { * serializes this message into a buffer */ serialize(): Buffer; + /** + * Checks if this message is part of a blockwise transfer + */ + isPartialMessage(): boolean; } diff --git a/build/Message.js b/build/Message.js index aff20d4d..fde6c1ce 100644 --- a/build/Message.js +++ b/build/Message.js @@ -49,6 +49,7 @@ exports.MessageCodes = Object.freeze({ valid: new MessageCode(2, 3), changed: new MessageCode(2, 4), content: new MessageCode(2, 5), + continue: new MessageCode(2, 31), }, clientError: { __major: 4, @@ -59,6 +60,7 @@ exports.MessageCodes = Object.freeze({ notFound: new MessageCode(4, 4), methodNotAllowed: new MessageCode(4, 5), notAcceptable: new MessageCode(4, 6), + requestEntityIncomplete: new MessageCode(4, 8), preconditionFailed: new MessageCode(4, 12), requestEntityTooLarge: new MessageCode(4, 13), unsupportedContentFormat: new MessageCode(4, 15), @@ -165,6 +167,19 @@ class Message { } return ret; } + /** + * Checks if this message is part of a blockwise transfer + */ + isPartialMessage() { + // start with the response option, since that's more likely + const block2option = Option_1.findOption(this.options, "Block2"); + if (this.code.isResponse() && block2option != null) + return true; + const block1option = Option_1.findOption(this.options, "Block1"); + if (this.code.isRequest() && block1option != null) + return true; + return false; + } } exports.Message = Message; /* diff --git a/build/Option.d.ts b/build/Option.d.ts index 4757f213..532d6783 100644 --- a/build/Option.d.ts +++ b/build/Option.d.ts @@ -1,13 +1,17 @@ /// import { ContentFormats } from "./ContentFormats"; +/** + * All defined option names + */ +export declare type OptionName = "Observe" | "Uri-Port" | "Content-Format" | "Max-Age" | "Accept" | "Block2" | "Block1" | "Size2" | "Size1" | "If-Match" | "ETag" | "If-None-Match" | "Uri-Host" | "Location-Path" | "Uri-Path" | "Uri-Query" | "Location-Query" | "Proxy-Uri" | "Proxy-Scheme"; /** * Abstract base class for all message options. Provides methods to parse and serialize. */ export declare abstract class Option { readonly code: number; - readonly name: string; + readonly name: OptionName; rawValue: Buffer; - constructor(code: number, name: string, rawValue: Buffer); + constructor(code: number, name: OptionName, rawValue: Buffer); readonly noCacheKey: boolean; readonly unsafe: boolean; readonly critical: boolean; @@ -30,36 +34,69 @@ export declare abstract class Option { * Specialized Message option for numeric contents */ export declare class NumericOption extends Option { - readonly name: string; + readonly name: OptionName; readonly repeatable: boolean; readonly maxLength: number; - constructor(code: number, name: string, repeatable: boolean, maxLength: number, rawValue: Buffer); + constructor(code: number, name: OptionName, repeatable: boolean, maxLength: number, rawValue: Buffer); value: number; - static create(code: number, name: string, repeatable: boolean, maxLength: number, rawValue: Buffer): NumericOption; + static create(code: number, name: OptionName, repeatable: boolean, maxLength: number, rawValue: Buffer): NumericOption; + toString(): string; +} +/** + * Specialized Message optionis for blockwise transfer + */ +export declare class BlockOption extends NumericOption { + static create(code: number, name: OptionName, repeatable: boolean, maxLength: number, rawValue: Buffer): BlockOption; + /** + * The size exponent of this block in the range 0..6 + * The actual block size is calculated by 2**(4 + exp) + */ + sizeExponent: number; + /** + * The size of this block in bytes + */ + readonly blockSize: number; + /** + * Indicates if there are more blocks following after this one. + */ + isLastBlock: boolean; + /** + * The sequence number of this block. + * When present in a request message, this determines the number of the block being requested + * When present in a response message, this indicates the number of the provided block + */ + blockNumber: number; + /** + * Returns the position of the first byte of this block in the complete message + */ + readonly byteOffset: number; + toString(): string; } /** * Specialized Message options for binary (and empty) content. */ export declare class BinaryOption extends Option { - readonly name: string; + readonly name: OptionName; readonly repeatable: boolean; readonly minLength: number; readonly maxLength: number; - constructor(code: number, name: string, repeatable: boolean, minLength: number, maxLength: number, rawValue: Buffer); + constructor(code: number, name: OptionName, repeatable: boolean, minLength: number, maxLength: number, rawValue: Buffer); value: Buffer; - static create(code: number, name: string, repeatable: boolean, minLength: number, maxLength: number, rawValue: Buffer): BinaryOption; + static create(code: number, name: OptionName, repeatable: boolean, minLength: number, maxLength: number, rawValue: Buffer): BinaryOption; + toString(): string; } /** * Specialized Message options for string content. */ export declare class StringOption extends Option { - readonly name: string; + readonly name: OptionName; readonly repeatable: boolean; readonly minLength: number; readonly maxLength: number; - constructor(code: number, name: string, repeatable: boolean, minLength: number, maxLength: number, rawValue: Buffer); + constructor(code: number, name: OptionName, repeatable: boolean, minLength: number, maxLength: number, rawValue: Buffer); value: string; - static create(code: number, name: string, repeatable: boolean, minLength: number, maxLength: number, rawValue: Buffer): StringOption; + static create(code: number, name: OptionName, repeatable: boolean, minLength: number, maxLength: number, rawValue: Buffer): StringOption; + toString(): string; } export declare const Options: Readonly<{ UriHost: (hostname: string) => Option; @@ -68,4 +105,18 @@ export declare const Options: Readonly<{ LocationPath: (pathname: string) => Option; ContentFormat: (format: ContentFormats) => Option; Observe: (observe: boolean) => Option; + Block1: (num: number, isLast: boolean, size: number) => Option; + Block2: (num: number, isLast: boolean, size: number) => Option; }>; +/** + * Searches for a single option in an array of options + * @param opts The options array to search for the option + * @param name The name of the option to search for + */ +export declare function findOption(opts: Option[], name: OptionName): Option; +/** + * Searches for a repeatable option in an array of options + * @param opts The options array to search for the option + * @param name The name of the option to search for + */ +export declare function findOptions(opts: Option[], name: OptionName): Option[]; diff --git a/build/Option.js b/build/Option.js index 24d51e38..923eb9f4 100644 --- a/build/Option.js +++ b/build/Option.js @@ -175,8 +175,78 @@ class NumericOption extends Option { static create(code, name, repeatable, maxLength, rawValue) { return new NumericOption(code, name, repeatable, maxLength, rawValue); } + toString() { + return `${this.name} (${this.code}): ${this.value}`; + } } exports.NumericOption = NumericOption; +/** + * Specialized Message optionis for blockwise transfer + */ +class BlockOption extends NumericOption { + static create(code, name, repeatable, maxLength, rawValue) { + return new BlockOption(code, name, repeatable, maxLength, rawValue); + } + /** + * The size exponent of this block in the range 0..6 + * The actual block size is calculated by 2**(4 + exp) + */ + get sizeExponent() { + return this.value & 0b111; + } + set sizeExponent(value) { + if (value < 0 || value > 6) { + throw new Error("the size exponent must be in the range of 0..6"); + } + // overwrite the last 3 bits + this.value = (this.value & ~0b111) | value; + } + /** + * The size of this block in bytes + */ + get blockSize() { + return 1 << (this.sizeExponent + 4); + } + /** + * Indicates if there are more blocks following after this one. + */ + get isLastBlock() { + const moreBlocks = (this.value & 0b1000) === 0b1000; + return !moreBlocks; + } + set isLastBlock(value) { + const moreBlocks = !value; + // overwrite the 4th bit + this.value = (this.value & ~0b1000) | (moreBlocks ? 0b1000 : 0); + } + /** + * The sequence number of this block. + * When present in a request message, this determines the number of the block being requested + * When present in a response message, this indicates the number of the provided block + */ + get blockNumber() { + return this.value >>> 4; + } + set blockNumber(value) { + // TODO: check if we need to update the value length + this.value = (value << 4) | (this.value & 0b1111); + } + /** + * Returns the position of the first byte of this block in the complete message + */ + get byteOffset() { + // from the spec: + // Implementation note: As an implementation convenience, "(val & ~0xF) + // << (val & 7)", i.e., the option value with the last 4 bits masked + // out, shifted to the left by the value of SZX, gives the byte + // position of the first byte of the block being transferred. + return (this.value & ~0b1111) << (this.value & 0b111); + } + toString() { + return `${this.name} (${this.code}): ${this.blockNumber}/${this.isLastBlock ? 0 : 1}/${this.blockSize}`; + } +} +exports.BlockOption = BlockOption; /** * Specialized Message options for binary (and empty) content. */ @@ -206,6 +276,9 @@ class BinaryOption extends Option { static create(code, name, repeatable, minLength, maxLength, rawValue) { return new BinaryOption(code, name, repeatable, minLength, maxLength, rawValue); } + toString() { + return `${this.name} (${this.code}): 0x${this.rawValue.toString("hex")}`; + } } exports.BinaryOption = BinaryOption; /** @@ -237,6 +310,9 @@ class StringOption extends Option { static create(code, name, repeatable, minLength, maxLength, rawValue) { return new StringOption(code, name, repeatable, minLength, maxLength, rawValue); } + toString() { + return `${this.name} (${this.code}): "${this.value}"`; + } } exports.StringOption = StringOption; /** @@ -254,6 +330,9 @@ defineOptionConstructor(NumericOption, 7, "Uri-Port", false, 2); defineOptionConstructor(NumericOption, 12, "Content-Format", false, 2); defineOptionConstructor(NumericOption, 14, "Max-Age", false, 4); defineOptionConstructor(NumericOption, 17, "Accept", false, 2); +defineOptionConstructor(BlockOption, 23, "Block2", false, 3); +defineOptionConstructor(BlockOption, 27, "Block1", false, 3); +defineOptionConstructor(NumericOption, 28, "Size2", false, 4); defineOptionConstructor(NumericOption, 60, "Size1", false, 4); defineOptionConstructor(BinaryOption, 1, "If-Match", true, 0, 8); defineOptionConstructor(BinaryOption, 4, "ETag", true, 1, 8); @@ -265,6 +344,7 @@ defineOptionConstructor(StringOption, 15, "Uri-Query", true, 0, 255); defineOptionConstructor(StringOption, 20, "Location-Query", true, 0, 255); defineOptionConstructor(StringOption, 35, "Proxy-Uri", true, 1, 1034); defineOptionConstructor(StringOption, 39, "Proxy-Scheme", true, 1, 255); +// tslint:disable:no-string-literal // tslint:disable-next-line:variable-name exports.Options = Object.freeze({ UriHost: (hostname) => optionConstructors["Uri-Host"](Buffer.from(hostname)), @@ -272,6 +352,36 @@ exports.Options = Object.freeze({ UriPath: (pathname) => optionConstructors["Uri-Path"](Buffer.from(pathname)), LocationPath: (pathname) => optionConstructors["Location-Path"](Buffer.from(pathname)), ContentFormat: (format) => optionConstructors["Content-Format"](numberToBuffer(format)), - // tslint:disable-next-line:no-string-literal Observe: (observe) => optionConstructors["Observe"](Buffer.from([observe ? 0 : 1])), + Block1: (num, isLast, size) => { + // Warning: we're not checking for a valid size here, do that in advance! + const sizeExp = Math.log2(size) - 4; + const value = (num << 4) | (isLast ? 0 : 0b1000) | (sizeExp & 0b111); + return optionConstructors["Block1"](numberToBuffer(value)); + }, + Block2: (num, isLast, size) => { + // Warning: we're not checking for a valid size here, do that in advance! + const sizeExp = Math.log2(size) - 4; + const value = (num << 4) | (isLast ? 0 : 0b1000) | (sizeExp & 0b111); + return optionConstructors["Block2"](numberToBuffer(value)); + }, }); +// tslint:enable:no-string-literal +/** + * Searches for a single option in an array of options + * @param opts The options array to search for the option + * @param name The name of the option to search for + */ +function findOption(opts, name) { + return opts.find(o => o.name === name); +} +exports.findOption = findOption; +/** + * Searches for a repeatable option in an array of options + * @param opts The options array to search for the option + * @param name The name of the option to search for + */ +function findOptions(opts, name) { + return opts.filter(o => o.name === name); +} +exports.findOptions = findOptions; diff --git a/build/lib/LogMessage.d.ts b/build/lib/LogMessage.d.ts new file mode 100644 index 00000000..fc0569da --- /dev/null +++ b/build/lib/LogMessage.d.ts @@ -0,0 +1,2 @@ +import { Message } from "../Message"; +export declare function logMessage(msg: Message, includePayload?: boolean): void; diff --git a/build/lib/LogMessage.js b/build/lib/LogMessage.js new file mode 100644 index 00000000..e805fc4d --- /dev/null +++ b/build/lib/LogMessage.js @@ -0,0 +1,25 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +// initialize debugging +const debugPackage = require("debug"); +const debug = debugPackage("node-coap-client:message"); +function logMessage(msg, includePayload = false) { + debug("============================="); + debug(`received message`); + debug(`messageId: ${msg.messageId}`); + if (msg.token != null) { + debug(`token: ${msg.token.toString("hex")}`); + } + debug(`code: ${msg.code}`); + debug(`type: ${msg.type}`); + debug(`version: ${msg.version}`); + debug("options:"); + for (const opt of msg.options) { + debug(` [${opt.constructor.name}] ${opt.toString()}`); + } + debug("payload:"); + debug(msg.payload.toString("utf-8")); + debug("============================="); + debug(""); +} +exports.logMessage = logMessage; diff --git a/src/CoapClient.ts b/src/CoapClient.ts index 7b9f5735..afbb8256 100644 --- a/src/CoapClient.ts +++ b/src/CoapClient.ts @@ -8,10 +8,11 @@ import { createDeferredPromise, DeferredPromise } from "./lib/DeferredPromise"; import { Origin } from "./lib/Origin"; import { SocketWrapper } from "./lib/SocketWrapper"; import { Message, MessageCode, MessageCodes, MessageType } from "./Message"; -import { BinaryOption, NumericOption, Option, Options, StringOption } from "./Option"; +import { BinaryOption, BlockOption, findOption, NumericOption, Option, Options, StringOption } from "./Option"; // initialize debugging import * as debugPackage from "debug"; +import { logMessage } from "./lib/LogMessage"; const debug = debugPackage("node-coap-client"); // print version info @@ -29,6 +30,8 @@ export interface RequestOptions { confirmable?: boolean; /** Whether this message will be retransmitted on loss */ retransmit?: boolean; + /** The preferred block size of partial responses */ + preferredBlockSize?: number; } export interface CoapResponse { @@ -53,6 +56,7 @@ interface IPendingRequest { url: string; originalMessage: Message; // allows resending the message, includes token and message id retransmit: RetransmissionInfo; + partialResponse?: Message; // either (request): promise: Promise; // or (observe) @@ -81,6 +85,7 @@ class PendingRequest extends EventEmitter implements IPendingRequest { public connection: ConnectionInfo; public url: string; public originalMessage: Message; // allows resending the message, includes token and message id + public partialResponse: Message; // allows buffering for block-wise message receipt public retransmit: RetransmissionInfo; // either (request): public promise: Promise; @@ -151,14 +156,14 @@ function incrementMessageID(msgId: number): number { return (++msgId > 0xffff) ? 1 : msgId; } -function findOption(opts: Option[], name: string): Option { - for (const opt of opts) { - if (opt.name === name) return opt; - } -} - -function findOptions(opts: Option[], name: string): Option[] { - return opts.filter(opt => opt.name === name); +function validateBlockSize(size: number): boolean { + // block size is represented as 2**(4 + X) where X is an integer from 0..6 + const exp = Math.log2(size) - 4; + // is the exponent an integer? + if (exp % 1 !== 0) return false; + // is the exponent in the range of 0..6? + if (exp < 0 || exp > 6) return false; + return true; } /** @@ -179,8 +184,13 @@ export class CoapClient { private static pendingRequestsByUrl: { [url: string]: PendingRequest } = {}; /** Queue of the messages waiting to be sent */ private static sendQueue: QueuedMessage[] = []; - /** Number of message we expect an answer for */ - private static concurrency: number = 0; + /** Default values for request options */ + private static defaultRequestOptions: RequestOptions = { + confirmable: true, + keepAlive: true, + retransmit: true, + preferredBlockSize: null, + }; /** * Sets the security params to be used for the given hostname @@ -189,6 +199,38 @@ export class CoapClient { CoapClient.dtlsParams[hostname] = params; } + /** + * Sets the default options for requests + * @param defaults The default options to use for requests when no options are given + */ + public static setDefaultRequestOptions(defaults: RequestOptions): void { + if (defaults.confirmable != null) this.defaultRequestOptions.confirmable = defaults.confirmable; + if (defaults.keepAlive != null) this.defaultRequestOptions.keepAlive = defaults.keepAlive; + if (defaults.retransmit != null) this.defaultRequestOptions.retransmit = defaults.retransmit; + if (defaults.preferredBlockSize != null) { + if (!validateBlockSize(defaults.preferredBlockSize)) { + throw new Error(`${defaults.preferredBlockSize} is not a valid block size. The value must be a power of 2 between 16 and 1024`); + } + this.defaultRequestOptions.preferredBlockSize = defaults.preferredBlockSize; + } + } + + private static getRequestOptions(options?: RequestOptions): RequestOptions { + // ensure we have options and set the default params + options = options || {}; + if (options.confirmable == null) options.confirmable = this.defaultRequestOptions.confirmable; + if (options.keepAlive == null) options.keepAlive = this.defaultRequestOptions.keepAlive; + if (options.retransmit == null) options.retransmit = this.defaultRequestOptions.retransmit; + if (options.preferredBlockSize == null) { + options.preferredBlockSize = this.defaultRequestOptions.preferredBlockSize; + } else { + if (!validateBlockSize(options.preferredBlockSize)) { + throw new Error(`${options.preferredBlockSize} is not a valid block size. The value must be a power of 2 between 16 and 1024`); + } + } + return options; + } + /** * Closes and forgets about connections, useful if DTLS session is reset on remote end * @param originOrHostname - Origin (protocol://hostname:port) or Hostname to reset, @@ -266,14 +308,10 @@ export class CoapClient { } // ensure we have options and set the default params - options = options || {}; - if (options.confirmable == null) options.confirmable = true; - if (options.keepAlive == null) options.keepAlive = true; - if (options.retransmit == null) options.retransmit = true; + options = this.getRequestOptions(options); // retrieve or create the connection we're going to use const origin = Origin.fromUrl(url); - const originString = origin.toString(); const connection = await CoapClient.getConnection(origin); // find all the message parameters @@ -281,13 +319,10 @@ export class CoapClient { const code = MessageCodes.request[method]; const messageId = connection.lastMsgId = incrementMessageID(connection.lastMsgId); const token = connection.lastToken = incrementToken(connection.lastToken); - const tokenString = token.toString("hex"); payload = payload || Buffer.from([]); // create message options, be careful to order them by code, no sorting is implemented yet const msgOptions: Option[] = []; - //// [6] observe or not? - // msgOptions.push(Options.Observe(options.observe)) // [11] path of the request let pathname = url.pathname || ""; while (pathname.startsWith("/")) { pathname = pathname.slice(1); } @@ -298,6 +333,10 @@ export class CoapClient { ); // [12] content format msgOptions.push(Options.ContentFormat(ContentFormats.application_json)); + // [23] Block2 (preferred response block size) + if (options.preferredBlockSize != null) { + msgOptions.push(Options.Block2(0, true, options.preferredBlockSize)); + } // create the promise we're going to return const response = createDeferredPromise(); @@ -308,13 +347,7 @@ export class CoapClient { // create the retransmission info let retransmit: RetransmissionInfo; if (options.retransmit && type === MessageType.CON) { - const timeout = CoapClient.getRetransmissionInterval(); - retransmit = { - timeout, - action: () => CoapClient.retransmit(messageId), - jsTimeout: null, - counter: 0, - }; + retransmit = CoapClient.createRetransmissionInfo(messageId); } // remember the request @@ -339,6 +372,19 @@ export class CoapClient { } + /** + * Creates a RetransmissionInfo to use for retransmission of lost packets + * @param messageId The message id of the corresponding request + */ + private static createRetransmissionInfo(messageId: number): RetransmissionInfo { + return { + timeout: CoapClient.getRetransmissionInterval(), + action: () => CoapClient.retransmit(messageId), + jsTimeout: null, + counter: 0, + }; + } + /** * Pings a CoAP endpoint to check if it is alive * @param target - The target to be pinged. Must be a string, NodeJS.Url or Origin and has to contain the protocol, host and port. @@ -437,7 +483,7 @@ export class CoapClient { debug(`retransmitting message ${msgID.toString(16)}, try #${request.retransmit.counter + 1}`); // resend the message - CoapClient.send(request.connection, request.originalMessage, true); + CoapClient.send(request.connection, request.originalMessage, "immediate"); // and increase the params request.retransmit.counter++; request.retransmit.timeout *= 2; @@ -454,6 +500,35 @@ export class CoapClient { request.retransmit = null; } + /** + * When the server responds with block-wise responses, this requests the next block. + * @param request The original request which resulted in a block-wise response + */ + private static requestNextBlock(request: PendingRequest) { + const message = request.originalMessage; + const connection = request.connection; + + // requests for the next block are a new message with a new message id + const oldMsgID = message.messageId; + message.messageId = connection.lastMsgId = incrementMessageID(connection.lastMsgId); + // this means we have to update the dictionaries aswell, so the request is still found + CoapClient.pendingRequestsByMsgID[message.messageId] = request; + delete CoapClient.pendingRequestsByMsgID[oldMsgID]; + + // even if the original request was an observe, the partial requests are not + message.options = message.options.filter(o => o.name !== "Observe"); + + // Change the Block2 option, so the server knows which block to send + const block2Opt = findOption(message.options, "Block2") as BlockOption; + block2Opt.isLastBlock = true; // not sure if that's necessary, but better be safe + block2Opt.blockNumber++; + + // enable retransmission for this updated request + request.retransmit = CoapClient.createRetransmissionInfo(message.messageId); + // and enqueue it for sending + CoapClient.send(connection, message, "high"); + } + /** * Observes a CoAP resource * @param url - The URL to be requested. Must start with coap:// or coaps:// @@ -475,14 +550,10 @@ export class CoapClient { } // ensure we have options and set the default params - options = options || {}; - if (options.confirmable == null) options.confirmable = true; - if (options.keepAlive == null) options.keepAlive = true; - if (options.retransmit == null) options.retransmit = true; + options = this.getRequestOptions(options); // retrieve or create the connection we're going to use const origin = Origin.fromUrl(url); - const originString = origin.toString(); const connection = await CoapClient.getConnection(origin); // find all the message parameters @@ -490,7 +561,6 @@ export class CoapClient { const code = MessageCodes.request[method]; const messageId = connection.lastMsgId = incrementMessageID(connection.lastMsgId); const token = connection.lastToken = incrementToken(connection.lastToken); - const tokenString = token.toString("hex"); payload = payload || Buffer.from([]); // create message options, be careful to order them by code, no sorting is implemented yet @@ -508,8 +578,8 @@ export class CoapClient { // [12] content format msgOptions.push(Options.ContentFormat(ContentFormats.application_json)); - // create the promise we're going to return - const response = createDeferredPromise(); + // In contrast to requests, we don't work with a deferred promise when observing + // Instead, we invoke a callback for *every* response. // create the message we're going to send const message = CoapClient.createMessage(type, code, messageId, token, msgOptions, payload); @@ -517,13 +587,7 @@ export class CoapClient { // create the retransmission info let retransmit: RetransmissionInfo; if (options.retransmit && type === MessageType.CON) { - const timeout = CoapClient.getRetransmissionInterval(); - retransmit = { - timeout, - action: () => CoapClient.retransmit(messageId), - jsTimeout: null, - counter: 0, - }; + retransmit = CoapClient.createRetransmissionInfo(messageId); } // remember the request @@ -565,7 +629,7 @@ export class CoapClient { private static onMessage(origin: string, message: Buffer, rinfo: dgram.RemoteInfo) { // parse the CoAP message const coapMsg = Message.parse(message); - debug(`received message: ID=0x${coapMsg.messageId.toString(16)}${(coapMsg.token && coapMsg.token.length) ? (", token=" + coapMsg.token.toString("hex")) : ""}`); + logMessage(coapMsg); if (coapMsg.code.isEmpty()) { // ACK or RST @@ -602,7 +666,6 @@ export class CoapClient { // we are a client implementation, we should not get requests // ignore them } else if (coapMsg.code.isResponse()) { - debug(`response with payload: ${coapMsg.payload.toString("utf8")}`); // this is a response, find out what to do with it if (coapMsg.token && coapMsg.token.length) { // this message has a token, check which request it belongs to @@ -614,8 +677,6 @@ export class CoapClient { if (coapMsg.type === MessageType.ACK) { debug(`received ACK for message 0x${coapMsg.messageId.toString(16)}, stopping retransmission...`); CoapClient.stopRetransmission(request); - // reduce the request's concurrency, since it was handled on the server - request.concurrency = 0; } // parse options @@ -626,6 +687,37 @@ export class CoapClient { if (optCntFmt) contentFormat = (optCntFmt as NumericOption).value; } + let responseIsComplete: boolean = true; + if (coapMsg.isPartialMessage()) { + // Check if we expect more blocks + const blockOption = findOption(coapMsg.options, "Block2") as BlockOption; // we know this is != null + // TODO: check for outdated partial responses + + // assemble the partial blocks + if (request.partialResponse == null) { + request.partialResponse = coapMsg; + } else { + // extend the stored buffer + // TODO: we might have to check if we got the correct fragment + request.partialResponse.payload = Buffer.concat([request.partialResponse.payload, coapMsg.payload]); + } + if (blockOption.isLastBlock) { + // override the message payload with the assembled partial payload + // so the full payload gets returned to the listeners + coapMsg.payload = request.partialResponse.payload; + } else { + CoapClient.requestNextBlock(request); + responseIsComplete = false; + } + } + + // Now that we have a response, also reduce the request's concurrency, + // so other requests can be fired off + if (coapMsg.type === MessageType.ACK) request.concurrency = 0; + + // while we only have a partial response, we cannot return it to the caller yet + if (!responseIsComplete) return; + // prepare the response const response: CoapResponse = { code: coapMsg.code, @@ -651,7 +743,7 @@ export class CoapClient { MessageCodes.empty, coapMsg.messageId, ); - CoapClient.send(request.connection, ACK, true); + CoapClient.send(request.connection, ACK, "immediate"); } } else { // request == null @@ -669,7 +761,7 @@ export class CoapClient { MessageCodes.empty, coapMsg.messageId, ); - CoapClient.send(connection, RST, true); + CoapClient.send(connection, RST, "immediate"); } } // request != null? } // (coapMsg.token && coapMsg.token.length) @@ -709,19 +801,32 @@ export class CoapClient { private static send( connection: ConnectionInfo, message: Message, - highPriority: boolean = false, + priority: "normal" | "high" | "immediate" = "normal", ): void { const request = CoapClient.findRequest({msgID: message.messageId}); - if (highPriority) { - // Send high-prio messages immediately - debug(`sending high priority message 0x${message.messageId.toString(16)}`); - CoapClient.doSend(connection, request, message); - } else { - // Put the message in the queue - CoapClient.sendQueue.push({connection, message}); - debug(`added message to send queue, new length = ${CoapClient.sendQueue.length}`); + switch (priority) { + case "immediate": { + // Send high-prio messages immediately + // This is for ACKs, RSTs and retransmissions + debug(`sending high priority message 0x${message.messageId.toString(16)}`); + CoapClient.doSend(connection, request, message); + break; + } + case "normal": { + // Put the message in the queue + CoapClient.sendQueue.push({connection, message}); + debug(`added message to the send queue with normal priority, new length = ${CoapClient.sendQueue.length}`); + break; + } + case "high": { + // Put the message in the queue (in first position) + // This is for subsequent requests to blockwise resources + CoapClient.sendQueue.unshift({connection, message}); + debug(`added message to the send queue with high priority, new length = ${CoapClient.sendQueue.length}`); + break; + } } // if there's a request for this message, listen for concurrency changes @@ -729,7 +834,7 @@ export class CoapClient { // and continue working off the queue when it drops request.on("concurrencyChanged", (req: PendingRequest) => { debug(`request 0x${message.messageId.toString(16)}: concurrency changed => ${req.concurrency}`); - if (request.concurrency === 0) CoapClient.workOffSendQueue(); + if (req.concurrency === 0) CoapClient.workOffSendQueue(); }); } @@ -929,7 +1034,6 @@ export class CoapClient { } // retrieve or create the connection we're going to use - const originString = target.toString(); try { await CoapClient.getConnection(target); return true; diff --git a/src/Message.test.ts b/src/Message.test.ts index 7ce53e8c..0f157c94 100644 --- a/src/Message.test.ts +++ b/src/Message.test.ts @@ -1,5 +1,8 @@ +// tslint:disable:no-console +// tslint:disable:no-unused-expression import { expect } from "chai"; +import { CoapClient as coap } from "./CoapClient"; import { Message, MessageCode, MessageCodes, MessageType } from "./Message"; describe("Message Tests =>", () => { @@ -32,3 +35,86 @@ describe("Message Tests =>", () => { }); }); + +describe.only("blockwise tests =>", () => { + // This buffer from https://github.com/AlCalzone/node-coap-client/issues/21 + // is a raw message contains the Block option + const buf = Buffer.from( + "64450025018fccf460613223093a80910eff7b2239303031223a224556455259444159" + + "222c2239303032223a313530383235303135392c2239303638223a312c223930303322" + + "3a3230303436382c2239303537223a302c223135303133223a5b7b2235383530223a31" + + "2c2235383531223a3230332c2235373037223a353432372c2235373038223a34323539" + + "362c2235373039223a33303031352c2235373130223a32363837302c2235373131223a" + + "302c2235373036223a22663165306235222c2239303033223a36353533397d2c7b2235" + + "383530223a312c2235383531223a3230332c2235373037223a353432372c2235373038" + + "223a34323539362c2235373039223a33303031352c2235373130223a32363837302c22" + + "35373131223a302c2235373036223a22663165306235222c2239303033223a36353534" + + "307d2c7b2235383530223a312c2235383531223a3230332c2235373037223a35343237" + + "2c2235373038223a34323539362c2235373039223a33303031352c2235373130223a32" + + "363837302c2235373131223a302c2235373036223a22663165306235222c2239303033" + + "223a36353534317d2c7b2235383530223a312c2235383531223a3230332c2235373037" + + "223a353432372c2235373038223a34323539362c2235373039223a33303031352c2235" + + "373130223a32363837302c2235373131223a302c2235373036223a2266316530623522" + + "2c2239303033223a36353534327d2c7b2235383530223a312c2235383531223a323033" + + "2c2235373037223a353432372c2235373038223a34323539362c2235373039223a3330" + + "3031352c2235373130223a32363837302c2235373131223a302c2235373036223a2266" + + "3165306235222c2239303033223a36353534337d2c7b2235383530223a312c22353835" + + "31223a3230332c2235373037223a353432372c2235373038223a34323539362c223537" + + "3039223a33303031352c2235373130223a32363837302c2235373131223a302c223537" + + "3036223a22663165306235222c2239303033223a36353534347d2c7b2235383530223a" + + "312c2235383531223a3230332c2235373037223a353432372c2235373038223a343235" + + "39362c2235373039223a33303031352c2235373130223a32363837302c223537313122" + + "3a302c2235373036223a22663165306235222c2239303033223a36353534357d2c7b22" + + "35383530223a312c2235383531223a3230332c2235373037223a353432372c22353730" + + "38223a34323539362c2235373039223a33303031352c2235373130223a32363837302c" + + "2235373131223a302c2235373036223a22663165306235222c2239303033223a363535" + + "34367d2c7b2235383530223a312c2235383531223a3230332c2235373037223a353432" + + "372c2235373038223a34323539362c2235373039223a3330303135", + "hex", + ); + + const settings = { + host: "gw-b072bf257a41", + securityCode: "", + identity: "tradfri_1509642359115", + psk: "gzqZY5HUlFOOVu9f", + }; + const requestBase = `coaps://${settings.host}:5684/`; + + it("should parse without crashing", () => { + const msg = Message.parse(buf); + console.log(`code: ${msg.code}`); + console.log(`messageId: ${msg.messageId}`); + if (msg.token != null) { + console.log(`token: ${msg.token.toString("hex")}`); + } + console.log(`type: ${msg.type}`); + console.log(`version: ${msg.version}`); + console.log("options:"); + for (const opt of msg.options) { + console.log(` [${opt.constructor.name}] ${opt.toString()}`); + } + console.log("payload:"); + console.log(msg.payload.toString("utf-8")); + }); + + it.only("custom tests", async () => { + coap.setSecurityParams(settings.host, { + psk: { [settings.identity]: settings.psk }, + }); + + // connect + expect(await coap.tryToConnect(requestBase)).to.be.true; + + // limit response size + coap.setDefaultRequestOptions({ + preferredBlockSize: 64, + }); + + const resp = await coap.request(`${requestBase}15011/15012`, "get"); + console.log("got complete payload:"); + console.log(resp.payload.toString("utf8")); + + coap.reset(); + }); +}); diff --git a/src/Message.ts b/src/Message.ts index 0d68df4f..9438bd6c 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -1,4 +1,4 @@ -import { Option } from "./Option"; +import { findOption, Option } from "./Option"; export enum MessageType { CON = 0, // Confirmable @@ -58,6 +58,7 @@ export const MessageCodes = Object.freeze({ valid: new MessageCode(2, 3), changed: new MessageCode(2, 4), content: new MessageCode(2, 5), + continue: new MessageCode(2, 31), }, clientError: { @@ -69,6 +70,7 @@ export const MessageCodes = Object.freeze({ notFound: new MessageCode(4, 4), methodNotAllowed: new MessageCode(4, 5), notAcceptable: new MessageCode(4, 6), + requestEntityIncomplete: new MessageCode(4, 8), preconditionFailed: new MessageCode(4, 12), requestEntityTooLarge: new MessageCode(4, 13), unsupportedContentFormat: new MessageCode(4, 15), @@ -200,6 +202,18 @@ export class Message { return ret; } + /** + * Checks if this message is part of a blockwise transfer + */ + public isPartialMessage(): boolean { + // start with the response option, since that's more likely + const block2option = findOption(this.options, "Block2"); + if (this.code.isResponse() && block2option != null) return true; + const block1option = findOption(this.options, "Block1"); + if (this.code.isRequest() && block1option != null) return true; + return false; + } + } /* diff --git a/src/Option.ts b/src/Option.ts index 501dd042..6c109ca8 100644 --- a/src/Option.ts +++ b/src/Option.ts @@ -9,6 +9,31 @@ function numberToBuffer(value: number): Buffer { return Buffer.from(ret); } +/** + * All defined option names + */ +export type OptionName = + "Observe" | + "Uri-Port" | + "Content-Format" | + "Max-Age" | + "Accept" | + "Block2" | + "Block1" | + "Size2" | + "Size1" | + "If-Match" | + "ETag" | + "If-None-Match" | + "Uri-Host" | + "Location-Path" | + "Uri-Path" | + "Uri-Query" | + "Location-Query" | + "Proxy-Uri" | + "Proxy-Scheme" +; + /** * Abstract base class for all message options. Provides methods to parse and serialize. */ @@ -16,7 +41,7 @@ export abstract class Option { constructor( public readonly code: number, - public readonly name: string, + public readonly name: OptionName, public rawValue: Buffer, ) { @@ -173,7 +198,7 @@ export class NumericOption extends Option { constructor( code: number, - public readonly name: string, + public readonly name: OptionName, public readonly repeatable: boolean, public readonly maxLength: number, rawValue: Buffer, @@ -198,7 +223,7 @@ export class NumericOption extends Option { public static create( code: number, - name: string, + name: OptionName, repeatable: boolean, maxLength: number, rawValue: Buffer, @@ -206,6 +231,90 @@ export class NumericOption extends Option { return new NumericOption(code, name, repeatable, maxLength, rawValue); } + public toString(): string { + return `${this.name} (${this.code}): ${this.value}`; + } + +} + +/** + * Specialized Message optionis for blockwise transfer + */ +export class BlockOption extends NumericOption { + + public static create( + code: number, + name: OptionName, + repeatable: boolean, + maxLength: number, + rawValue: Buffer, + ): BlockOption { + return new BlockOption(code, name, repeatable, maxLength, rawValue); + } + + /** + * The size exponent of this block in the range 0..6 + * The actual block size is calculated by 2**(4 + exp) + */ + public get sizeExponent(): number { + return this.value & 0b111; + } + public set sizeExponent(value: number) { + if (value < 0 || value > 6) { + throw new Error("the size exponent must be in the range of 0..6"); + } + // overwrite the last 3 bits + this.value = (this.value & ~0b111) | value; + } + /** + * The size of this block in bytes + */ + public get blockSize(): number { + return 1 << (this.sizeExponent + 4); + } + + /** + * Indicates if there are more blocks following after this one. + */ + public get isLastBlock(): boolean { + const moreBlocks = (this.value & 0b1000) === 0b1000; + return !moreBlocks; + } + public set isLastBlock(value: boolean) { + const moreBlocks = !value; + // overwrite the 4th bit + this.value = (this.value & ~0b1000) | (moreBlocks ? 0b1000 : 0); + } + + /** + * The sequence number of this block. + * When present in a request message, this determines the number of the block being requested + * When present in a response message, this indicates the number of the provided block + */ + public get blockNumber(): number { + return this.value >>> 4; + } + public set blockNumber(value: number) { + // TODO: check if we need to update the value length + this.value = (value << 4) | (this.value & 0b1111); + } + + /** + * Returns the position of the first byte of this block in the complete message + */ + public get byteOffset(): number { + // from the spec: + // Implementation note: As an implementation convenience, "(val & ~0xF) + // << (val & 7)", i.e., the option value with the last 4 bits masked + // out, shifted to the left by the value of SZX, gives the byte + // position of the first byte of the block being transferred. + return (this.value & ~0b1111) << (this.value & 0b111); + } + + public toString(): string { + return `${this.name} (${this.code}): ${this.blockNumber}/${this.isLastBlock ? 0 : 1}/${this.blockSize}`; + } + } /** @@ -215,7 +324,7 @@ export class BinaryOption extends Option { constructor( code: number, - public readonly name: string, + public readonly name: OptionName, public readonly repeatable: boolean, public readonly minLength: number, public readonly maxLength: number, @@ -240,7 +349,7 @@ export class BinaryOption extends Option { public static create( code: number, - name: string, + name: OptionName, repeatable: boolean, minLength: number, maxLength: number, @@ -249,6 +358,10 @@ export class BinaryOption extends Option { return new BinaryOption(code, name, repeatable, minLength, maxLength, rawValue); } + public toString(): string { + return `${this.name} (${this.code}): 0x${this.rawValue.toString("hex")}`; + } + } /** @@ -258,7 +371,7 @@ export class StringOption extends Option { constructor( code: number, - public readonly name: string, + public readonly name: OptionName, public readonly repeatable: boolean, public readonly minLength: number, public readonly maxLength: number, @@ -283,7 +396,7 @@ export class StringOption extends Option { public static create( code: number, - name: string, + name: OptionName, repeatable: boolean, minLength: number, maxLength: number, @@ -292,6 +405,10 @@ export class StringOption extends Option { return new StringOption(code, name, repeatable, minLength, maxLength, rawValue); } + public toString(): string { + return `${this.name} (${this.code}): "${this.value}"`; + } + } /** @@ -301,7 +418,7 @@ const optionConstructors: {[code: string]: (raw: Buffer) => Option} = {}; function defineOptionConstructor( // tslint:disable-next-line:ban-types constructor: Function, - code: number, name: string, repeatable: boolean, + code: number, name: OptionName, repeatable: boolean, ...args: any[], ): void { optionConstructors[code] = optionConstructors[name] = @@ -312,6 +429,9 @@ defineOptionConstructor(NumericOption, 7, "Uri-Port", false, 2); defineOptionConstructor(NumericOption, 12, "Content-Format", false, 2); defineOptionConstructor(NumericOption, 14, "Max-Age", false, 4); defineOptionConstructor(NumericOption, 17, "Accept", false, 2); +defineOptionConstructor(BlockOption, 23, "Block2", false, 3); +defineOptionConstructor(BlockOption, 27, "Block1", false, 3); +defineOptionConstructor(NumericOption, 28, "Size2", false, 4); defineOptionConstructor(NumericOption, 60, "Size1", false, 4); defineOptionConstructor(BinaryOption, 1, "If-Match", true, 0, 8); defineOptionConstructor(BinaryOption, 4, "ETag", true, 1, 8); @@ -324,6 +444,7 @@ defineOptionConstructor(StringOption, 20, "Location-Query", true, 0, 255); defineOptionConstructor(StringOption, 35, "Proxy-Uri", true, 1, 1034); defineOptionConstructor(StringOption, 39, "Proxy-Scheme", true, 1, 255); +// tslint:disable:no-string-literal // tslint:disable-next-line:variable-name export const Options = Object.freeze({ UriHost: (hostname: string) => optionConstructors["Uri-Host"](Buffer.from(hostname)), @@ -333,6 +454,37 @@ export const Options = Object.freeze({ LocationPath: (pathname: string) => optionConstructors["Location-Path"](Buffer.from(pathname)), ContentFormat: (format: ContentFormats) => optionConstructors["Content-Format"](numberToBuffer(format)), - // tslint:disable-next-line:no-string-literal Observe: (observe: boolean) => optionConstructors["Observe"](Buffer.from([observe ? 0 : 1])), + + Block1: (num: number, isLast: boolean, size: number) => { + // Warning: we're not checking for a valid size here, do that in advance! + const sizeExp = Math.log2(size) - 4; + const value = (num << 4) | (isLast ? 0 : 0b1000) | (sizeExp & 0b111); + return optionConstructors["Block1"](numberToBuffer(value)); + }, + Block2: (num: number, isLast: boolean, size: number) => { + // Warning: we're not checking for a valid size here, do that in advance! + const sizeExp = Math.log2(size) - 4; + const value = (num << 4) | (isLast ? 0 : 0b1000) | (sizeExp & 0b111); + return optionConstructors["Block2"](numberToBuffer(value)); + }, }); +// tslint:enable:no-string-literal + +/** + * Searches for a single option in an array of options + * @param opts The options array to search for the option + * @param name The name of the option to search for + */ +export function findOption(opts: Option[], name: OptionName): Option { + return opts.find(o => o.name === name); +} + +/** + * Searches for a repeatable option in an array of options + * @param opts The options array to search for the option + * @param name The name of the option to search for + */ +export function findOptions(opts: Option[], name: OptionName): Option[] { + return opts.filter(o => o.name === name); +} diff --git a/src/lib/LogMessage.ts b/src/lib/LogMessage.ts new file mode 100644 index 00000000..8aaa156b --- /dev/null +++ b/src/lib/LogMessage.ts @@ -0,0 +1,25 @@ +// initialize debugging +import * as debugPackage from "debug"; +const debug = debugPackage("node-coap-client:message"); + +import { Message } from "../Message"; + +export function logMessage(msg: Message, includePayload: boolean = false): void { + debug("============================="); + debug(`received message`); + debug(`messageId: ${msg.messageId}`); + if (msg.token != null) { + debug(`token: ${msg.token.toString("hex")}`); + } + debug(`code: ${msg.code}`); + debug(`type: ${msg.type}`); + debug(`version: ${msg.version}`); + debug("options:"); + for (const opt of msg.options) { + debug(` [${opt.constructor.name}] ${opt.toString()}`); + } + debug("payload:"); + debug(msg.payload.toString("utf-8")); + debug("============================="); + debug(""); +}