Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/prettier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
uses: actions/checkout@v4

- name: Install prettier
run: npm install prettier@2.3.2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bobur-khay what is the reason for change? We kept having problems with prettier versioning before

run: npm install prettier@2.8.8

- name: Run prettier
run: npm run format:check
46 changes: 39 additions & 7 deletions node/code-gen/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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) => {
Expand All @@ -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] : [])];
}
}

Expand All @@ -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
? [
Expand Down
7 changes: 4 additions & 3 deletions node/code-gen/src/generators/csharp.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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}

Expand Down
7 changes: 4 additions & 3 deletions node/code-gen/src/generators/dart.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -86,7 +86,8 @@ Future<void> 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);

Expand All @@ -108,7 +109,7 @@ import "package:http/http.dart" as http;
// Operation: ${operation} on "${affordanceKey}"

Future<void> main() async {
final url = Uri.parse("${form.href}");
final url = Uri.parse("${href}");
${payloadBlock}
${methodCall}

Expand Down
7 changes: 4 additions & 3 deletions node/code-gen/src/generators/go.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const method = getHttpMethod(operation, form);
const hasPayload = operationHasPayload(operation);

Expand Down Expand Up @@ -43,7 +44,7 @@ import (
)

func main() {
\turl := "${form.href}"
\turl := "${href}"
${payloadBlock}

\tclient := &http.Client{Timeout: 10 * time.Second}
Expand Down
114 changes: 98 additions & 16 deletions node/code-gen/src/generators/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -137,15 +166,16 @@ 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 [];
}
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)));
}

/**
Expand Down Expand Up @@ -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`);
Expand All @@ -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<string, number | string | undefined>;
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),
};
}

Expand All @@ -320,11 +402,11 @@ export const NODE_WOT_BINDINGS: Record<string, BindingInfo> = {
* 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<string>();
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);
Expand Down
11 changes: 6 additions & 5 deletions node/code-gen/src/generators/java.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -38,7 +39,7 @@ public class Main {
.connectTimeout(Duration.ofSeconds(10))
.build();

String url = "${form.href}";
String url = "${href}";
${payloadDecl}

HttpRequest request = HttpRequest.newBuilder()
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading