@typecad/framework-arduino 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +192 -0
  3. package/dist/arduino/i2c-arduino.d.ts +47 -0
  4. package/dist/arduino/i2c-arduino.js +6 -0
  5. package/dist/arduino/index.d.ts +6 -0
  6. package/dist/arduino/index.js +8 -0
  7. package/dist/arduino/spi-arduino.d.ts +42 -0
  8. package/dist/arduino/spi-arduino.js +6 -0
  9. package/dist/arduino/stubs-uno.d.ts +9 -0
  10. package/dist/arduino/stubs-uno.js +65 -0
  11. package/dist/arduino/uart-arduino.d.ts +41 -0
  12. package/dist/arduino/uart-arduino.js +6 -0
  13. package/dist/arduino-class-map.d.ts +5 -0
  14. package/dist/arduino-class-map.js +21 -0
  15. package/dist/arduino-compile.d.ts +24 -0
  16. package/dist/arduino-compile.js +316 -0
  17. package/dist/arduino-libs.d.ts +6 -0
  18. package/dist/arduino-libs.js +11 -0
  19. package/dist/cli-metadata.d.ts +12 -0
  20. package/dist/cli-metadata.js +196 -0
  21. package/dist/cpp-parser.d.ts +47 -0
  22. package/dist/cpp-parser.js +292 -0
  23. package/dist/debug-codegen.d.ts +20 -0
  24. package/dist/debug-codegen.js +115 -0
  25. package/dist/displays/ili9341-spi.d.ts +6 -0
  26. package/dist/displays/ili9341-spi.js +28 -0
  27. package/dist/displays/ssd1309-i2c.d.ts +2 -0
  28. package/dist/displays/ssd1309-i2c.js +16 -0
  29. package/dist/displays/st7796-spi.d.ts +2 -0
  30. package/dist/displays/st7796-spi.js +20 -0
  31. package/dist/graphics/ili9341.d.ts +24 -0
  32. package/dist/graphics/ili9341.js +59 -0
  33. package/dist/index.d.ts +13 -0
  34. package/dist/index.js +27 -0
  35. package/dist/lib-declaration.d.ts +35 -0
  36. package/dist/lib-declaration.js +399 -0
  37. package/dist/lib-discovery.d.ts +38 -0
  38. package/dist/lib-discovery.js +189 -0
  39. package/dist/profile.d.ts +8 -0
  40. package/dist/profile.js +584 -0
  41. package/dist/strategy.d.ts +174 -0
  42. package/dist/strategy.js +2032 -0
  43. package/package.json +73 -0
  44. package/src/arduino/i2c-arduino.ts +72 -0
  45. package/src/arduino/index.ts +18 -0
  46. package/src/arduino/spi-arduino.ts +63 -0
  47. package/src/arduino/stubs-uno.ts +80 -0
  48. package/src/arduino/uart-arduino.ts +59 -0
  49. package/src/arduino-class-map.ts +32 -0
  50. package/src/arduino-compile.ts +359 -0
  51. package/src/arduino-libs.ts +33 -0
  52. package/src/cli-metadata.ts +250 -0
  53. package/src/cpp-parser.ts +361 -0
  54. package/src/debug-codegen.ts +155 -0
  55. package/src/displays/ili9341-spi.ts +33 -0
  56. package/src/displays/ssd1309-i2c.ts +19 -0
  57. package/src/displays/st7796-spi.ts +23 -0
  58. package/src/graphics/ili9341.ts +80 -0
  59. package/src/index.ts +37 -0
  60. package/src/lib-declaration.ts +493 -0
  61. package/src/lib-discovery.ts +267 -0
  62. package/src/profile.ts +676 -0
  63. package/src/strategy.ts +2126 -0
@@ -0,0 +1,359 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Arduino sketch compilation, upload, and monitoring
3
+ //
4
+ // Provides functions to flatten generated modules into an Arduino sketch,
5
+ // compile using arduino-cli, upload to a board, and monitor serial output.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import path from "node:path";
9
+ import fs from "node:fs";
10
+ import { spawnSync } from "node:child_process";
11
+ import type { CompileError, CompileResult, UploadResult } from "@typecad/cuttlefish/api/shared";
12
+ import { parseCompileErrors, collectCppFiles } from "@typecad/cuttlefish/api/shared";
13
+
14
+ export type ArduinoCompileResult = CompileResult;
15
+ export type ArduinoUploadResult = UploadResult;
16
+
17
+ const ARDUINO_ERROR = /^(.*?):(\d+):\d+:\s*(error|warning|note):\s*(.*)$/i;
18
+
19
+ /**
20
+ * Parse compile errors with Arduino format support.
21
+ */
22
+ function parseArduinoCompileErrors(output: string, sketchDir?: string): CompileError[] {
23
+ const errors: CompileError[] = [];
24
+
25
+ for (const rawLine of output.split(/\r?\n/)) {
26
+ const line = rawLine.trim();
27
+ if (!line) continue;
28
+
29
+ let match = line.match(ARDUINO_ERROR);
30
+
31
+ if (!match) {
32
+ continue;
33
+ }
34
+
35
+ const severityRaw = match[3].toLowerCase();
36
+ const severity: "error" | "warning" | "note" =
37
+ severityRaw.includes("error") ? "error" : severityRaw === "warning" ? "warning" : "note";
38
+
39
+ let filePath = match[1];
40
+
41
+ if (sketchDir) {
42
+ if (!path.isAbsolute(filePath)) {
43
+ const resolved = path.resolve(sketchDir, filePath);
44
+ if (fs.existsSync(resolved)) {
45
+ filePath = resolved;
46
+ }
47
+ }
48
+ if (filePath.includes(path.basename(sketchDir))) {
49
+ filePath = path.resolve(sketchDir, path.basename(sketchDir) + ".ino");
50
+ }
51
+ }
52
+
53
+ errors.push({
54
+ filePath: path.resolve(filePath),
55
+ line: parseInt(match[2], 10),
56
+ column: 1,
57
+ severity,
58
+ message: match[4],
59
+ });
60
+ }
61
+
62
+ return errors;
63
+ }
64
+
65
+ /**
66
+ * Parse memory usage from arduino-cli output.
67
+ */
68
+ export function parseArduinoMemoryUsage(output: string): { flashUsed?: number, flashTotal?: number, ramUsed?: number, ramTotal?: number } {
69
+ const result: { flashUsed?: number, flashTotal?: number, ramUsed?: number, ramTotal?: number } = {};
70
+
71
+ // Sketch uses 444 bytes (1%) of program storage space. Maximum is 32256 bytes.
72
+ const flashMatch = output.match(/Sketch uses (\d+) bytes.*Maximum is (\d+) bytes/i);
73
+ if (flashMatch) {
74
+ result.flashUsed = parseInt(flashMatch[1], 10);
75
+ result.flashTotal = parseInt(flashMatch[2], 10);
76
+ }
77
+
78
+ // Global variables use 9 bytes (0%) of dynamic memory, leaving 2039 bytes for local variables. Maximum is 2048 bytes.
79
+ const ramMatch = output.match(/Global variables use (\d+) bytes.*Maximum is (\d+) bytes/i);
80
+ if (ramMatch) {
81
+ result.ramUsed = parseInt(ramMatch[1], 10);
82
+ result.ramTotal = parseInt(ramMatch[2], 10);
83
+ }
84
+
85
+ return result;
86
+ }
87
+
88
+ /**
89
+ * Extract architecture from FQBN string.
90
+ * FQBN format: vendor:arch:board[:config]
91
+ */
92
+ export function toArchitectureFromFqbn(buildTarget?: string): string | undefined {
93
+ if (!buildTarget) return undefined;
94
+ return buildTarget.split(":")[1];
95
+ }
96
+
97
+ export function flattenGeneratedModulesIntoSketch(sketchDir: string, sketchPath: string): void {
98
+ const normalizedSketchPath = path.resolve(sketchPath);
99
+ const cppFiles = collectCppFiles(sketchDir).filter((filePath) => path.resolve(filePath) !== normalizedSketchPath);
100
+ if (cppFiles.length === 0) {
101
+ return;
102
+ }
103
+
104
+ // Collect header file names that correspond to the merged .cpp files
105
+ // These includes should be stripped from the entry sketch since the code is merged inline
106
+ const mergedHeaderNames = new Set<string>();
107
+ // Also track header file paths for inlining
108
+ const headerFiles = new Map<string, string>(); // baseName -> headerPath
109
+ for (const cppPath of cppFiles) {
110
+ const baseName = path.basename(cppPath, path.extname(cppPath));
111
+ const headerName = `${baseName}.h`;
112
+ mergedHeaderNames.add(headerName);
113
+
114
+ // Check if corresponding .h file exists
115
+ const headerPath = path.join(path.dirname(cppPath), headerName);
116
+ if (fs.existsSync(headerPath) && fs.statSync(headerPath).isFile()) {
117
+ headerFiles.set(baseName, headerPath);
118
+ }
119
+ }
120
+
121
+ // Also collect standalone .h files in the output directory that have no .cpp.
122
+ // These come from modules that only contain `declare function` (no bodies = no .cpp output).
123
+ for (const entry of fs.readdirSync(sketchDir)) {
124
+ if (!entry.endsWith(".h")) continue;
125
+ if (mergedHeaderNames.has(entry)) continue;
126
+ // Skip the sketch's own header and non-generated files
127
+ if (entry === path.basename(normalizedSketchPath, ".ino") + ".h") continue;
128
+ mergedHeaderNames.add(entry);
129
+ const headerPath = path.join(sketchDir, entry);
130
+ if (fs.statSync(headerPath).isFile()) {
131
+ headerFiles.set(path.basename(entry, ".h"), headerPath);
132
+ }
133
+ }
134
+
135
+ const originalSketch = fs.readFileSync(normalizedSketchPath, "utf8");
136
+ const sanitizedSketch = originalSketch
137
+ .replace(/^\s*#include\s+<Arduino\.h>\s*$/gm, "")
138
+ .replace(/^\s*#include\s+"Arduino\.h"\s*$/gm, "")
139
+ // Strip includes for headers corresponding to merged .cpp modules
140
+ .replace(/^\s*#include\s+"([^"]+)"\s*$/gm, (line, includePath: string) => {
141
+ const headerName = path.basename(includePath);
142
+ if (mergedHeaderNames.has(headerName)) {
143
+ return ""; // Remove the include since the module is merged inline
144
+ }
145
+ return line;
146
+ });
147
+ // Collect module contents - native C++ modules are NOT wrapped in namespaces
148
+ // since they're already valid C++ code
149
+ const moduleContents: string[] = [];
150
+
151
+ for (const cppPath of cppFiles) {
152
+ const baseName = path.basename(cppPath, path.extname(cppPath));
153
+ const relativePath = path.relative(sketchDir, cppPath).replace(/\\/g, "/");
154
+
155
+ // First, inline the corresponding .h file if it exists
156
+ const headerPath = headerFiles.get(baseName);
157
+ if (headerPath) {
158
+ const headerRelativePath = path.relative(sketchDir, headerPath).replace(/\\/g, "/");
159
+ const headerContent = fs.readFileSync(headerPath, "utf8");
160
+ const sanitizedHeader = headerContent
161
+ .replace(/^\s*#include\s+<Arduino\.h>\s*$/gm, "")
162
+ .replace(/^\s*#include\s+"Arduino\.h"\s*$/gm, "")
163
+ .replace(/^\s*#include\s+"([^"]+)"\s*$/gm, (line, includePath: string) => {
164
+ const headerName = path.basename(includePath);
165
+ if (mergedHeaderNames.has(headerName)) {
166
+ return ""; // Remove the include since the module is merged inline
167
+ }
168
+ return line;
169
+ });
170
+
171
+ moduleContents.push(`\n// ---- merged from ${headerRelativePath} ----\n${sanitizedHeader}\n`);
172
+ }
173
+
174
+ const content = fs.readFileSync(cppPath, "utf8");
175
+ const rewritten = content.replace(/^\s*#include\s+"([^"]+)"\s*$/gm, (_line, includePath: string) => {
176
+ const headerName = path.basename(includePath);
177
+ // Strip includes for headers that we've already inlined
178
+ if (mergedHeaderNames.has(headerName)) {
179
+ return "";
180
+ }
181
+
182
+ const resolved = path.resolve(path.dirname(cppPath), includePath);
183
+ if (!resolved.startsWith(path.resolve(sketchDir))) {
184
+ return `#include "${includePath}"`;
185
+ }
186
+ let relativeToSketch = path.relative(sketchDir, resolved).replace(/\\/g, "/");
187
+ relativeToSketch = relativeToSketch.replace(/(^|\/)([^\/]+)\/\2(?=\/|$)/g, "$1$2");
188
+ if (!relativeToSketch.startsWith(".")) {
189
+ relativeToSketch = `./${relativeToSketch}`;
190
+ }
191
+ return `#include "${relativeToSketch}"`;
192
+ });
193
+ const sanitized = rewritten
194
+ .replace(/^\s*#include\s+<Arduino\.h>\s*$/gm, "")
195
+ .replace(/^\s*#include\s+"Arduino\.h"\s*$/gm, "");
196
+
197
+ // Skip modules whose body is empty after tree-shaking
198
+ const stripped = sanitized
199
+ .replace(/^\s*#include\s+.*$/gm, "")
200
+ .replace(/^\s*\/\/.*$/gm, "")
201
+ .replace(/\/\*[\s\S]*?\*\//g, "")
202
+ .trim();
203
+ if (stripped.length === 0) {
204
+ continue;
205
+ }
206
+
207
+ moduleContents.push(`\n// ---- merged from ${relativePath} ----\n${sanitized}\n`);
208
+ }
209
+
210
+ const mergedSketch = `${moduleContents.join("")}// ---- entry sketch ----\n${sanitizedSketch}\n`;
211
+ fs.writeFileSync(normalizedSketchPath, mergedSketch, "utf8");
212
+
213
+ // Clean up merged .cpp and .h files
214
+ for (const cppPath of cppFiles) {
215
+ fs.rmSync(cppPath, { force: true });
216
+ }
217
+ for (const headerPath of headerFiles.values()) {
218
+ fs.rmSync(headerPath, { force: true });
219
+ }
220
+ }
221
+
222
+ export function compileArduinoSketch(
223
+ sketchFilePath: string,
224
+ buildTarget: string,
225
+ options?: { extraFlags?: string[]; defines?: Record<string, string> },
226
+ ): ArduinoCompileResult {
227
+ const resolvedSketchFilePath = path.resolve(sketchFilePath);
228
+ let sketchDir = path.dirname(resolvedSketchFilePath);
229
+ let sketchDirName = path.basename(sketchDir);
230
+ let requiredSketchPath = path.join(sketchDir, `${sketchDirName}.ino`);
231
+
232
+ if (sketchDirName.startsWith(".")) {
233
+ const stagingDir = path.join(path.dirname(sketchDir), "ts2cpp_sketch");
234
+ try {
235
+ fs.rmSync(stagingDir, { recursive: true, force: true });
236
+ fs.mkdirSync(stagingDir, { recursive: true });
237
+ fs.cpSync(sketchDir, stagingDir, { recursive: true });
238
+ sketchDir = stagingDir;
239
+ sketchDirName = path.basename(sketchDir);
240
+ requiredSketchPath = path.join(sketchDir, `${sketchDirName}.ino`);
241
+ } catch {
242
+ // Best-effort staging; fall back to original directory behavior.
243
+ }
244
+ }
245
+
246
+ if (path.extname(resolvedSketchFilePath).toLowerCase() === ".ino" && resolvedSketchFilePath !== requiredSketchPath) {
247
+ try {
248
+ const sourceText = fs.readFileSync(resolvedSketchFilePath, "utf8");
249
+ if (!fs.existsSync(requiredSketchPath) || fs.readFileSync(requiredSketchPath, "utf8") !== sourceText) {
250
+ fs.writeFileSync(requiredSketchPath, sourceText, "utf8");
251
+ }
252
+
253
+ // Arduino sketches must have a single primary .ino matching the folder name.
254
+ // Remove other .ino files in the sketch root to avoid duplicate setup()/loop().
255
+ for (const child of fs.readdirSync(sketchDir)) {
256
+ if (!child.toLowerCase().endsWith(".ino")) {
257
+ continue;
258
+ }
259
+ const candidate = path.join(sketchDir, child);
260
+ if (path.resolve(candidate) !== path.resolve(requiredSketchPath)) {
261
+ fs.rmSync(candidate, { force: true });
262
+ }
263
+ }
264
+ } catch {
265
+ // Best-effort sketch alias generation. If this fails, arduino-cli output will report details.
266
+ }
267
+ }
268
+
269
+ try {
270
+ if (fs.existsSync(requiredSketchPath)) {
271
+ flattenGeneratedModulesIntoSketch(sketchDir, requiredSketchPath);
272
+ }
273
+ } catch {
274
+ // Best-effort flattening for generated modules.
275
+ }
276
+
277
+ // Build the arduino-cli argument list. User defines/extraFlags (from
278
+ // output.defines and output.extraFlags in cuttlefish.config.ts) are injected
279
+ // via a build_opt.h file in the sketch dir, which arduino-cli reads through
280
+ // the platform recipe's @"{build.opt.path}" token and feeds to the compiler
281
+ // WITHOUT touching any platform property.
282
+ //
283
+ // Why not --build-property? Both candidate properties are load-bearing on
284
+ // modern cores and `--build-property` REPLACES rather than appends:
285
+ // - compiler.cpp.extra_flags carries -MMD/-c (dependency tracking); replacing
286
+ // it desyncs the sketch from the precompiled core, producing dozens of
287
+ // "undefined reference" errors to core symbols (pinMode, Serial, main, ...).
288
+ // - build.extra_flags on the ESP32 core is a composed value that pulls in
289
+ // the USB/CDC/boot-mode defines (ARDUINO_USB_CDC_ON_BOOT, ARDUINO_USB_MODE,
290
+ // ...); replacing it silently breaks USB-serial sketches.
291
+ // build_opt.h avoids both: the platform recipe appends it verbatim, so every
292
+ // board/core property stays intact and the user defines are applied cleanly.
293
+ const compileArgs: string[] = ["compile", "--fqbn", buildTarget];
294
+ const userFlagParts: string[] = [];
295
+ if (options?.defines) {
296
+ for (const [name, value] of Object.entries(options.defines)) {
297
+ userFlagParts.push(value !== "" ? `-D${name}=${value}` : `-D${name}`);
298
+ }
299
+ }
300
+ if (options?.extraFlags) {
301
+ userFlagParts.push(...options.extraFlags);
302
+ }
303
+ if (userFlagParts.length > 0) {
304
+ const buildOptPath = path.join(sketchDir, "build_opt.h");
305
+ try {
306
+ // Preserve any user-authored build_opt.h content; append our defines
307
+ // rather than overwriting so we don't drop flags the sketch relies on.
308
+ const existing = fs.existsSync(buildOptPath)
309
+ ? fs.readFileSync(buildOptPath, "utf8")
310
+ : "";
311
+ const generated = `${userFlagParts.join("\n")}\n`;
312
+ const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
313
+ fs.writeFileSync(buildOptPath, existing + sep + generated, "utf8");
314
+ } catch {
315
+ // Best-effort: if we can't write build_opt.h, fall back to no defines
316
+ // rather than failing the whole compile. The compile will still run;
317
+ // the user's defines just won't be applied.
318
+ }
319
+ }
320
+ compileArgs.push(sketchDir);
321
+
322
+ const cmd = spawnSync("arduino-cli", compileArgs, {
323
+ encoding: "utf8",
324
+ timeout: 120000,
325
+ });
326
+
327
+ const output = `${cmd.stdout ?? ""}\n${cmd.stderr ?? ""}`.trim();
328
+ const gccErrors = parseCompileErrors(output, sketchDir);
329
+ const arduinoErrors = parseArduinoCompileErrors(output, sketchDir);
330
+ const errors = gccErrors.length > 0 ? gccErrors : arduinoErrors;
331
+
332
+ return {
333
+ success: cmd.status === 0,
334
+ output,
335
+ errors,
336
+ memoryUsage: cmd.status === 0 ? parseArduinoMemoryUsage(output) : undefined,
337
+ };
338
+ }
339
+
340
+ export function uploadArduinoSketch(sketchDir: string, buildTarget: string, port: string): ArduinoUploadResult {
341
+ const cmd = spawnSync("arduino-cli", ["upload", "--fqbn", buildTarget, "--port", port, sketchDir], {
342
+ encoding: "utf8",
343
+ timeout: 60000,
344
+ });
345
+
346
+ const output = `${cmd.stdout ?? ""}\n${cmd.stderr ?? ""}`.trim();
347
+
348
+ return {
349
+ success: cmd.status === 0,
350
+ output,
351
+ };
352
+ }
353
+
354
+ export function monitorArduinoSketch(port: string, baud: number): void {
355
+ spawnSync("arduino-cli", ["monitor", "--port", port, "--config", `baudrate=${baud}`], {
356
+ stdio: "inherit",
357
+ timeout: 0,
358
+ });
359
+ }
@@ -0,0 +1,33 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/framework-arduino — Arduino Library Utilities
3
+ //
4
+ // Thin re-export barrel. Implementation is split across:
5
+ // - lib-discovery.ts — finding installed libraries via arduino-cli
6
+ // - cpp-parser.ts — parsing C++ headers into an IR
7
+ // - lib-declaration.ts — generating .d.ts declarations + docs
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export type { ArduinoLibrary } from './lib-discovery.js';
11
+ export {
12
+ getInstalledLibraries,
13
+ findArduinoLibrary,
14
+ findLibraryHeader,
15
+ isArduinoLibraryImport,
16
+ clearLibraryCache,
17
+ } from './lib-discovery.js';
18
+
19
+ export type { CppParseResult } from './cpp-parser.js';
20
+ export {
21
+ mapCppTypeToTs,
22
+ parseParameters,
23
+ parseCppClass,
24
+ } from './cpp-parser.js';
25
+
26
+ export type { GeneratedArduinoLib } from './lib-declaration.js';
27
+ export {
28
+ tryGenerateArduinoLibDecl,
29
+ getArduinoLibraryHeaderName,
30
+ getArduinoLibraryClassNames,
31
+ generateUsageDocumentation,
32
+ } from './lib-declaration.js';
33
+
@@ -0,0 +1,250 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Arduino CLI metadata loader
3
+ //
4
+ // Loads board metadata from arduino-cli when available.
5
+ // Uses disk caching to avoid repeated slow arduino-cli calls.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import os from "node:os";
12
+ import type { Diagnostic } from "@typecad/cuttlefish/api/shared";
13
+ import type { ArduinoPlatformContext } from "./strategy.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Disk cache for arduino-cli metadata
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const CACHE_DIR = path.join(os.homedir(), ".TypeCAD", "cache");
20
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
21
+
22
+ interface CachedMetadata {
23
+ fqbn: string;
24
+ metadata: {
25
+ architecture?: string;
26
+ pins: Record<string, number>;
27
+ builtinFunctions: string[]; // Serialized as array
28
+ builtinGlobals: string[]; // Serialized as array
29
+ };
30
+ timestamp: number;
31
+ }
32
+
33
+ function getCachePath(fqbn: string): string {
34
+ const safeFqbn = fqbn.replace(/[:/\\]/g, "_");
35
+ return path.join(CACHE_DIR, `arduino-cli-${safeFqbn}.json`);
36
+ }
37
+
38
+ function loadCachedMetadata(fqbn: string): ArduinoCliMetadata | null {
39
+ try {
40
+ const cachePath = getCachePath(fqbn);
41
+ if (!fs.existsSync(cachePath)) return null;
42
+
43
+ const cached: CachedMetadata = JSON.parse(fs.readFileSync(cachePath, "utf8"));
44
+
45
+ // Check TTL
46
+ if (Date.now() - cached.timestamp > CACHE_TTL_MS) {
47
+ return null; // Expired
48
+ }
49
+
50
+ // Convert arrays back to Sets
51
+ return {
52
+ architecture: cached.metadata.architecture,
53
+ pins: cached.metadata.pins,
54
+ builtinFunctions: new Set(cached.metadata.builtinFunctions),
55
+ builtinGlobals: new Set(cached.metadata.builtinGlobals),
56
+ };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function saveCachedMetadata(fqbn: string, metadata: ArduinoCliMetadata): void {
63
+ try {
64
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
65
+ const cached: CachedMetadata = {
66
+ fqbn,
67
+ metadata: {
68
+ architecture: metadata.architecture,
69
+ pins: metadata.pins,
70
+ builtinFunctions: [...metadata.builtinFunctions], // Convert Set to array
71
+ builtinGlobals: [...metadata.builtinGlobals], // Convert Set to array
72
+ },
73
+ timestamp: Date.now(),
74
+ };
75
+ fs.writeFileSync(getCachePath(fqbn), JSON.stringify(cached));
76
+ } catch {
77
+ // Ignore cache write errors
78
+ }
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Arduino CLI probing
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Extract architecture from FQBN string.
87
+ * FQBN format: vendor:arch:board[:menu=options]
88
+ * e.g., "arduino:avr:uno" -> "avr"
89
+ */
90
+ function toArchitectureFromFqbn(fqbn?: string): string | undefined {
91
+ if (!fqbn) return undefined;
92
+ const parts = fqbn.split(":");
93
+ return parts.length >= 2 ? parts[1] : undefined;
94
+ }
95
+
96
+ export interface ArduinoCliMetadata {
97
+ architecture?: string;
98
+ pins: Record<string, number>;
99
+ builtinFunctions: Set<string>;
100
+ builtinGlobals: Set<string>;
101
+ }
102
+
103
+ function parsePinMap(value: unknown): Record<string, number> {
104
+ const pins: Record<string, number> = {};
105
+
106
+ if (Array.isArray(value)) {
107
+ for (const item of value) {
108
+ if (!item || typeof item !== "object") {
109
+ continue;
110
+ }
111
+ const candidate = item as Record<string, unknown>;
112
+ const name = typeof candidate.name === "string" ? candidate.name : undefined;
113
+ const numeric =
114
+ typeof candidate.value === "number"
115
+ ? candidate.value
116
+ : typeof candidate.value === "string" && !Number.isNaN(Number(candidate.value))
117
+ ? Number(candidate.value)
118
+ : undefined;
119
+ if (name && numeric !== undefined) {
120
+ pins[name] = numeric;
121
+ }
122
+ }
123
+ return pins;
124
+ }
125
+
126
+ if (value && typeof value === "object") {
127
+ for (const [name, raw] of Object.entries(value as Record<string, unknown>)) {
128
+ if (typeof raw === "number") {
129
+ pins[name] = raw;
130
+ } else if (typeof raw === "string" && !Number.isNaN(Number(raw))) {
131
+ pins[name] = Number(raw);
132
+ }
133
+ }
134
+ }
135
+
136
+ return pins;
137
+ }
138
+
139
+ function parseStringSet(value: unknown): Set<string> {
140
+ if (!Array.isArray(value)) {
141
+ return new Set<string>();
142
+ }
143
+
144
+ return new Set(
145
+ value
146
+ .filter((item): item is string => typeof item === "string")
147
+ .map((item) => item.trim())
148
+ .filter((item) => item.length > 0),
149
+ );
150
+ }
151
+
152
+ function normalizeMetadata(raw: unknown, context?: ArduinoPlatformContext): ArduinoCliMetadata {
153
+ const object = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
154
+
155
+ // board details --format json puts architecture under platform.architecture
156
+ const platform = object.platform && typeof object.platform === "object" ? (object.platform as Record<string, unknown>) : {};
157
+ const architecture =
158
+ (typeof platform.architecture === "string" ? platform.architecture.toLowerCase() : undefined) ??
159
+ (typeof object.architecture === "string" ? object.architecture.toLowerCase() : undefined) ??
160
+ (typeof object.arch === "string" ? object.arch.toLowerCase() : undefined) ??
161
+ toArchitectureFromFqbn(context?.buildTarget);
162
+
163
+ const pins = {
164
+ ...parsePinMap(object.pins),
165
+ ...parsePinMap(object.pinMappings),
166
+ };
167
+
168
+ const builtinFunctions = new Set<string>([
169
+ ...parseStringSet(object.builtinFunctions),
170
+ ...parseStringSet(object.functions),
171
+ ]);
172
+
173
+ const builtinGlobals = new Set<string>([
174
+ ...parseStringSet(object.builtinGlobals),
175
+ ...parseStringSet(object.globals),
176
+ ]);
177
+
178
+ return {
179
+ architecture,
180
+ pins,
181
+ builtinFunctions,
182
+ builtinGlobals,
183
+ };
184
+ }
185
+
186
+ function tryArduinoCliProbe(ctx?: ArduinoPlatformContext): { raw?: unknown; diagnostic?: Diagnostic } {
187
+ if (!ctx || !ctx.buildTarget) {
188
+ return {};
189
+ }
190
+
191
+ const cmd = spawnSync("arduino-cli", ["board", "details", "--show-properties", "--format", "json", "--fqbn", ctx.buildTarget], {
192
+ encoding: "utf8",
193
+ timeout: 10000,
194
+ });
195
+
196
+ if (cmd.error || cmd.status !== 0) {
197
+ return {
198
+ diagnostic: {
199
+ severity: "warning",
200
+ code: "TS2CPP_ARDUINO_CLI_PROBE_FAILED",
201
+ message: `Failed to parse arduino-cli properties for ${ctx.buildTarget}`,
202
+ },
203
+ };
204
+ }
205
+
206
+ try {
207
+ return { raw: JSON.parse(cmd.stdout) as unknown };
208
+ } catch {
209
+ return {
210
+ diagnostic: {
211
+ severity: "warning",
212
+ code: "TS2CPP_ARDUINO_CLI_PARSE_FAILED",
213
+ message: "arduino-cli output could not be parsed as JSON; using static profile fallbacks.",
214
+ },
215
+ };
216
+ }
217
+ }
218
+
219
+ export function loadArduinoCliMetadata(ctx?: ArduinoPlatformContext): {
220
+ metadata?: ArduinoCliMetadata;
221
+ diagnostics: Diagnostic[];
222
+ } {
223
+ const diagnostics: Diagnostic[] = [];
224
+ const fqbn = ctx?.buildTarget;
225
+
226
+ if (!fqbn) {
227
+ return { diagnostics };
228
+ }
229
+
230
+ // Try disk cache first
231
+ const cached = loadCachedMetadata(fqbn);
232
+ if (cached) {
233
+ return { metadata: cached, diagnostics };
234
+ }
235
+
236
+ // Cache miss - probe arduino-cli
237
+ const probe = tryArduinoCliProbe(ctx);
238
+ if (probe.diagnostic) {
239
+ diagnostics.push(probe.diagnostic);
240
+ }
241
+
242
+ if (probe.raw) {
243
+ const metadata = normalizeMetadata(probe.raw, ctx);
244
+ // Save to disk cache for future sessions
245
+ saveCachedMetadata(fqbn, metadata);
246
+ return { metadata, diagnostics };
247
+ }
248
+
249
+ return { diagnostics };
250
+ }