diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 001cf30..63e82a1 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Install prettier - run: npm install prettier@2.3.2 + run: npm install prettier@2.8.8 - name: Run prettier run: npm run format:check diff --git a/node/code-gen/src/cli.ts b/node/code-gen/src/cli.ts index 18378f2..af6c250 100644 --- a/node/code-gen/src/cli.ts +++ b/node/code-gen/src/cli.ts @@ -11,7 +11,7 @@ import { LANGUAGES_SUPPORT, } from "./types.js"; import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; -import { basename, isAbsolute, join, resolve } from "node:path"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; import { homedir } from "node:os"; import { generateCode } from "./index.js"; import { @@ -288,8 +288,31 @@ function expandHome(p: string): string { return p; } +/** + * Returns the parent of a directory path (which is expected to end with a separator), + * collapsing the last path segment rather than appending "..". + */ +function getParentDirectory(directory: string, sep: string): string { + const rooted = directory.startsWith("~") || isAbsolute(directory) || /^[\\/]/.test(directory); + const absoluteParent = resolve(expandHome(directory || "."), ".."); + + // Home-relative or absolute inputs resolve to a concrete absolute parent path. + if (rooted) { + const normalized = absoluteParent.split(/[\\/]/).join(sep); + return normalized.endsWith(sep) ? normalized : normalized + sep; + } + + // Relative inputs keep a relative display. + const rel = relative(process.cwd(), absoluteParent); + if (rel === "") return `.${sep}`; + return rel.split(/[\\/]/).join(sep) + sep; +} + function getPathChoices(line: string, mode: PathMode, base: string): { name: string; value: string }[] { - const effective = line.length > 0 ? line : base; + // Typed input is resolved relative to the currently drilled-into base directory, + // unless the user types an absolute or home-relative (~) path, which overrides the base. + const typedIsRooted = line.startsWith("~") || isAbsolute(line) || /^[\\/]/.test(line); + const effective = line.length > 0 ? (typedIsRooted ? line : base + line) : base; const sep = effective.includes("/") || effective.startsWith("~") ? "/" : "\\"; const hasTrailingSep = /[\\/]$/.test(effective); const lineBaseName = hasTrailingSep ? "" : basename(effective); @@ -298,10 +321,19 @@ function getPathChoices(line: string, mode: PathMode, base: string): { name: str const typedChoice = { name: - line.length > 0 ? `Use typed path: ${line}` : base ? `Use directory: ${base}` : "Use current directory: ./", - value: line.length > 0 ? line : base || "./", + line.length > 0 + ? `Use typed path: ${effective}` + : base + ? `Use directory: ${base}` + : "Use current directory: ./", + value: line.length > 0 ? effective : base || "./", }; + // Step-back entry that navigates to the parent directory. + const parentDir = getParentDirectory(lineDirectory, sep); + const parentChoice = { name: parentDir, value: parentDir }; + const showParentChoice = "..".startsWith(lineBaseName); + try { const entries = readdirSync(searchDirectory, { withFileTypes: true }) .filter((entry) => { @@ -317,9 +349,9 @@ function getPathChoices(line: string, mode: PathMode, base: string): { name: str return { name: suggestedPath, value: suggestedPath }; }); - return [typedChoice, ...entries]; + return [typedChoice, ...(showParentChoice ? [parentChoice] : []), ...entries]; } catch { - return [typedChoice]; + return [typedChoice, ...(showParentChoice ? [parentChoice] : [])]; } } @@ -335,7 +367,7 @@ async function getAffordanceFromUser(affordances: Affordances) { ...AFFORDANCE_TYPES.flatMap((affordanceType) => { const separatorTitle = capitalizeFirstLetter(affordanceType) + ":"; - const affordanceKeys = Object.keys(affordances[affordanceType as keyof Affordances]); + const affordanceKeys = Object.keys(affordances[affordanceType]); return affordanceKeys.length > 0 ? [ diff --git a/node/code-gen/src/generators/csharp.ts b/node/code-gen/src/generators/csharp.ts index 4cf83e5..511211d 100644 --- a/node/code-gen/src/generators/csharp.ts +++ b/node/code-gen/src/generators/csharp.ts @@ -1,12 +1,13 @@ import { Op } from "../types.js"; -import { CodeGenerator, getHttpMethod, operationHasPayload } from "./helpers.js"; +import { CodeGenerator, getHttpMethod, operationHasPayload, resolveHref } from "./helpers.js"; // --------------------------------------------------------------------------- // System.Net.Http.HttpClient – C# built-in HTTP client // --------------------------------------------------------------------------- export const generateCSharpHttpClientCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; + const href = resolveHref(form.href, td.base); const method = getHttpMethod(operation, form); const hasPayload = operationHasPayload(operation); @@ -40,7 +41,7 @@ class Program { using var client = new HttpClient(); - var url = "${form.href}"; + var url = "${href}"; var request = new HttpRequestMessage(${methodExpr}, url); ${payloadDecl} diff --git a/node/code-gen/src/generators/dart.ts b/node/code-gen/src/generators/dart.ts index e1d306b..fad1a4f 100644 --- a/node/code-gen/src/generators/dart.ts +++ b/node/code-gen/src/generators/dart.ts @@ -1,5 +1,5 @@ import { Op } from "../types.js"; -import { CodeGenerator, getHttpMethod, operationHasPayload } from "./helpers.js"; +import { CodeGenerator, getHttpMethod, operationHasPayload, resolveHref } from "./helpers.js"; // --------------------------------------------------------------------------- // dart_wot – Dart WoT client library @@ -86,7 +86,8 @@ Future main() async { // --------------------------------------------------------------------------- export const generateDartHttpCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; + const href = resolveHref(form.href, td.base); const method = getHttpMethod(operation, form); const hasPayload = operationHasPayload(operation); @@ -108,7 +109,7 @@ import "package:http/http.dart" as http; // Operation: ${operation} on "${affordanceKey}" Future main() async { - final url = Uri.parse("${form.href}"); + final url = Uri.parse("${href}"); ${payloadBlock} ${methodCall} diff --git a/node/code-gen/src/generators/go.ts b/node/code-gen/src/generators/go.ts index 7262456..451d77a 100644 --- a/node/code-gen/src/generators/go.ts +++ b/node/code-gen/src/generators/go.ts @@ -1,11 +1,12 @@ -import { CodeGenerator, getHttpMethod, operationHasPayload } from "./helpers.js"; +import { CodeGenerator, getHttpMethod, operationHasPayload, resolveHref } from "./helpers.js"; // --------------------------------------------------------------------------- // net/http – Go standard library HTTP client // --------------------------------------------------------------------------- export const generateGoNetHttpCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; + const href = resolveHref(form.href, td.base); const method = getHttpMethod(operation, form); const hasPayload = operationHasPayload(operation); @@ -43,7 +44,7 @@ import ( ) func main() { -\turl := "${form.href}" +\turl := "${href}" ${payloadBlock} \tclient := &http.Client{Timeout: 10 * time.Second} diff --git a/node/code-gen/src/generators/helpers.ts b/node/code-gen/src/generators/helpers.ts index 4ee8678..580a325 100644 --- a/node/code-gen/src/generators/helpers.ts +++ b/node/code-gen/src/generators/helpers.ts @@ -37,18 +37,47 @@ export function getProtocolFromHref(href: string): string { return scheme[1].split(".")[0].split("+")[0].toLowerCase(); } +/** + * Resolves a (possibly relative) form href against the TD's `base` URI. + * - Absolute hrefs (those carrying a URI scheme) are returned unchanged. + * - Relative hrefs are resolved against `base` when it is provided. + * - When no base is available, the relative href is returned unchanged. + */ +export function resolveHref(href: string, base?: string): string { + // Already absolute: it carries a URI scheme, so there is nothing to resolve. + if (getProtocolFromHref(href)) { + return href; + } + if (!base) { + return href; + } + try { + return new URL(href, base).href; + } catch { + // Fallback for exotic schemes the WHATWG URL parser may reject. + return `${base.replace(/\/+$/, "")}/${href.replace(/^\/+/, "")}`; + } +} + /** * Determines the protocol used by a form. * Prefers the URI scheme from the href; if the href is relative (no scheme), - * falls back to vendor-specific vocabulary prefixes on the form's keys + * falls back to the scheme of the TD `base` (when provided) and finally to + * vendor-specific vocabulary prefixes on the form's keys * (e.g. "modbus:address" → "modbus"). */ -export function getProtocolFromForm(form: Form): string { +export function getProtocolFromForm(form: Form, base?: string): string { const scheme = getProtocolFromHref(form.href); if (scheme) { return scheme; } + // Relative href: infer the protocol from the TD base's scheme. + const baseScheme = base ? getProtocolFromHref(base) : ""; + if (baseScheme) { + return baseScheme; + } + let httpFallback = ""; for (const key of Object.keys(form)) { const prefix = key.split(":")[0]; @@ -137,7 +166,8 @@ export function getAvailableOperations(affordance: Affordance | undefined, affor export function getAvailableProtocols( affordance: Affordance | undefined, affordanceType: AffordanceType, - operation?: Op + operation?: Op, + base?: string ): string[] { if (!affordance?.forms?.length) { return []; @@ -145,7 +175,7 @@ export function getAvailableProtocols( const forms = operation ? affordance.forms.filter((form) => getEffectiveOps(form, affordanceType, affordance).includes(operation)) : affordance.forms; - return Array.from(new Set(forms.map((form) => getProtocolFromForm(form)).filter(Boolean))); + return Array.from(new Set(forms.map((form) => getProtocolFromForm(form, base)).filter(Boolean))); } /** @@ -255,12 +285,13 @@ export function selectForm( operation: Op, supportedProtocols: readonly PROTOCOL[], affordanceType?: AffordanceType, - affordance?: Affordance + affordance?: Affordance, + base?: string ): Form { const match = forms.find( (form) => getEffectiveOps(form, affordanceType ?? "properties", affordance).includes(operation) && - supportedProtocols.some((p) => getProtocolFromForm(form).includes(p)) + supportedProtocols.some((p) => getProtocolFromForm(form, base).includes(p)) ); if (!match) { throw new Error(`No form found for operation "${operation}" with supported protocols`); @@ -279,21 +310,72 @@ export interface ModbusInfo { } /** - * Extracts Modbus connection parameters from a form, - * using modv: extensions and falling back to the href path segments. + * Reads a Modbus extension value from a form, accepting both the official + * `modv:` namespace and the legacy/vendor `modbus:` namespace. + */ +function readModbusExtension(form: Form, field: string): number | string | undefined { + const record = form as unknown as Record; + return record[`modv:${field}`] ?? record[`modbus:${field}`]; +} + +/** Coerces a Modbus extension value (which may be a numeric string) to a number. */ +function toModbusNumber(value: number | string | undefined): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + const parsed = typeof value === "number" ? value : parseInt(value, 10); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** + * Derives the Modbus function name from the `entity` extension and the + * operation kind (read vs. write) when no explicit function is provided. + */ +function modbusFunctionFromEntity(entity: string | undefined, isWrite: boolean, quantity: number): string { + switch ((entity ?? "").toLowerCase()) { + case "coil": + return isWrite ? (quantity > 1 ? "writeMultipleCoils" : "writeSingleCoil") : "readCoil"; + case "discreteinput": + return "readDiscreteInput"; + case "holdingregister": + return isWrite ? (quantity > 1 ? "writeMultipleRegisters" : "writeSingleRegister") : "readHoldingRegisters"; + case "inputregister": + return "readInputRegisters"; + default: + return isWrite ? "writeSingleCoil" : "readCoil"; + } +} + +/** + * Extracts Modbus connection parameters from a form, supporting both the + * `modv:` and `modbus:` extension namespaces (values may be numeric strings). + * Falls back to the href path segments for unit id / address, and derives the + * Modbus function from the `entity` extension when no function is specified. + * The href is resolved against the TD `base` when it is relative. */ -export function parseModbusInfo(form: Form): ModbusInfo { - const sanitized = form.href.replace(/^modbus\+tcp/, "http"); +export function parseModbusInfo(form: Form, base?: string, operation?: Op): ModbusInfo { + const resolved = resolveHref(form.href, base); + const sanitized = resolved.replace(/^modbus\+tcp/, "http"); const url = new URL(sanitized); const pathParts = url.pathname.split("/").filter(Boolean); + const unitId = toModbusNumber(readModbusExtension(form, "unitID")); + const address = toModbusNumber(readModbusExtension(form, "address")); + const quantity = toModbusNumber(readModbusExtension(form, "quantity")) ?? 1; + const entity = readModbusExtension(form, "entity") as string | undefined; + const explicitFunction = readModbusExtension(form, "function") as string | undefined; + const isWrite = operation ? operationHasPayload(operation) : false; + + const pathUnitId = toModbusNumber(pathParts[0]); + const pathAddress = toModbusNumber(pathParts[1]); + return { host: url.hostname, port: parseInt(url.port) || 502, - unitId: form["modv:unitID"] ?? (pathParts[0] ? parseInt(pathParts[0]) : 1), - address: form["modv:address"] ?? (pathParts[1] ? parseInt(pathParts[1]) : 0), - quantity: form["modv:quantity"] ?? 1, - modbusFunction: form["modv:function"] ?? "readCoil", + unitId: unitId ?? pathUnitId ?? 1, + address: address ?? pathAddress ?? 0, + quantity, + modbusFunction: explicitFunction ?? modbusFunctionFromEntity(entity, isWrite, quantity), }; } @@ -320,11 +402,11 @@ export const NODE_WOT_BINDINGS: Record = { * Collects the unique node-wot binding imports needed for the protocols * used across the given forms. */ -export function getNodeWotBindings(forms: Form[]): BindingInfo[] { +export function getNodeWotBindings(forms: Form[], base?: string): BindingInfo[] { const seen = new Set(); const bindings: BindingInfo[] = []; for (const form of forms) { - const protocol = getProtocolFromForm(form); + const protocol = getProtocolFromForm(form, base); const binding = NODE_WOT_BINDINGS[protocol]; if (binding && !seen.has(binding.factoryName)) { seen.add(binding.factoryName); diff --git a/node/code-gen/src/generators/java.ts b/node/code-gen/src/generators/java.ts index 2cf2939..6298f18 100644 --- a/node/code-gen/src/generators/java.ts +++ b/node/code-gen/src/generators/java.ts @@ -1,12 +1,13 @@ import { Op } from "../types.js"; -import { CodeGenerator, getHttpMethod, operationHasPayload, parseModbusInfo } from "./helpers.js"; +import { CodeGenerator, getHttpMethod, operationHasPayload, parseModbusInfo, resolveHref } from "./helpers.js"; // --------------------------------------------------------------------------- // java.net.http.HttpClient – Java built-in HTTP client (Java 11+) // --------------------------------------------------------------------------- export const generateJavaHttpClientCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; + const href = resolveHref(form.href, td.base); const method = getHttpMethod(operation, form); const hasPayload = operationHasPayload(operation); @@ -38,7 +39,7 @@ public class Main { .connectTimeout(Duration.ofSeconds(10)) .build(); - String url = "${form.href}"; + String url = "${href}"; ${payloadDecl} HttpRequest request = HttpRequest.newBuilder() @@ -193,8 +194,8 @@ function getDigitalpetriCall(modbusFunction: string, address: number, quantity: } export const generateDigitalpetriModbusCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; - const info = parseModbusInfo(form); + const { td, affordanceKey, operation, form } = ctx; + const info = parseModbusInfo(form, td.base, operation); const call = getDigitalpetriCall(info.modbusFunction, info.address, info.quantity); return `import com.digitalpetri.modbus.master.ModbusTcpMaster; diff --git a/node/code-gen/src/generators/javascript.ts b/node/code-gen/src/generators/javascript.ts index ac08f46..2d3d3e6 100644 --- a/node/code-gen/src/generators/javascript.ts +++ b/node/code-gen/src/generators/javascript.ts @@ -6,6 +6,7 @@ import { isStreamingOperation, operationHasPayload, parseModbusInfo, + resolveHref, } from "./helpers.js"; // --------------------------------------------------------------------------- @@ -71,7 +72,7 @@ export const generateNodeWotCode: CodeGenerator = (ctx) => { const allForms = AFFORDANCE_TYPES.flatMap((type) => (td[type] ? Object.values(td[type]) : [])).flatMap( (affordance) => affordance.forms ); - const bindings = getNodeWotBindings(allForms); + const bindings = getNodeWotBindings(allForms, td.base); const imports = [ `import { Servient } from "@node-wot/core";`, @@ -108,7 +109,8 @@ main(); // --------------------------------------------------------------------------- export const generateFetchCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; + const href = resolveHref(form.href, td.base); const method = getHttpMethod(operation, form); const hasPayload = operationHasPayload(operation); const streaming = isStreamingOperation(operation); @@ -137,7 +139,7 @@ export const generateFetchCode: CodeGenerator = (ctx) => { return `// Auto-generated code using the Fetch API // Operation: ${operation} on "${affordanceKey}" ${payloadLine} -const url = "${form.href}"; +const url = "${href}"; const response = await fetch(url, { ${fetchOptions.join(",\n ")}, @@ -156,10 +158,10 @@ ${responseHandling} // --------------------------------------------------------------------------- export const generateWebthingCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; if (isStreamingOperation(operation)) { - const wsUrl = form.href.replace(/^http/, "ws"); + const wsUrl = resolveHref(form.href, td.base).replace(/^http/, "ws"); return `// Auto-generated code using WebSocket (webthing) // Operation: ${operation} on "${affordanceKey}" @@ -217,8 +219,8 @@ function getModbusSerialCall(modbusFunction: string, address: number, quantity: } export const generateModbusSerialCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; - const info = parseModbusInfo(form); + const { td, affordanceKey, operation, form } = ctx; + const info = parseModbusInfo(form, td.base, operation); const isWrite = operationHasPayload(operation); const call = getModbusSerialCall(info.modbusFunction, info.address, info.quantity); diff --git a/node/code-gen/src/generators/php.ts b/node/code-gen/src/generators/php.ts index a235acf..b8ea581 100644 --- a/node/code-gen/src/generators/php.ts +++ b/node/code-gen/src/generators/php.ts @@ -1,11 +1,12 @@ -import { CodeGenerator, getHttpMethod, operationHasPayload } from "./helpers.js"; +import { CodeGenerator, getHttpMethod, operationHasPayload, resolveHref } from "./helpers.js"; // --------------------------------------------------------------------------- // cURL – PHP HTTP client // --------------------------------------------------------------------------- export const generatePhpCurlCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; + const href = resolveHref(form.href, td.base); const method = getHttpMethod(operation, form); const hasPayload = operationHasPayload(operation); @@ -21,7 +22,7 @@ curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);` // Auto-generated code using cURL // Operation: ${operation} on "${affordanceKey}" -$url = "${form.href}"; +$url = "${href}"; $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); diff --git a/node/code-gen/src/generators/python.ts b/node/code-gen/src/generators/python.ts index 0707952..08fcd0b 100644 --- a/node/code-gen/src/generators/python.ts +++ b/node/code-gen/src/generators/python.ts @@ -1,5 +1,12 @@ import { Op } from "../types.js"; -import { CodeGenerator, getHttpMethod, isStreamingOperation, operationHasPayload, parseModbusInfo } from "./helpers.js"; +import { + CodeGenerator, + getHttpMethod, + isStreamingOperation, + operationHasPayload, + parseModbusInfo, + resolveHref, +} from "./helpers.js"; // --------------------------------------------------------------------------- // requests – Python HTTP library @@ -10,7 +17,8 @@ function pythonMethodCall(method: string): string { } export const generateRequestsCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; + const href = resolveHref(form.href, td.base); const method = getHttpMethod(operation, form); const hasPayload = operationHasPayload(operation); const streaming = isStreamingOperation(operation); @@ -34,7 +42,7 @@ export const generateRequestsCode: CodeGenerator = (ctx) => { # Auto-generated code using the requests library # Operation: ${operation} on "${affordanceKey}" ${payloadDef} -url = "${form.href}" +url = "${href}" response = requests.${pythonMethodCall( method @@ -152,8 +160,8 @@ function getPyModbusCall(modbusFunction: string, address: number, quantity: numb } export const generatePyModbusCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; - const info = parseModbusInfo(form); + const { td, affordanceKey, operation, form } = ctx; + const info = parseModbusInfo(form, td.base, operation); const isWrite = operationHasPayload(operation); const call = getPyModbusCall(info.modbusFunction, info.address, info.quantity, info.unitId); diff --git a/node/code-gen/src/generators/ruby.ts b/node/code-gen/src/generators/ruby.ts index cb0e5d3..b2eea54 100644 --- a/node/code-gen/src/generators/ruby.ts +++ b/node/code-gen/src/generators/ruby.ts @@ -1,11 +1,12 @@ -import { CodeGenerator, getHttpMethod, operationHasPayload } from "./helpers.js"; +import { CodeGenerator, getHttpMethod, operationHasPayload, resolveHref } from "./helpers.js"; // --------------------------------------------------------------------------- // Net::HTTP – Ruby standard library HTTP client // --------------------------------------------------------------------------- export const generateRubyNetHttpCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; + const href = resolveHref(form.href, td.base); const method = getHttpMethod(operation, form); const hasPayload = operationHasPayload(operation); @@ -25,7 +26,7 @@ request.content_type = "application/json" request.body = JSON.generate({})` : ""; - const sslLine = form.href.startsWith("https") ? `http.use_ssl = true` : `http.use_ssl = false`; + const sslLine = href.startsWith("https") ? `http.use_ssl = true` : `http.use_ssl = false`; return `require "net/http" require "uri" @@ -34,7 +35,7 @@ require "json" # Auto-generated code using Net::HTTP # Operation: ${operation} on "${affordanceKey}" -uri = URI.parse("${form.href}") +uri = URI.parse("${href}") http = Net::HTTP.new(uri.host, uri.port) ${sslLine} diff --git a/node/code-gen/src/generators/rust.ts b/node/code-gen/src/generators/rust.ts index 340a8cd..9daac5c 100644 --- a/node/code-gen/src/generators/rust.ts +++ b/node/code-gen/src/generators/rust.ts @@ -1,11 +1,12 @@ -import { CodeGenerator, getHttpMethod, operationHasPayload } from "./helpers.js"; +import { CodeGenerator, getHttpMethod, operationHasPayload, resolveHref } from "./helpers.js"; // --------------------------------------------------------------------------- // reqwest – Rust async HTTP client // --------------------------------------------------------------------------- export const generateReqwestCode: CodeGenerator = (ctx) => { - const { affordanceKey, operation, form } = ctx; + const { td, affordanceKey, operation, form } = ctx; + const href = resolveHref(form.href, td.base); const method = getHttpMethod(operation, form); const hasPayload = operationHasPayload(operation); @@ -25,7 +26,7 @@ use serde_json; #[tokio::main] async fn main() -> Result<(), Box> { - let url = "${form.href}"; + let url = "${href}"; ${payloadDef} let response = ${requestBuilder} .await?; diff --git a/node/code-gen/src/index.ts b/node/code-gen/src/index.ts index ab1f069..2158ad5 100644 --- a/node/code-gen/src/index.ts +++ b/node/code-gen/src/index.ts @@ -53,7 +53,7 @@ export function generateCode(params: GenerateCodeParams): GenerateCodeResult { // Filter forms for the given protocol support const availableFormsForProtocol = availableFormsForOperation.filter((form) => - isProtocolSupported(language, library, getProtocolFromForm(form)) + isProtocolSupported(language, library, getProtocolFromForm(form, td.base)) ); if (availableFormsForProtocol.length > 0) { @@ -65,7 +65,7 @@ export function generateCode(params: GenerateCodeParams): GenerateCodeResult { const forms = td[affordanceType][affordanceKey].forms; const supportedProtocols = LANGUAGES_SUPPORT[language].libraries[library]; - const form = selectForm(forms, operation, supportedProtocols, affordanceType, affordance); + const form = selectForm(forms, operation, supportedProtocols, affordanceType, affordance, td.base); const code = generator({ td, affordanceType, affordanceKey, operation, form, affordance }); return { code }; @@ -76,7 +76,7 @@ export function generateCode(params: GenerateCodeParams): GenerateCodeResult { ].libraries[library].join( ", " )}. Available protocols for this affordance are: ${availableFormsForOperation - .map((form) => getProtocolFromForm(form)) + .map((form) => getProtocolFromForm(form, td.base)) .join(", ")}.` ); } diff --git a/node/code-gen/src/tests/fixtures.ts b/node/code-gen/src/tests/fixtures.ts index ea959fc..724a834 100644 --- a/node/code-gen/src/tests/fixtures.ts +++ b/node/code-gen/src/tests/fixtures.ts @@ -211,3 +211,47 @@ export const WRITE_ONLY_TD: Affordances = { actions: {}, events: {}, }; + +/** + * An HTTP-based TD that uses relative hrefs resolved against a `base` URI. + */ +export const RELATIVE_HTTP_TD: Affordances = { + base: "https://example.com/things/thing1/", + properties: { + temperature: { + type: "number", + forms: [ + { + href: "properties/temperature", + op: ["readproperty", "writeproperty"], + }, + ], + }, + }, + actions: {}, + events: {}, +}; + +/** + * A Modbus TD that uses relative hrefs resolved against a `base` URI. + */ +export const RELATIVE_MODBUS_TD: Affordances = { + base: "modbus+tcp://192.168.1.1:502/", + properties: { + coilStatus: { + type: "boolean", + forms: [ + { + href: "1/100", + op: ["readproperty"], + "modv:function": "readCoil", + "modv:unitID": 1, + "modv:address": 100, + "modv:quantity": 4, + }, + ], + }, + }, + actions: {}, + events: {}, +}; diff --git a/node/code-gen/src/tests/helpers.test.ts b/node/code-gen/src/tests/helpers.test.ts index e115942..1dd9219 100644 --- a/node/code-gen/src/tests/helpers.test.ts +++ b/node/code-gen/src/tests/helpers.test.ts @@ -8,6 +8,7 @@ import { isStreamingOperation, selectForm, parseModbusInfo, + resolveHref, getNodeWotBindings, extractAvailableAffordances, getAvailableOperations, @@ -319,6 +320,104 @@ describe("parseModbusInfo", () => { expect(info.unitId).toBe(1); expect(info.address).toBe(0); }); + + it("reads the legacy modbus: namespace and coerces string values", () => { + const form: Form = { + href: "/test", + "modbus:unitID": "1", + "modbus:quantity": 1, + "modbus:address": 2, + "modbus:entity": "HoldingRegister", + }; + const info = parseModbusInfo(form, "modbus://localhost:62", "readproperty"); + expect(info.host).toBe("localhost"); + expect(info.port).toBe(62); + expect(info.unitId).toBe(1); + expect(info.address).toBe(2); + expect(info.quantity).toBe(1); + expect(info.modbusFunction).toBe("readHoldingRegisters"); + }); + + it("does not fall back to a non-numeric path segment (no NaN unitId)", () => { + const form: Form = { + href: "/test", + }; + const info = parseModbusInfo(form, "modbus://localhost:62"); + expect(info.unitId).toBe(1); + expect(info.address).toBe(0); + expect(Number.isNaN(info.unitId)).toBe(false); + }); + + it("derives the write function from the entity for a write operation", () => { + const form: Form = { + href: "/", + "modbus:unitID": "3", + "modbus:address": 10, + "modbus:entity": "HoldingRegister", + }; + const info = parseModbusInfo(form, "modbus://localhost:502", "writeproperty"); + expect(info.unitId).toBe(3); + expect(info.modbusFunction).toBe("writeSingleRegister"); + }); + + it("prefers an explicit function over the entity", () => { + const form: Form = { + href: "/", + "modv:function": "readInputRegisters", + "modbus:entity": "HoldingRegister", + }; + const info = parseModbusInfo(form, "modbus://localhost:502", "readproperty"); + expect(info.modbusFunction).toBe("readInputRegisters"); + }); + + it("resolves a relative href against the TD base", () => { + const form: Form = { + href: "1/100", + "modv:function": "readCoil", + }; + const info = parseModbusInfo(form, "modbus+tcp://192.168.1.1:502/"); + expect(info.host).toBe("192.168.1.1"); + expect(info.port).toBe(502); + expect(info.unitId).toBe(1); + expect(info.address).toBe(100); + }); + + it("resolves a root-relative href against the TD base", () => { + const form: Form = { + href: "/3/50", + }; + const info = parseModbusInfo(form, "modbus+tcp://192.168.1.1:502"); + expect(info.host).toBe("192.168.1.1"); + expect(info.unitId).toBe(3); + expect(info.address).toBe(50); + }); +}); + +describe("resolveHref", () => { + it("returns an absolute href unchanged", () => { + expect(resolveHref("https://example.com/foo", "https://other.com/")).toBe("https://example.com/foo"); + }); + + it("resolves a relative href against the base", () => { + expect(resolveHref("properties/temp", "https://example.com/base/")).toBe( + "https://example.com/base/properties/temp" + ); + }); + + it("resolves a root-relative href against the base", () => { + expect(resolveHref("/properties/temp", "https://example.com/base/")).toBe( + "https://example.com/properties/temp" + ); + }); + + it("resolves a relative href against a modbus+tcp base", () => { + expect(resolveHref("1/100", "modbus+tcp://192.168.1.1:502/")).toBe("modbus+tcp://192.168.1.1:502/1/100"); + }); + + it("returns the relative href unchanged when no base is provided", () => { + expect(resolveHref("/properties/temp")).toBe("/properties/temp"); + expect(resolveHref("/properties/temp", undefined)).toBe("/properties/temp"); + }); }); describe("getNodeWotBindings", () => { diff --git a/node/code-gen/src/tests/index.test.ts b/node/code-gen/src/tests/index.test.ts index bfc8f2f..d4f902f 100644 --- a/node/code-gen/src/tests/index.test.ts +++ b/node/code-gen/src/tests/index.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from "vitest"; import { generateCode, isProtocolSupported, generatePrompt } from "../index.js"; -import { HTTP_TD, MODBUS_TD, WRITE_ONLY_TD, CUSTOM_METHOD_TD } from "./fixtures.js"; +import { + HTTP_TD, + MODBUS_TD, + WRITE_ONLY_TD, + CUSTOM_METHOD_TD, + RELATIVE_HTTP_TD, + RELATIVE_MODBUS_TD, +} from "./fixtures.js"; describe("generateCode", () => { describe("JavaScript / fetch", () => { @@ -723,6 +730,54 @@ describe("generateCode", () => { } }); }); + + describe("Relative hrefs resolved against the TD base", () => { + it("resolves a relative HTTP href for fetch", () => { + const result = generateCode({ + td: RELATIVE_HTTP_TD, + language: "javascript", + library: "fetch", + affordanceType: "properties", + affordanceKey: "temperature", + operation: "readproperty", + }); + expect("code" in result).toBe(true); + if ("code" in result) { + expect(result.code).toContain('const url = "https://example.com/things/thing1/properties/temperature"'); + } + }); + + it("resolves a relative HTTP href for python requests", () => { + const result = generateCode({ + td: RELATIVE_HTTP_TD, + language: "python", + library: "requests", + affordanceType: "properties", + affordanceKey: "temperature", + operation: "readproperty", + }); + expect("code" in result).toBe(true); + if ("code" in result) { + expect(result.code).toContain('url = "https://example.com/things/thing1/properties/temperature"'); + } + }); + + it("resolves a relative Modbus href without throwing", () => { + const result = generateCode({ + td: RELATIVE_MODBUS_TD, + language: "javascript", + library: "modbus-serial", + affordanceType: "properties", + affordanceKey: "coilStatus", + operation: "readproperty", + }); + expect("code" in result).toBe(true); + if ("code" in result) { + expect(result.code).toContain('client.connectTCP("192.168.1.1", { port: 502 })'); + expect(result.code).toContain("readCoils"); + } + }); + }); }); describe("isProtocolSupported", () => { diff --git a/node/code-gen/src/types.ts b/node/code-gen/src/types.ts index 85d193f..4fd1974 100644 --- a/node/code-gen/src/types.ts +++ b/node/code-gen/src/types.ts @@ -15,10 +15,16 @@ export interface GenerateCodeParams { operation: Op; output?: string; } -export type Affordances = Record>; +export type Affordances = Record> & { + /** + * Base URI used to resolve relative form hrefs (WoT TD `base` field). + * Optional: when absent, hrefs are expected to be absolute. + */ + base?: string; +}; export const AFFORDANCE_TYPES = ["properties", "actions", "events"] as const; -export type AffordanceType = typeof AFFORDANCE_TYPES[number]; +export type AffordanceType = (typeof AFFORDANCE_TYPES)[number]; export interface Affordance { forms: Form[]; @@ -35,10 +41,16 @@ export interface Form { op?: Op | Op[]; "htv:methodName"?: string; subprotocol?: string; - "modv:unitID"?: number; - "modv:address"?: number; + "modv:unitID"?: number | string; + "modv:address"?: number | string; "modv:function"?: string; - "modv:quantity"?: number; + "modv:quantity"?: number | string; + "modv:entity"?: string; + "modbus:unitID"?: number | string; + "modbus:address"?: number | string; + "modbus:function"?: string; + "modbus:quantity"?: number | string; + "modbus:entity"?: string; } export const OPERATIONS = { @@ -57,7 +69,7 @@ export const OPERATIONS = { action: ["invokeaction", "queryaction", "cancelaction", "queryallactions"], event: ["subscribeevent", "unsubscribeevent", "subscribeallevents", "unsubscribeallevents"], } as const; -export type Op = typeof OPERATIONS[keyof typeof OPERATIONS][number]; +export type Op = (typeof OPERATIONS)[keyof typeof OPERATIONS][number]; export enum PROTOCOL { COAP = "coap",