diff --git a/package.json b/package.json index e83305e2d66861534bdda1d9e135648215c96cbd..c1201a56115e8c8d75fab9d0a6791810686784c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "usp-js", - "version": "0.4.4", + "version": "0.4.5", "description": "Library for easy usp(TR-369) communication using mqtt or ws.", "main": "node/index.js", "browser": "web/index.js", diff --git a/src/commands/common/add.ts b/src/commands/common/add.ts index 96a18df49998e6b23c8eacbd169cddc91c10a58d..9b410a7cfb59d5e03a9730e4ef4d2b51f01e88d7 100644 --- a/src/commands/common/add.ts +++ b/src/commands/common/add.ts @@ -1,6 +1,10 @@ import { DecodeFn, EncodeFn } from "../../types"; import * as util from "../util"; -import { AddLookupCreateObject, AddLookupCreateParamSetting } from "../../types"; +import { + AddLookupCreateObject, + AddLookupCreateParamSetting, +} from "../../types"; +import { parseSetArgs } from "../../util"; const decode: DecodeFn = (msg) => { const paths: string[] | undefined = util.searchAll(msg, "instantiatedPath"); @@ -21,9 +25,9 @@ const makePair = (value): [string, any, boolean][] => : []; const encode: EncodeFn = ({ value, path }) => { - const paths = Array.isArray(path) ? path : [path]; - const values = value ? (Array.isArray(value) ? value : [value]) : []; - const allowPartial = values && values.some((it) => it.allowPartial) || false; + const [paths, values] = parseSetArgs(value, path); + const allowPartial = + (values && values.some((it) => it.allowPartial)) || false; const createObjs = paths.map((path, i) => ({ lookup: "Add.CreateObject" as AddLookupCreateObject, diff --git a/src/commands/common/set.ts b/src/commands/common/set.ts index 31fede982d86b210395569ee840f6c7f53d3604f..dd63b3e117ec5b40f8da02529852b2e7f97963c2 100644 --- a/src/commands/common/set.ts +++ b/src/commands/common/set.ts @@ -6,6 +6,7 @@ import { SetLookupUpdateParamSetting, } from "../../types"; import * as util from "../util"; +import { parseSetArgs } from "../../util"; const decode: DecodeFn = (msg, decodeOptions) => { if (decodeOptions?.raw) return [msg]; @@ -25,8 +26,7 @@ const makePairs = (path: string, value): [string, any, boolean][] => : [[path.split(".").pop() || "", (value || "").toString(), true]]; const encode: EncodeFn = ({ value, path: initialPath }) => { - const paths = Array.isArray(initialPath) ? initialPath : [initialPath]; - const values = value ? (Array.isArray(value) ? value : [value]) : []; + const [paths, values] = parseSetArgs(value, initialPath); const allowPartial = (values && values.some((it) => it.allowPartial)) || false; diff --git a/src/configurations/build.ts b/src/configurations/build.ts index 39f864d8fb1770c32d55c643c207359f3b654a03..5098a5fca4cb139f899bc7fc42d1bf255bc3a571 100644 --- a/src/configurations/build.ts +++ b/src/configurations/build.ts @@ -28,6 +28,8 @@ import { makeRouter, parseCallbackOptions, } from "../util"; +import makeVerify from "../verify"; +import defaultErrors from "../errors"; const defaultPublishEndpoint = "/usp/endpoint"; const defaultSubscribeEndpoint = "/usp/controller"; @@ -175,7 +177,7 @@ const initializeSession = ({ }; const buildConnect: BuildConnectionFn = - ({ connectClient, decodeID, loadProtobuf }) => + ({ connectClient, decodeID, loadProtobuf, errors = defaultErrors }) => async (options, events) => { const subscribeEndpoint = options.subscribeEndpoint || defaultSubscribeEndpoint; @@ -310,8 +312,15 @@ const buildConnect: BuildConnectionFn = }; }; + const verify = makeVerify(errors); const call: CallFn = (command, args, callOpts) => new Promise((resolve, reject) => { + const error = verify(command, args); + if (error) { + reject(error); + return; + } + // todo make looky nice sessionOptions.sequenceId++; sessionOptions.expectedId = sessionOptions.sequenceId; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..70ee712783d9a2824291dfc78fc30a8d4019cc95 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,41 @@ +import { VerifyResult } from "./types"; + +export const defaultMissingErrorMessage = "Unknown error occurred"; + +export default { + [VerifyResult.NoError]: "No error", + + // GET + [VerifyResult.PathsMissing]: "'paths' argument is missing", + [VerifyResult.PathsNotStringOrStringArray]: + "'paths' argument has to be a string or a string array", + [VerifyResult.RawOptionNotBool]: "'raw' option has to be a boolean", + [VerifyResult.RetainPathOptionNotBool]: + "'retainPath' option has to be a boolean", + [VerifyResult.MaxDepthOptionNotNumber]: + "'max_depth' option has to be a positive number", + [VerifyResult.MaxDepthOptionIsNegative]: + "'max_depth' option has to be a positive number", + + // SET / ADD + [VerifyResult.ValueIsMissing]: "'value' argument is required", + [VerifyResult.ValueMustBeArray]: + "'value' argument must be an array, when 'path' is an array", + [VerifyResult.ValueAndPathArraysMustBeSameLength]: + "'path' and 'value' arrays must have the same length", + [VerifyResult.ValueIsNotTupleArray]: + "'path' argument must be an array of tuples", + + // DELETE + [VerifyResult.AllowPartialOptionNotBool]: + "'allowPartial' option has to be a boolean", + + // SUPPORTED_DM + [VerifyResult.OptionsMustBeBool]: "all options have to be boolean", + + // INSTANCES + [VerifyResult.FirstLevelOnlyNotBool]: "'firstLevelOnly' has to be a boolean", + + // PROTO + [VerifyResult.ProtoNotString]: "'proto' argument has to be a string", +} as Record<VerifyResult, string>; diff --git a/src/types.ts b/src/types.ts index fb14a307d4841ddcd642631a44c5ac975dbc6054..321d32d15c3d0fc0c6eab6e8f1553bb4fe5d7f21 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,6 +130,38 @@ export type SetRawResponse = { }; }; +export type VerifierFunc = (args: Record<string, any>) => VerifyResult; + +export enum VerifyResult { + NoError = "VerifyResult.NoError", + + // GET + PathsMissing = "VerifyResult.PathsMissing", + PathsNotStringOrStringArray = "VerifyResult.PathsNotStringOrStringArray", + RawOptionNotBool = "VerifyResult.RawOptionNotBool", + RetainPathOptionNotBool = "VerifyResult.RetainPathOptionNotBool", + MaxDepthOptionNotNumber = "VerifyResult.MaxDepthOptionNotNumber", + MaxDepthOptionIsNegative = "VerifyResult.MaxDepthOptionIsNegative", + + // SET / ADD + ValueIsMissing = "VerifyResult.ValueIsMissing", + ValueMustBeArray = "VerifyResult.ValueMustBeArray", + ValueAndPathArraysMustBeSameLength = "VerifyResult.ValueAndPathArraysMustBeSameLength", + ValueIsNotTupleArray = "VerifyResult.ValueIsNotTupleArray", + + // DELETE + AllowPartialOptionNotBool = "VerifyResult.AllowPartialOptionNotBool", + + // SUPPORTED_DM + OptionsMustBeBool = "VerifyResult.OptionsMustBeBool", + + // INSTANCES + FirstLevelOnlyNotBool = "VerifyResult.FirstLevelOnlyNotBool", + + // PROTO + ProtoNotString = "VerifyResult.ProtoNotString", +} + export type GetNestedReturn = | UspProperty | UspPropertyList @@ -162,7 +194,11 @@ export type SetCommand = (( | (JSValue | JSValue[] | InputRecord) | (JSValue | JSValue[] | InputRecord)[], options: { raw: true } & SetCommandOptions - ) => Promise<SetRawResponse>); + ) => Promise<SetRawResponse>) & + (( + values: [string, JSValue | JSValue[] | InputRecord][], + options?: SetCommandOptions + ) => Promise<SetResponse>); export type AddCommand = (( path: string | string[], value?: @@ -180,7 +216,11 @@ export type AddCommand = (( | null | undefined, options: { raw: true } - ) => Promise<AddRawResponse>); + ) => Promise<AddRawResponse>) & + (( + values: [string, JSValue | JSValue[] | InputRecord][], + options?: { raw?: boolean } + ) => Promise<AddResponse>); export type DelCommand = ( path: string | string[], allowPartial?: boolean @@ -452,6 +492,11 @@ export interface USP { ["Device.NAT.PortMapping.1.", "Device.NAT.PortMapping.2."], [{ Description: "cat-1" }, { Description: "cat-2" }] ) + * // or + * await device.set([ + ["Device.NAT.PortMapping.1.Description", "desc 1"], + ["Device.NAT.PortMapping.2.Description", "desc 2"], + ]); * ``` */ set: SetCommand; @@ -502,6 +547,10 @@ export interface USP { ["Device.NAT.PortMapping.", "Device.NAT.PortMapping."], [{ Description: "cpe-1" }, { Description: "cpe-2" }] ); + * await device.add([ + ["Device.NAT.PortMapping.", { Description: "bbbb" }], + ["Device.NAT.PortMapping.", { Description: "bbbb" }], + ]); * ``` */ add: AddCommand; @@ -688,7 +737,7 @@ export type HostConnectionOptions = { export type CertType = string | string[] | Buffer | Buffer[]; -export type USPVersion = typeof knownUSPVersions[number]; +export type USPVersion = (typeof knownUSPVersions)[number]; export interface MainConnectionOptions { username: string; @@ -831,6 +880,7 @@ export type BuildConnectionFn = (connectConfig: { connectClient: ConnectClientFn; decodeID: DecodeIDFn; loadProtobuf: LoadProtobufFn; + errors?: Record<VerifyResult, string>; }) => Connect; export type USPSession = { diff --git a/src/util.ts b/src/util.ts index 318d87e8ad5599ccfd9899fb03548e6a41b5e665..a0e258402b0f9fd68ccb868388a6b1ef551a4b8d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,10 @@ import { search } from "./commands/util"; -import { CallbackOptions, CommandType, OnCallback, OnIdent, SubscriptionCallback } from "./types"; +import { + CallbackOptions, + CommandType, + OnCallback, + OnIdent, +} from "./types"; /** * Makes a router for storing resolve/reject for a message @@ -23,10 +28,7 @@ const isRegExp = (v: OnIdent): v is RegExp => typeof v === "object"; const satisfies = (id: OnIdent, matches: string): boolean => isRegExp(id) ? matches.match(id) !== null : id === matches; export const makeCallbackRouter = () => { - const routes = new Map< - string, - { callback: OnCallback; ident: OnIdent } - >(); + const routes = new Map<string, { callback: OnCallback; ident: OnIdent }>(); return { get: (id: string): OnCallback[] => { return Array.from(routes.values()) @@ -40,10 +42,26 @@ export const makeCallbackRouter = () => { }; }; -export const parseCallbackOptions = (msg: Record<string, any>): CallbackOptions | null => { - const sendResp = search(msg, "sendResp") - if (typeof sendResp === "boolean") return { sendResp } - return null -} +export const parseCallbackOptions = ( + msg: Record<string, any> +): CallbackOptions | null => { + const sendResp = search(msg, "sendResp"); + if (typeof sendResp === "boolean") return { sendResp }; + return null; +}; export const knownUSPVersions = ["1.0", "1.1", "1.2"] as const; + +export const parseSetArgs = (value, initialPath) => { + if ( + Array.isArray(initialPath) && + initialPath.every((v) => Array.isArray(v) && v.length === 2) && + value === undefined + ) { + return [initialPath.map((v) => v[0]), initialPath.map((v) => v[1])]; + } + + const paths = Array.isArray(initialPath) ? initialPath : [initialPath]; + const values = value ? (Array.isArray(value) ? value : [value]) : []; + return [paths, values]; +}; diff --git a/src/verify.ts b/src/verify.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3363d6bedf340131fbf8099fb80bf820c51d0c2 --- /dev/null +++ b/src/verify.ts @@ -0,0 +1,148 @@ +import { defaultMissingErrorMessage } from "./errors"; +import { VerifyResult, CommandType, VerifierFunc } from "./types"; + +const isPresent = (item) => item !== undefined; +const isBool = (item) => typeof item === "boolean"; +const isNumber = (item) => typeof item === "number"; +const isString = (item) => typeof item === "string"; +const isArray = (item) => Array.isArray(item); +const isTupleArray = (item) => + Array.isArray(item) && + item.length > 0 && + item.every((it) => isArray(it) && it.length === 2); + +const checkGetOptions: VerifierFunc = ({ options }) => { + if (!isPresent(options)) return VerifyResult.NoError; + if (isPresent(options.raw) && !isBool(options.raw)) + return VerifyResult.RawOptionNotBool; + if (isPresent(options.retainPath) && !isBool(options.retainPath)) + return VerifyResult.RetainPathOptionNotBool; + if (isPresent(options.max_depth)) { + if (!isNumber(options.max_depth)) + return VerifyResult.MaxDepthOptionNotNumber; + if (options.max_depth < 0) return VerifyResult.MaxDepthOptionIsNegative; + } + + return VerifyResult.NoError; +}; + +const checkSetValue: VerifierFunc = ({ path, value }) => { + if (isTupleArray(path)) return VerifyResult.NoError + if (isArray(path)) { + if (!isPresent(value) && !isTupleArray(path)) + return VerifyResult.ValueIsNotTupleArray; + if (!isArray(value)) return VerifyResult.ValueMustBeArray; + if (path.length !== value.length) + return VerifyResult.ValueAndPathArraysMustBeSameLength; + } + return value !== undefined && value !== null + ? VerifyResult.NoError + : VerifyResult.ValueIsMissing; +}; + +const checkAddValue: VerifierFunc = ({ path, value }) => { + if (isTupleArray(path)) return VerifyResult.NoError + if (isArray(path)) { + if (!isPresent(value)) { + if (!isTupleArray(path)) return VerifyResult.ValueIsNotTupleArray; + else return VerifyResult.NoError; + } + if (!isArray(value)) return VerifyResult.ValueMustBeArray; + if (path.length !== value.length) + return VerifyResult.ValueAndPathArraysMustBeSameLength; + } + return VerifyResult.NoError; +}; + +const checkSetOptions: VerifierFunc = ({ options }) => { + if (!isPresent(options)) return VerifyResult.NoError; + if (isPresent(options.raw) && !isBool(options.raw)) + return VerifyResult.RawOptionNotBool; + + return VerifyResult.NoError; +}; + +const checkDelOptions: VerifierFunc = ({ allowPartial }) => { + if (!isPresent(allowPartial)) return VerifyResult.NoError; + if (!isBool(allowPartial)) return VerifyResult.AllowPartialOptionNotBool; + + return VerifyResult.NoError; +}; + +const checkSupportedDMOptions: VerifierFunc = ({ opts }) => { + if (!isPresent(opts)) return VerifyResult.NoError; + const allOptsAreBool = Object.values(opts).every(isBool); + return allOptsAreBool ? VerifyResult.NoError : VerifyResult.OptionsMustBeBool; +}; + +const checkInstancesOptions: VerifierFunc = ({ opts }) => { + if (!isPresent(opts)) return VerifyResult.NoError; + if (isPresent(opts.firstLevelOnly) && !isBool(opts.firstLevelOnly)) + return VerifyResult.FirstLevelOnlyNotBool; + return VerifyResult.NoError; +}; + +const checkProto: VerifierFunc = ({ proto }) => { + if (isPresent(proto) && !isString(proto)) return VerifyResult.ProtoNotString; + return VerifyResult.NoError; +}; + +const checkSetPaths: VerifierFunc = ({ path, value }) => { + if (!isPresent(path)) return VerifyResult.PathsMissing + if (!isPresent(value) && isArray(path) && !isTupleArray(path)) + return VerifyResult.ValueIsNotTupleArray; + return isString(path) || + (isArray(path) && + path.length > 0 && + path.every((path) => typeof path === "string" || isArray(path))) + ? VerifyResult.NoError + : VerifyResult.PathsNotStringOrStringArray; +}; + +const checkPaths: VerifierFunc = ({ + paths: initalPaths, + path: initialPath, +}) => { + const paths = initalPaths || initialPath; + return !isPresent(paths) + ? VerifyResult.PathsMissing + : isString(paths) || + (isArray(paths) && + paths.length > 0 && + paths.every((path) => typeof path === "string")) + ? VerifyResult.NoError + : VerifyResult.PathsNotStringOrStringArray; +}; + +const argsVerifiers: Partial<Record<CommandType, VerifierFunc[]>> = { + GET: [checkPaths, checkGetOptions], + SET: [checkSetPaths, checkSetValue, checkSetOptions], + ADD: [checkSetPaths, checkAddValue, checkSetOptions], + DELETE: [checkPaths, checkDelOptions], + OPERATE: [checkPaths], + GET_SUPPORTED_DM: [checkPaths, checkSupportedDMOptions], + GET_INSTANCES: [checkPaths, checkInstancesOptions], + GET_SUPPORTED_PROTO: [checkProto], +}; + +const translateError = ( + errors: Record<VerifyResult, string>, + verifyResult: VerifyResult +): Error => new Error(errors?.[verifyResult] || defaultMissingErrorMessage); + +export const makeVerify = + (errors: Record<VerifyResult, string>) => + (command: CommandType, args: Record<string, any>): Error | null => { + const verifiers = argsVerifiers[command]; + if (!verifiers) return null; + + const results = verifiers.map((verifier) => verifier(args)); + const finalResult = + results.find((value) => value !== VerifyResult.NoError) || + VerifyResult.NoError; + return finalResult === VerifyResult.NoError + ? null + : translateError(errors, finalResult); + }; + +export default makeVerify; diff --git a/tests/integration/config.json b/tests/integration/config.json index 43bab89ca497fccdaef5e199d0af94a6f5849a90..163c186303ac1cbf9c5af9fbd453a4c0d2c7dc4f 100644 --- a/tests/integration/config.json +++ b/tests/integration/config.json @@ -1,6 +1,6 @@ { "ws": { - "host": "localhost", + "host": "192.168.2.1", "username": "admin", "password": "admin", "port": 9001, diff --git a/tests/integration/errors.test.ts b/tests/integration/errors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c70ce1a21f3956dc49f1b8705a38384559776f6 --- /dev/null +++ b/tests/integration/errors.test.ts @@ -0,0 +1,157 @@ +import assert from "assert"; +import connect from "../../src"; +import { ConnectionOptions } from "../../src/types"; +import config from "./config.json"; + +const expectError = async (func, errorMessage) => { + try { + await func(); + } catch (error) { + assert.strictEqual(typeof error, "object"); + assert.strictEqual(error.message, errorMessage); + } +}; + +describe("Test errors", function () { + this.timeout(10000); + + let device: any = null; + + before(async () => { + device = await connect(config.ws as ConnectionOptions); + }); + + after(async () => { + await device.disconnect(); + }); + + it("no error", async () => { + const resp = await device.get([ + "Device.Time.CurrentLocalTime", + "Device.Time.LocalTimeZone", + ]); + assert.strictEqual(Array.isArray(resp), true); + }); + + it("get errors", async () => { + await expectError(() => device.get(), "'paths' argument is missing"); + + await expectError( + () => device.get(3), + "'paths' argument has to be a string or a string array" + ); + await expectError( + () => device.get([]), + "'paths' argument has to be a string or a string array" + ); + await expectError( + () => device.get([1, 2, 3]), + "'paths' argument has to be a string or a string array" + ); + + await expectError( + () => device.get("Device.", { raw: 13 }), + "'raw' option has to be a boolean" + ); + + await expectError( + () => device.get("Device.", { retainPath: 13 }), + "'retainPath' option has to be a boolean" + ); + + await expectError( + () => device.get("Device.", { max_depth: false }), + "'max_depth' option has to be a positive number" + ); + await expectError( + () => device.get("Device.", { max_depth: -3 }), + "'max_depth' option has to be a positive number" + ); + }); + + it("set/add errors", async () => { + await expectError( + () => device.set("Device.NAT.PortMapping.1.Description"), + "'value' argument is required" + ); + + await expectError( + () => device.set(["Device.NAT.PortMapping.1.Description"], "Desc"), + "'value' argument must be an array, when 'path' is an array" + ); + await expectError( + () => device.add(["Device.NAT.PortMapping."], { Description: "Desc" }), + "'value' argument must be an array, when 'path' is an array" + ); + + await expectError( + () => + device.set( + ["Device.NAT.PortMapping.1.Description"], + ["Desc 1", "Desc 2"] + ), + "'path' and 'value' arrays must have the same length" + ); + await expectError( + () => + device.add( + ["Device.NAT.PortMapping."], + [{ Description: "Desc 1" }, { Description: "Desc 2" }] + ), + "'path' and 'value' arrays must have the same length" + ); + + await expectError( + () => + device.set([ + ["Device.NAT.PortMapping.1.Description", "desc 1", "desc 2"], + ]), + "'path' argument must be an array of tuples" + ); + await expectError( + () => + device.add([ + [ + "Device.NAT.PortMapping.", + { Description: "Desc 1" }, + { Description: "Desc 2" }, + ], + ]), + "'path' argument must be an array of tuples" + ); + }); + + it("delete errors", async () => { + await expectError( + () => device.del("Device.NAT.PortMapping.1.", 13), + "'allowPartial' option has to be a boolean" + ); + }); + + it("supportedDM errors", async () => { + await expectError( + () => + device.supportedDM("Device.NAT.PortMapping.1.", { + firstLevelOnly: true, + returnCommands: true, + returnEvents: true, + returnParams: 13, + }), + "all options have to be boolean" + ); + }); + + it("instances errors", async () => { + await expectError( + () => device.instances("Device.NAT.PortMapping.", 13), + "'firstLevelOnly' has to be a boolean" + ); + }); + + it("proto errors", async () => { + await expectError( + () => device.supportedProto(13), + "'proto' argument has to be a string" + ); + }); +}); diff --git a/tests/integration/index.test.ts b/tests/integration/index.test.ts index ff872749c390d8b0cf91333cc5c72db66e361a21..36b2cfc7c88d64fff92e1dcdf62639eefe9a3f06 100644 --- a/tests/integration/index.test.ts +++ b/tests/integration/index.test.ts @@ -97,6 +97,23 @@ describe("Test general API", function () { await device.set("Device.WiFi.Radio.1.Alias", alias); }); + it("set multiple with value", async () => { + await device.set( + [ + "Device.NAT.PortMapping.1.Description", + "Device.NAT.PortMapping.2.Description", + ], + ["desc 1", "desc 2"] + ); + }); + + it("set multiple with value (alternate usage)", async () => { + await device.set([ + ["Device.NAT.PortMapping.1.Description", "desc 1"], + ["Device.NAT.PortMapping.2.Description", "desc 2"], + ]); + }); + it("set with object", async () => { const Alias = await device.get("Device.WiFi.Radio.1.Alias"); await device.set("Device.WiFi.Radio.1.", { Alias });