From 5d1447707a12b4d812068b06d4eb588df516b662 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 05:58:24 +0000 Subject: [PATCH 1/7] feat: Add GraphQL to C++ code generation for ESP32 firmware Implements a lightweight code generator that parses the GraphQL schema and generates C++ header files with ArduinoJson-compatible structs for the ESP32 firmware. Key components: - generate-graphql-types.mjs: Node.js script that parses schema.ts and generates graphql_types.h with structs, operations, and JSON helpers - prebuild.py: PlatformIO pre-build hook that automatically regenerates types when the schema changes (hash-based change detection) - graphql-types library: New PlatformIO library containing generated types Generated types include: - LedCommand, LedCommandInput (with uint8_t for RGB values) - LedUpdate, ControllerPing, ClimbMatchResult - DeviceLogEntry, SendDeviceLogsResponse - GraphQL operation string constants - JSON parsing/serialization helper functions This provides type safety between the GraphQL backend and ESP32 firmware without heavy dependencies like cppgraphqlgen. https://claude.ai/code/session_01CkAwoY8k1wbNeCMJ14Xfzv --- embedded/libs/graphql-types/library.json | 19 + .../libs/graphql-types/src/graphql_types.h | 232 +++++++++ .../libs/led-controller/src/led_controller.h | 11 +- .../projects/board-controller/platformio.ini | 4 + embedded/scripts/generate-graphql-types.mjs | 441 ++++++++++++++++++ embedded/scripts/prebuild.py | 128 +++++ package.json | 1 + 7 files changed, 827 insertions(+), 9 deletions(-) create mode 100644 embedded/libs/graphql-types/library.json create mode 100644 embedded/libs/graphql-types/src/graphql_types.h create mode 100644 embedded/scripts/generate-graphql-types.mjs create mode 100644 embedded/scripts/prebuild.py diff --git a/embedded/libs/graphql-types/library.json b/embedded/libs/graphql-types/library.json new file mode 100644 index 00000000..f6211dea --- /dev/null +++ b/embedded/libs/graphql-types/library.json @@ -0,0 +1,19 @@ +{ + "name": "graphql-types", + "version": "1.0.0", + "description": "Auto-generated GraphQL types for ESP32 controller firmware", + "keywords": ["graphql", "types", "codegen", "esp32"], + "authors": { + "name": "BoardSesh" + }, + "frameworks": ["arduino"], + "platforms": ["espressif32"], + "dependencies": { + "bblanchon/ArduinoJson": "^7.0.0" + }, + "build": { + "flags": [ + "-I src" + ] + } +} diff --git a/embedded/libs/graphql-types/src/graphql_types.h b/embedded/libs/graphql-types/src/graphql_types.h new file mode 100644 index 00000000..e49708f8 --- /dev/null +++ b/embedded/libs/graphql-types/src/graphql_types.h @@ -0,0 +1,232 @@ +/** + * Auto-generated GraphQL Types for ESP32 Firmware + * + * Generated: 2026-02-03T05:56:00.785Z + * Source: packages/shared-schema/src/schema.ts + * + * DO NOT EDIT MANUALLY - This file is generated by: + * npm run controller:codegen + * + * These types are compatible with ArduinoJson for JSON serialization. + */ + +#ifndef GRAPHQL_TYPES_H +#define GRAPHQL_TYPES_H + +#include +#include +#include + +// ============================================ +// GraphQL Type Constants +// ============================================ + +namespace GraphQLTypename { + constexpr const char* LED_UPDATE = "LedUpdate"; + constexpr const char* CONTROLLER_PING = "ControllerPing"; +} + +// ============================================ +// GraphQL Types for Controller +// ============================================ + +/** + * Output type: LedCommand + * Generated from GraphQL schema + */ +struct LedCommand { + int32_t position; + uint8_t r; + uint8_t g; + uint8_t b; +}; + +/** + * Input type: LedCommandInput + * Generated from GraphQL schema + */ +struct LedCommandInput { + int32_t position; + uint8_t r; + uint8_t g; + uint8_t b; + int32_t role; +}; + +/** + * Output type: LedUpdate + * Generated from GraphQL schema + */ +struct LedUpdate { + LedCommand* commands; + size_t commandsCount; + const char* climbUuid; + const char* climbName; + int32_t angle; +}; + +/** + * Output type: ControllerPing + * Generated from GraphQL schema + */ +struct ControllerPing { + const char* timestamp; +}; + +/** + * Output type: ClimbMatchResult + * Generated from GraphQL schema + */ +struct ClimbMatchResult { + bool matched; + const char* climbUuid; + const char* climbName; +}; + +/** + * Input type: DeviceLogEntry + * Generated from GraphQL schema + */ +struct DeviceLogEntry { + float ts; + const char* level; + const char* component; + const char* message; + const char* metadata; +}; + +/** + * Output type: SendDeviceLogsResponse + * Generated from GraphQL schema + */ +struct SendDeviceLogsResponse { + bool success; + int32_t accepted; +}; + +// // Union type: ControllerEvent = LedUpdate | ControllerPing +// Use __typename field to determine actual type + + +// ============================================ +// GraphQL Operations for ESP32 Controller +// ============================================ + +namespace GraphQLOps { + +/** + * Subscription: Controller Events + * Receives LED updates and ping events from the backend + */ +constexpr const char* CONTROLLER_EVENTS_SUBSCRIPTION = + "subscription ControllerEvents($sessionId: ID!) { " + "controllerEvents(sessionId: $sessionId) { " + "... on LedUpdate { __typename commands { position r g b } climbUuid climbName angle } " + "... on ControllerPing { __typename timestamp } " + "} }"; + +/** + * Mutation: Set Climb From LED Positions + * Sends LED positions from Bluetooth to match a climb + */ +constexpr const char* SET_CLIMB_FROM_LED_POSITIONS = + "mutation SetClimbFromLeds($sessionId: ID!, $positions: [LedCommandInput!]!) { " + "setClimbFromLedPositions(sessionId: $sessionId, positions: $positions) { " + "matched climbUuid climbName } }"; + +/** + * Mutation: Controller Heartbeat + * Keep-alive ping to update lastSeenAt + */ +constexpr const char* CONTROLLER_HEARTBEAT = + "mutation ControllerHeartbeat($sessionId: ID!) { " + "controllerHeartbeat(sessionId: $sessionId) }"; + +/** + * Mutation: Send Device Logs + * Forward device logs to backend for Axiom ingestion + */ +constexpr const char* SEND_DEVICE_LOGS = + "mutation SendDeviceLogs($input: SendDeviceLogsInput!) { " + "sendDeviceLogs(input: $input) { success accepted } }"; + +} // namespace GraphQLOps + +// ============================================ +// JSON Parsing Helpers (ArduinoJson) +// ============================================ + +#include + +/** + * Parse a LedCommand from a JsonObject + */ +inline bool parseLedCommand(JsonObject& obj, LedCommand& cmd) { + if (!obj.containsKey("position")) return false; + cmd.position = obj["position"]; + cmd.r = obj["r"] | 0; + cmd.g = obj["g"] | 0; + cmd.b = obj["b"] | 0; + return true; +} + +/** + * Parse a LedUpdate from a JsonObject + * Note: Caller must free commands array when done + */ +inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { + JsonArray commands = obj["commands"]; + if (commands.isNull()) { + update.commands = nullptr; + update.commandsCount = 0; + } else { + update.commandsCount = commands.size(); + update.commands = new LedCommand[update.commandsCount]; + size_t i = 0; + for (JsonObject cmd : commands) { + parseLedCommand(cmd, update.commands[i++]); + } + } + update.climbUuid = obj["climbUuid"] | nullptr; + update.climbName = obj["climbName"] | nullptr; + update.angle = obj["angle"] | 0; + return true; +} + +/** + * Parse a ClimbMatchResult from a JsonObject + */ +inline bool parseClimbMatchResult(JsonObject& obj, ClimbMatchResult& result) { + result.matched = obj["matched"] | false; + result.climbUuid = obj["climbUuid"] | nullptr; + result.climbName = obj["climbName"] | nullptr; + return true; +} + +/** + * Serialize a LedCommandInput to a JsonObject + */ +inline void serializeLedCommandInput(JsonObject& obj, const LedCommandInput& cmd) { + obj["position"] = cmd.position; + obj["r"] = cmd.r; + obj["g"] = cmd.g; + obj["b"] = cmd.b; + if (cmd.role >= 0) { + obj["role"] = cmd.role; + } +} + +/** + * Serialize a DeviceLogEntry to a JsonObject + */ +inline void serializeDeviceLogEntry(JsonObject& obj, const DeviceLogEntry& entry) { + obj["ts"] = entry.ts; + obj["level"] = entry.level; + obj["component"] = entry.component; + obj["message"] = entry.message; + if (entry.metadata) { + obj["metadata"] = entry.metadata; + } +} + +#endif // GRAPHQL_TYPES_H diff --git a/embedded/libs/led-controller/src/led_controller.h b/embedded/libs/led-controller/src/led_controller.h index 5ae6536b..be291e85 100644 --- a/embedded/libs/led-controller/src/led_controller.h +++ b/embedded/libs/led-controller/src/led_controller.h @@ -3,18 +3,11 @@ #include #include +#include // Generated GraphQL types (includes LedCommand) #define MAX_LEDS 500 -/** - * LED command structure matching GraphQL LedCommand type - */ -struct LedCommand { - int position; - uint8_t r; - uint8_t g; - uint8_t b; -}; +// LedCommand is now defined in graphql_types.h (auto-generated from GraphQL schema) class LedController { public: diff --git a/embedded/projects/board-controller/platformio.ini b/embedded/projects/board-controller/platformio.ini index 2dd9b936..3a13ef14 100644 --- a/embedded/projects/board-controller/platformio.ini +++ b/embedded/projects/board-controller/platformio.ini @@ -22,6 +22,7 @@ lib_deps = graphql-ws-client=symlink://../../libs/graphql-ws-client nordic-uart-ble=symlink://../../libs/nordic-uart-ble esp-web-server=symlink://../../libs/esp-web-server + graphql-types=symlink://../../libs/graphql-types ; External libraries from PlatformIO registry fastled/FastLED@^3.6.0 bblanchon/ArduinoJson@^7.0.0 @@ -29,6 +30,9 @@ lib_deps = tzapu/WiFiManager@^2.0.17 h2zero/NimBLE-Arduino@^1.4.1 +; Pre-build script for GraphQL type generation +extra_scripts = pre:../../scripts/prebuild.py + build_flags = -D CORE_DEBUG_LEVEL=3 -D CONFIG_NIMBLE_CPP_ATT_VALUE_INIT_LENGTH=512 diff --git a/embedded/scripts/generate-graphql-types.mjs b/embedded/scripts/generate-graphql-types.mjs new file mode 100644 index 00000000..587ef29b --- /dev/null +++ b/embedded/scripts/generate-graphql-types.mjs @@ -0,0 +1,441 @@ +#!/usr/bin/env node +/** + * GraphQL to C++ Type Generator for ESP32 Firmware + * + * This script reads the GraphQL schema from packages/shared-schema and generates + * C++ header files with ArduinoJson-compatible structs for the ESP32 firmware. + * + * Usage: + * node embedded/scripts/generate-graphql-types.mjs + * + * Or via npm script: + * npm run controller:codegen + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +// ESM-compatible __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Path configuration +const SCHEMA_PATH = path.join(__dirname, '../../packages/shared-schema/src/schema.ts'); +const OUTPUT_DIR = path.join(__dirname, '../libs/graphql-types/src'); +const OUTPUT_FILE = path.join(OUTPUT_DIR, 'graphql_types.h'); + +// Types that the firmware needs (controller-relevant subset) +const CONTROLLER_TYPES = [ + 'LedCommand', + 'LedCommandInput', + 'LedUpdate', + 'ControllerPing', + 'ControllerEvent', + 'ClimbMatchResult', + 'DeviceLogEntry', + 'SendDeviceLogsInput', + 'SendDeviceLogsResponse', +]; + +// GraphQL to C++ type mapping +const TYPE_MAP = { + 'Int': 'int32_t', + 'Int!': 'int32_t', + 'Float': 'float', + 'Float!': 'float', + 'String': 'const char*', + 'String!': 'const char*', + 'Boolean': 'bool', + 'Boolean!': 'bool', + 'ID': 'const char*', + 'ID!': 'const char*', +}; + +// Field-specific type overrides for embedded optimization +// These override the default mappings for specific fields +const FIELD_TYPE_OVERRIDES = { + 'LedCommand.r': 'uint8_t', + 'LedCommand.g': 'uint8_t', + 'LedCommand.b': 'uint8_t', + 'LedCommandInput.r': 'uint8_t', + 'LedCommandInput.g': 'uint8_t', + 'LedCommandInput.b': 'uint8_t', +}; + +/** + * Parse GraphQL schema and extract types + * @param {string} schemaContent + * @returns {Map} + */ +function parseGraphQLSchema(schemaContent) { + const types = new Map(); + + // Remove doc comments for easier parsing + const cleanSchema = schemaContent.replace(/"""[\s\S]*?"""/g, ''); + + // Parse type definitions + const typeRegex = /(type|input)\s+(\w+)\s*\{([^}]+)\}/g; + let match; + + while ((match = typeRegex.exec(cleanSchema)) !== null) { + const kind = match[1]; + const name = match[2]; + const body = match[3]; + + if (!CONTROLLER_TYPES.includes(name)) continue; + + const fields = []; + const fieldRegex = /(\w+)\s*:\s*(\[?\w+!?\]?!?)/g; + let fieldMatch; + + while ((fieldMatch = fieldRegex.exec(body)) !== null) { + const fieldName = fieldMatch[1]; + let fieldType = fieldMatch[2]; + + const isArray = fieldType.startsWith('['); + const isNullable = !fieldType.endsWith('!'); + + // Clean up type + fieldType = fieldType.replace(/[\[\]!]/g, ''); + + fields.push({ + name: fieldName, + type: fieldType, + isNullable, + isArray, + }); + } + + types.set(name, { name, kind, fields }); + } + + // Parse union types + const unionRegex = /union\s+(\w+)\s*=\s*([^#\n]+)/g; + while ((match = unionRegex.exec(cleanSchema)) !== null) { + const name = match[1]; + const unionBody = match[2].trim(); + + if (!CONTROLLER_TYPES.includes(name)) continue; + + const unionTypes = unionBody.split('|').map(t => t.trim()); + types.set(name, { + name, + kind: 'union', + fields: [], + unionTypes, + }); + } + + return types; +} + +/** + * Convert GraphQL type to C++ type + * @param {string} graphqlType + * @param {boolean} isNullable + * @param {boolean} isArray + * @returns {string} + */ +function graphqlTypeToCpp(graphqlType, isNullable, isArray) { + // Check if it's a known scalar type + const key = isNullable ? graphqlType : `${graphqlType}!`; + if (TYPE_MAP[key]) { + return TYPE_MAP[key]; + } + if (TYPE_MAP[graphqlType]) { + return TYPE_MAP[graphqlType]; + } + + // For custom types, return struct name + return graphqlType; +} + +/** + * Generate C++ struct for a GraphQL type + * @param {object} type + * @returns {string} + */ +function generateCppStruct(type) { + if (type.kind === 'union') { + return `// Union type: ${type.name} = ${type.unionTypes?.join(' | ')} +// Use __typename field to determine actual type`; + } + + const lines = []; + lines.push(`/**`); + lines.push(` * ${type.kind === 'input' ? 'Input' : 'Output'} type: ${type.name}`); + lines.push(` * Generated from GraphQL schema`); + lines.push(` */`); + lines.push(`struct ${type.name} {`); + + for (const field of type.fields) { + // Check for field-specific type override + const overrideKey = `${type.name}.${field.name}`; + let cppType = FIELD_TYPE_OVERRIDES[overrideKey]; + + if (!cppType) { + cppType = graphqlTypeToCpp(field.type, field.isNullable, field.isArray); + } + + if (field.isArray) { + // For arrays, we need a pointer and count + lines.push(` ${cppType}* ${field.name};`); + lines.push(` size_t ${field.name}Count;`); + } else { + lines.push(` ${cppType} ${field.name};`); + } + } + + lines.push(`};`); + return lines.join('\n'); +} + +/** + * Generate GraphQL operation strings + * @returns {string} + */ +function generateOperations() { + return ` +// ============================================ +// GraphQL Operations for ESP32 Controller +// ============================================ + +namespace GraphQLOps { + +/** + * Subscription: Controller Events + * Receives LED updates and ping events from the backend + */ +constexpr const char* CONTROLLER_EVENTS_SUBSCRIPTION = + "subscription ControllerEvents($sessionId: ID!) { " + "controllerEvents(sessionId: $sessionId) { " + "... on LedUpdate { __typename commands { position r g b } climbUuid climbName angle } " + "... on ControllerPing { __typename timestamp } " + "} }"; + +/** + * Mutation: Set Climb From LED Positions + * Sends LED positions from Bluetooth to match a climb + */ +constexpr const char* SET_CLIMB_FROM_LED_POSITIONS = + "mutation SetClimbFromLeds($sessionId: ID!, $positions: [LedCommandInput!]!) { " + "setClimbFromLedPositions(sessionId: $sessionId, positions: $positions) { " + "matched climbUuid climbName } }"; + +/** + * Mutation: Controller Heartbeat + * Keep-alive ping to update lastSeenAt + */ +constexpr const char* CONTROLLER_HEARTBEAT = + "mutation ControllerHeartbeat($sessionId: ID!) { " + "controllerHeartbeat(sessionId: $sessionId) }"; + +/** + * Mutation: Send Device Logs + * Forward device logs to backend for Axiom ingestion + */ +constexpr const char* SEND_DEVICE_LOGS = + "mutation SendDeviceLogs($input: SendDeviceLogsInput!) { " + "sendDeviceLogs(input: $input) { success accepted } }"; + +} // namespace GraphQLOps +`; +} + +/** + * Generate the complete C++ header file + * @param {Map} types + * @returns {string} + */ +function generateHeader(types) { + const timestamp = new Date().toISOString(); + + let content = `/** + * Auto-generated GraphQL Types for ESP32 Firmware + * + * Generated: ${timestamp} + * Source: packages/shared-schema/src/schema.ts + * + * DO NOT EDIT MANUALLY - This file is generated by: + * npm run controller:codegen + * + * These types are compatible with ArduinoJson for JSON serialization. + */ + +#ifndef GRAPHQL_TYPES_H +#define GRAPHQL_TYPES_H + +#include +#include +#include + +// ============================================ +// GraphQL Type Constants +// ============================================ + +namespace GraphQLTypename { + constexpr const char* LED_UPDATE = "LedUpdate"; + constexpr const char* CONTROLLER_PING = "ControllerPing"; +} + +// ============================================ +// GraphQL Types for Controller +// ============================================ + +`; + + // Generate structs in dependency order + const orderedTypes = [ + 'LedCommand', + 'LedCommandInput', + 'LedUpdate', + 'ControllerPing', + 'ClimbMatchResult', + 'DeviceLogEntry', + 'SendDeviceLogsResponse', + ]; + + for (const typeName of orderedTypes) { + const type = types.get(typeName); + if (type) { + content += generateCppStruct(type) + '\n\n'; + } + } + + // Add union comment + const controllerEvent = types.get('ControllerEvent'); + if (controllerEvent) { + content += `// ${generateCppStruct(controllerEvent)}\n\n`; + } + + // Add operations + content += generateOperations(); + + // Add helper functions for JSON parsing + content += ` +// ============================================ +// JSON Parsing Helpers (ArduinoJson) +// ============================================ + +#include + +/** + * Parse a LedCommand from a JsonObject + */ +inline bool parseLedCommand(JsonObject& obj, LedCommand& cmd) { + if (!obj.containsKey("position")) return false; + cmd.position = obj["position"]; + cmd.r = obj["r"] | 0; + cmd.g = obj["g"] | 0; + cmd.b = obj["b"] | 0; + return true; +} + +/** + * Parse a LedUpdate from a JsonObject + * Note: Caller must free commands array when done + */ +inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { + JsonArray commands = obj["commands"]; + if (commands.isNull()) { + update.commands = nullptr; + update.commandsCount = 0; + } else { + update.commandsCount = commands.size(); + update.commands = new LedCommand[update.commandsCount]; + size_t i = 0; + for (JsonObject cmd : commands) { + parseLedCommand(cmd, update.commands[i++]); + } + } + update.climbUuid = obj["climbUuid"] | nullptr; + update.climbName = obj["climbName"] | nullptr; + update.angle = obj["angle"] | 0; + return true; +} + +/** + * Parse a ClimbMatchResult from a JsonObject + */ +inline bool parseClimbMatchResult(JsonObject& obj, ClimbMatchResult& result) { + result.matched = obj["matched"] | false; + result.climbUuid = obj["climbUuid"] | nullptr; + result.climbName = obj["climbName"] | nullptr; + return true; +} + +/** + * Serialize a LedCommandInput to a JsonObject + */ +inline void serializeLedCommandInput(JsonObject& obj, const LedCommandInput& cmd) { + obj["position"] = cmd.position; + obj["r"] = cmd.r; + obj["g"] = cmd.g; + obj["b"] = cmd.b; + if (cmd.role >= 0) { + obj["role"] = cmd.role; + } +} + +/** + * Serialize a DeviceLogEntry to a JsonObject + */ +inline void serializeDeviceLogEntry(JsonObject& obj, const DeviceLogEntry& entry) { + obj["ts"] = entry.ts; + obj["level"] = entry.level; + obj["component"] = entry.component; + obj["message"] = entry.message; + if (entry.metadata) { + obj["metadata"] = entry.metadata; + } +} + +#endif // GRAPHQL_TYPES_H +`; + + return content; +} + +async function main() { + console.log('GraphQL to C++ Type Generator'); + console.log('==============================\n'); + + // Read schema + console.log(`Reading schema from: ${SCHEMA_PATH}`); + if (!fs.existsSync(SCHEMA_PATH)) { + console.error(`Error: Schema file not found at ${SCHEMA_PATH}`); + process.exit(1); + } + + const schemaContent = fs.readFileSync(SCHEMA_PATH, 'utf-8'); + + // Parse schema + console.log('Parsing GraphQL schema...'); + const types = parseGraphQLSchema(schemaContent); + console.log(`Found ${types.size} controller-relevant types`); + + for (const [name, type] of types) { + console.log(` - ${name} (${type.kind}, ${type.fields.length} fields)`); + } + + // Generate header + console.log('\nGenerating C++ header...'); + const header = generateHeader(types); + + // Ensure output directory exists + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + // Write output + fs.writeFileSync(OUTPUT_FILE, header); + console.log(`Written to: ${OUTPUT_FILE}`); + + // Show stats + const lineCount = header.split('\n').length; + console.log(`\nGenerated ${lineCount} lines of C++ code`); + console.log('\nDone!'); +} + +main().catch(console.error); diff --git a/embedded/scripts/prebuild.py b/embedded/scripts/prebuild.py new file mode 100644 index 00000000..9861d198 --- /dev/null +++ b/embedded/scripts/prebuild.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +PlatformIO Pre-Build Script for GraphQL Type Generation + +This script runs before each build to ensure GraphQL types are up-to-date. +It checks if the schema has changed and regenerates types if needed. + +Usage in platformio.ini: + extra_scripts = pre:../scripts/prebuild.py +""" + +import os +import sys +import subprocess +import hashlib +from pathlib import Path + +Import("env") + +# Paths +SCRIPT_DIR = Path(__file__).parent +PROJECT_ROOT = SCRIPT_DIR.parent.parent +SCHEMA_PATH = PROJECT_ROOT / "packages" / "shared-schema" / "src" / "schema.ts" +TYPES_PATH = PROJECT_ROOT / "packages" / "shared-schema" / "src" / "types.ts" +OUTPUT_PATH = SCRIPT_DIR.parent / "libs" / "graphql-types" / "src" / "graphql_types.h" +HASH_FILE = SCRIPT_DIR.parent / "libs" / "graphql-types" / ".schema_hash" +CODEGEN_SCRIPT = SCRIPT_DIR / "generate-graphql-types.mjs" + + +def get_file_hash(filepath: Path) -> str: + """Calculate MD5 hash of a file.""" + if not filepath.exists(): + return "" + with open(filepath, "rb") as f: + return hashlib.md5(f.read()).hexdigest() + + +def get_combined_hash() -> str: + """Get combined hash of schema and types files.""" + schema_hash = get_file_hash(SCHEMA_PATH) + types_hash = get_file_hash(TYPES_PATH) + return hashlib.md5(f"{schema_hash}{types_hash}".encode()).hexdigest() + + +def get_stored_hash() -> str: + """Read previously stored hash.""" + if HASH_FILE.exists(): + return HASH_FILE.read_text().strip() + return "" + + +def store_hash(hash_value: str): + """Store hash for future comparison.""" + HASH_FILE.parent.mkdir(parents=True, exist_ok=True) + HASH_FILE.write_text(hash_value) + + +def run_codegen(): + """Run the JavaScript code generator.""" + print("=" * 60) + print("GraphQL Codegen: Generating C++ types from schema...") + print("=" * 60) + + try: + # Run with node (ES module) + result = subprocess.run( + ["node", str(CODEGEN_SCRIPT)], + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + print(f"Error running codegen:\n{result.stderr}") + # Don't fail the build, just warn + print("Warning: GraphQL codegen failed, using existing types") + return False + + print(result.stdout) + return True + + except FileNotFoundError: + print("Warning: Node.js not found. Skipping GraphQL codegen.") + print("Install Node.js to enable automatic type generation.") + return False + except subprocess.TimeoutExpired: + print("Warning: GraphQL codegen timed out") + return False + except Exception as e: + print(f"Warning: GraphQL codegen error: {e}") + return False + + +def before_build(source, target, env): + """Pre-build hook to check and regenerate types if needed.""" + print("\n[GraphQL Codegen] Checking if types need regeneration...") + + # Check if schema exists + if not SCHEMA_PATH.exists(): + print(f"[GraphQL Codegen] Schema not found at {SCHEMA_PATH}") + print("[GraphQL Codegen] Skipping codegen (schema not available)") + return + + # Check if output exists + if not OUTPUT_PATH.exists(): + print("[GraphQL Codegen] Output file missing, generating...") + if run_codegen(): + store_hash(get_combined_hash()) + return + + # Check if schema has changed + current_hash = get_combined_hash() + stored_hash = get_stored_hash() + + if current_hash != stored_hash: + print("[GraphQL Codegen] Schema changed, regenerating types...") + if run_codegen(): + store_hash(current_hash) + else: + print("[GraphQL Codegen] Types are up-to-date") + + +# Register the pre-build action +env.AddPreAction("buildprog", before_build) + +# Also run on first build (library build) +env.AddPreAction("$BUILD_DIR/src/main.cpp.o", before_build) diff --git a/package.json b/package.json index fb28ca9d..e76b801a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "db:setup": "docker-compose up db_setup", "db:migrate": "npm run db:migrate --workspace=@boardsesh/db", "db:studio": "npm run db:studio --workspace=@boardsesh/db", + "controller:codegen": "node embedded/scripts/generate-graphql-types.mjs", "controller:build": "cd embedded/projects/board-controller && pio run", "controller:upload": "cd embedded/projects/board-controller && pio run -t upload", "controller:monitor": "cd embedded/projects/board-controller && pio device monitor" From 30dfdb749cdffeacb0c24d1e3bbd66c1ec3e993d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 12:24:29 +0000 Subject: [PATCH 2/7] test: Add unit tests for GraphQL codegen and prebuild scripts Adds comprehensive test suites for the code generation system: Node.js tests (21 tests): - Schema parsing for types, inputs, unions, arrays - GraphQL to C++ type mapping - C++ struct generation with field overrides - Integration tests with real schema files - Edge cases (empty schema, missing types) Python tests (9 tests): - Hash calculation for file change detection - Combined hash for multiple files - Hash storage and retrieval - Integration with real schema files Run all tests with: npm run controller:codegen:test https://claude.ai/code/session_01CkAwoY8k1wbNeCMJ14Xfzv --- .../scripts/generate-graphql-types.test.mjs | 469 ++++++++++++++++++ embedded/scripts/test_prebuild.py | 167 +++++++ package.json | 1 + 3 files changed, 637 insertions(+) create mode 100644 embedded/scripts/generate-graphql-types.test.mjs create mode 100644 embedded/scripts/test_prebuild.py diff --git a/embedded/scripts/generate-graphql-types.test.mjs b/embedded/scripts/generate-graphql-types.test.mjs new file mode 100644 index 00000000..bc7dfb0f --- /dev/null +++ b/embedded/scripts/generate-graphql-types.test.mjs @@ -0,0 +1,469 @@ +#!/usr/bin/env node +/** + * Unit tests for GraphQL to C++ Type Generator + * + * Run with: node --test embedded/scripts/generate-graphql-types.test.mjs + */ + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Import functions to test by extracting them from the module +// Since the module runs main() on import, we need to re-implement the core functions for testing + +// GraphQL to C++ type mapping (copied from source for testing) +const TYPE_MAP = { + 'Int': 'int32_t', + 'Int!': 'int32_t', + 'Float': 'float', + 'Float!': 'float', + 'String': 'const char*', + 'String!': 'const char*', + 'Boolean': 'bool', + 'Boolean!': 'bool', + 'ID': 'const char*', + 'ID!': 'const char*', +}; + +const FIELD_TYPE_OVERRIDES = { + 'LedCommand.r': 'uint8_t', + 'LedCommand.g': 'uint8_t', + 'LedCommand.b': 'uint8_t', + 'LedCommandInput.r': 'uint8_t', + 'LedCommandInput.g': 'uint8_t', + 'LedCommandInput.b': 'uint8_t', +}; + +const CONTROLLER_TYPES = [ + 'LedCommand', + 'LedCommandInput', + 'LedUpdate', + 'ControllerPing', + 'ControllerEvent', + 'ClimbMatchResult', + 'DeviceLogEntry', + 'SendDeviceLogsInput', + 'SendDeviceLogsResponse', +]; + +function parseGraphQLSchema(schemaContent) { + const types = new Map(); + const cleanSchema = schemaContent.replace(/"""[\s\S]*?"""/g, ''); + const typeRegex = /(type|input)\s+(\w+)\s*\{([^}]+)\}/g; + let match; + + while ((match = typeRegex.exec(cleanSchema)) !== null) { + const kind = match[1]; + const name = match[2]; + const body = match[3]; + + if (!CONTROLLER_TYPES.includes(name)) continue; + + const fields = []; + const fieldRegex = /(\w+)\s*:\s*(\[?\w+!?\]?!?)/g; + let fieldMatch; + + while ((fieldMatch = fieldRegex.exec(body)) !== null) { + const fieldName = fieldMatch[1]; + let fieldType = fieldMatch[2]; + + const isArray = fieldType.startsWith('['); + const isNullable = !fieldType.endsWith('!'); + fieldType = fieldType.replace(/[\[\]!]/g, ''); + + fields.push({ + name: fieldName, + type: fieldType, + isNullable, + isArray, + }); + } + + types.set(name, { name, kind, fields }); + } + + const unionRegex = /union\s+(\w+)\s*=\s*([^#\n]+)/g; + while ((match = unionRegex.exec(cleanSchema)) !== null) { + const name = match[1]; + const unionBody = match[2].trim(); + + if (!CONTROLLER_TYPES.includes(name)) continue; + + const unionTypes = unionBody.split('|').map(t => t.trim()); + types.set(name, { + name, + kind: 'union', + fields: [], + unionTypes, + }); + } + + return types; +} + +function graphqlTypeToCpp(graphqlType, isNullable, isArray) { + const key = isNullable ? graphqlType : `${graphqlType}!`; + if (TYPE_MAP[key]) return TYPE_MAP[key]; + if (TYPE_MAP[graphqlType]) return TYPE_MAP[graphqlType]; + return graphqlType; +} + +function generateCppStruct(type) { + if (type.kind === 'union') { + return `// Union type: ${type.name} = ${type.unionTypes?.join(' | ')}\n// Use __typename field to determine actual type`; + } + + const lines = []; + lines.push(`struct ${type.name} {`); + + for (const field of type.fields) { + const overrideKey = `${type.name}.${field.name}`; + let cppType = FIELD_TYPE_OVERRIDES[overrideKey]; + + if (!cppType) { + cppType = graphqlTypeToCpp(field.type, field.isNullable, field.isArray); + } + + if (field.isArray) { + lines.push(` ${cppType}* ${field.name};`); + lines.push(` size_t ${field.name}Count;`); + } else { + lines.push(` ${cppType} ${field.name};`); + } + } + + lines.push(`};`); + return lines.join('\n'); +} + +// ============================================ +// Tests +// ============================================ + +describe('GraphQL Schema Parser', () => { + it('should parse a simple type definition', () => { + const schema = ` + type LedCommand { + position: Int! + r: Int! + g: Int! + b: Int! + } + `; + + const types = parseGraphQLSchema(schema); + assert.strictEqual(types.size, 1); + assert.ok(types.has('LedCommand')); + + const ledCommand = types.get('LedCommand'); + assert.strictEqual(ledCommand.kind, 'type'); + assert.strictEqual(ledCommand.fields.length, 4); + assert.strictEqual(ledCommand.fields[0].name, 'position'); + assert.strictEqual(ledCommand.fields[0].type, 'Int'); + assert.strictEqual(ledCommand.fields[0].isNullable, false); + }); + + it('should parse input type definitions', () => { + const schema = ` + input LedCommandInput { + position: Int! + r: Int! + g: Int! + b: Int! + role: Int + } + `; + + const types = parseGraphQLSchema(schema); + const input = types.get('LedCommandInput'); + + assert.strictEqual(input.kind, 'input'); + assert.strictEqual(input.fields.length, 5); + + const roleField = input.fields.find(f => f.name === 'role'); + assert.strictEqual(roleField.isNullable, true); + }); + + it('should parse array fields', () => { + const schema = ` + type LedUpdate { + commands: [LedCommand!]! + climbUuid: String + climbName: String + angle: Int! + } + `; + + const types = parseGraphQLSchema(schema); + const update = types.get('LedUpdate'); + + const commandsField = update.fields.find(f => f.name === 'commands'); + assert.strictEqual(commandsField.isArray, true); + assert.strictEqual(commandsField.type, 'LedCommand'); + }); + + it('should parse union types', () => { + const schema = ` + type LedUpdate { + commands: [LedCommand!]! + } + + type ControllerPing { + timestamp: String! + } + + union ControllerEvent = LedUpdate | ControllerPing + `; + + const types = parseGraphQLSchema(schema); + const union = types.get('ControllerEvent'); + + assert.strictEqual(union.kind, 'union'); + assert.deepStrictEqual(union.unionTypes, ['LedUpdate', 'ControllerPing']); + }); + + it('should skip non-controller types', () => { + const schema = ` + type User { + id: ID! + name: String! + } + + type LedCommand { + position: Int! + } + `; + + const types = parseGraphQLSchema(schema); + assert.strictEqual(types.size, 1); + assert.ok(types.has('LedCommand')); + assert.ok(!types.has('User')); + }); + + it('should handle doc comments', () => { + const schema = ` + """ + This is a doc comment that should be ignored + """ + type LedCommand { + """Position of the LED""" + position: Int! + } + `; + + const types = parseGraphQLSchema(schema); + assert.ok(types.has('LedCommand')); + assert.strictEqual(types.get('LedCommand').fields.length, 1); + }); +}); + +describe('GraphQL to C++ Type Mapping', () => { + it('should map Int to int32_t', () => { + assert.strictEqual(graphqlTypeToCpp('Int', false, false), 'int32_t'); + assert.strictEqual(graphqlTypeToCpp('Int', true, false), 'int32_t'); + }); + + it('should map String to const char*', () => { + assert.strictEqual(graphqlTypeToCpp('String', false, false), 'const char*'); + }); + + it('should map Boolean to bool', () => { + assert.strictEqual(graphqlTypeToCpp('Boolean', false, false), 'bool'); + }); + + it('should map Float to float', () => { + assert.strictEqual(graphqlTypeToCpp('Float', false, false), 'float'); + }); + + it('should map ID to const char*', () => { + assert.strictEqual(graphqlTypeToCpp('ID', false, false), 'const char*'); + }); + + it('should pass through custom types', () => { + assert.strictEqual(graphqlTypeToCpp('LedCommand', false, false), 'LedCommand'); + assert.strictEqual(graphqlTypeToCpp('CustomType', false, false), 'CustomType'); + }); +}); + +describe('C++ Struct Generation', () => { + it('should generate a simple struct', () => { + const type = { + name: 'ControllerPing', + kind: 'type', + fields: [ + { name: 'timestamp', type: 'String', isNullable: false, isArray: false } + ] + }; + + const output = generateCppStruct(type); + assert.ok(output.includes('struct ControllerPing {')); + assert.ok(output.includes('const char* timestamp;')); + assert.ok(output.includes('};')); + }); + + it('should apply field type overrides for RGB values', () => { + const type = { + name: 'LedCommand', + kind: 'type', + fields: [ + { name: 'position', type: 'Int', isNullable: false, isArray: false }, + { name: 'r', type: 'Int', isNullable: false, isArray: false }, + { name: 'g', type: 'Int', isNullable: false, isArray: false }, + { name: 'b', type: 'Int', isNullable: false, isArray: false }, + ] + }; + + const output = generateCppStruct(type); + assert.ok(output.includes('int32_t position;')); + assert.ok(output.includes('uint8_t r;')); + assert.ok(output.includes('uint8_t g;')); + assert.ok(output.includes('uint8_t b;')); + }); + + it('should generate array fields with count', () => { + const type = { + name: 'LedUpdate', + kind: 'type', + fields: [ + { name: 'commands', type: 'LedCommand', isNullable: false, isArray: true }, + ] + }; + + const output = generateCppStruct(type); + assert.ok(output.includes('LedCommand* commands;')); + assert.ok(output.includes('size_t commandsCount;')); + }); + + it('should generate union type comment', () => { + const type = { + name: 'ControllerEvent', + kind: 'union', + fields: [], + unionTypes: ['LedUpdate', 'ControllerPing'] + }; + + const output = generateCppStruct(type); + assert.ok(output.includes('Union type: ControllerEvent')); + assert.ok(output.includes('LedUpdate | ControllerPing')); + assert.ok(output.includes('__typename')); + }); +}); + +describe('Integration: Parse and Generate', () => { + it('should parse real schema and generate valid C++ for LedCommand', () => { + const schemaPath = path.join(__dirname, '../../packages/shared-schema/src/schema.ts'); + + if (!fs.existsSync(schemaPath)) { + console.log('Skipping integration test: schema file not found'); + return; + } + + const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); + const types = parseGraphQLSchema(schemaContent); + + // Verify we found the expected types + assert.ok(types.has('LedCommand'), 'Should have LedCommand'); + assert.ok(types.has('LedUpdate'), 'Should have LedUpdate'); + assert.ok(types.has('ControllerPing'), 'Should have ControllerPing'); + assert.ok(types.has('ControllerEvent'), 'Should have ControllerEvent'); + + // Verify LedCommand structure + const ledCommand = types.get('LedCommand'); + assert.strictEqual(ledCommand.fields.length, 4); + + const posField = ledCommand.fields.find(f => f.name === 'position'); + assert.ok(posField, 'Should have position field'); + + // Generate C++ and verify it's syntactically reasonable + const cppOutput = generateCppStruct(ledCommand); + assert.ok(cppOutput.includes('struct LedCommand'), 'Should have struct definition'); + assert.ok(cppOutput.includes('uint8_t r;'), 'Should use uint8_t for r'); + assert.ok(cppOutput.includes('};'), 'Should close struct'); + }); + + it('should generate valid output file', () => { + const outputPath = path.join(__dirname, '../libs/graphql-types/src/graphql_types.h'); + + if (!fs.existsSync(outputPath)) { + console.log('Skipping output file test: generated file not found'); + return; + } + + const content = fs.readFileSync(outputPath, 'utf-8'); + + // Verify header guards + assert.ok(content.includes('#ifndef GRAPHQL_TYPES_H'), 'Should have include guard'); + assert.ok(content.includes('#define GRAPHQL_TYPES_H'), 'Should have define guard'); + assert.ok(content.includes('#endif'), 'Should close include guard'); + + // Verify required includes + assert.ok(content.includes('#include '), 'Should include Arduino.h'); + assert.ok(content.includes('#include '), 'Should include ArduinoJson.h'); + + // Verify structs are generated + assert.ok(content.includes('struct LedCommand'), 'Should have LedCommand struct'); + assert.ok(content.includes('struct LedUpdate'), 'Should have LedUpdate struct'); + assert.ok(content.includes('struct ControllerPing'), 'Should have ControllerPing struct'); + + // Verify operations namespace + assert.ok(content.includes('namespace GraphQLOps'), 'Should have GraphQLOps namespace'); + assert.ok(content.includes('CONTROLLER_EVENTS_SUBSCRIPTION'), 'Should have subscription'); + + // Verify helper functions + assert.ok(content.includes('inline bool parseLedCommand'), 'Should have parse helper'); + assert.ok(content.includes('inline bool parseLedUpdate'), 'Should have parse helper'); + + // Verify uint8_t is used for RGB + assert.ok(content.includes('uint8_t r;'), 'Should use uint8_t for red'); + assert.ok(content.includes('uint8_t g;'), 'Should use uint8_t for green'); + assert.ok(content.includes('uint8_t b;'), 'Should use uint8_t for blue'); + }); +}); + +describe('Edge Cases', () => { + it('should handle empty schema', () => { + const types = parseGraphQLSchema(''); + assert.strictEqual(types.size, 0); + }); + + it('should handle schema with only non-controller types', () => { + const schema = ` + type User { + id: ID! + } + type Session { + id: ID! + } + `; + const types = parseGraphQLSchema(schema); + assert.strictEqual(types.size, 0); + }); + + it('should handle nullable fields correctly', () => { + const schema = ` + type ClimbMatchResult { + matched: Boolean! + climbUuid: String + climbName: String + } + `; + + const types = parseGraphQLSchema(schema); + const result = types.get('ClimbMatchResult'); + + const matched = result.fields.find(f => f.name === 'matched'); + const climbUuid = result.fields.find(f => f.name === 'climbUuid'); + + assert.strictEqual(matched.isNullable, false); + assert.strictEqual(climbUuid.isNullable, true); + }); +}); + +// Run summary +console.log('\n✅ All tests defined. Run with: node --test embedded/scripts/generate-graphql-types.test.mjs\n'); diff --git a/embedded/scripts/test_prebuild.py b/embedded/scripts/test_prebuild.py new file mode 100644 index 00000000..c988d73c --- /dev/null +++ b/embedded/scripts/test_prebuild.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Unit tests for prebuild.py hash-based caching logic. + +Run with: python3 embedded/scripts/test_prebuild.py +""" + +import hashlib +import tempfile +import unittest +from pathlib import Path + + +def get_file_hash(filepath: Path) -> str: + """Calculate MD5 hash of a file.""" + if not filepath.exists(): + return "" + with open(filepath, "rb") as f: + return hashlib.md5(f.read()).hexdigest() + + +def get_combined_hash(schema_path: Path, types_path: Path) -> str: + """Get combined hash of schema and types files.""" + schema_hash = get_file_hash(schema_path) + types_hash = get_file_hash(types_path) + return hashlib.md5(f"{schema_hash}{types_hash}".encode()).hexdigest() + + +class TestHashFunctions(unittest.TestCase): + """Test hash calculation functions.""" + + def test_get_file_hash_nonexistent(self): + """Should return empty string for non-existent file.""" + result = get_file_hash(Path("/nonexistent/file.txt")) + self.assertEqual(result, "") + + def test_get_file_hash_existing(self): + """Should return consistent hash for same content.""" + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: + f.write("test content") + temp_path = Path(f.name) + + try: + hash1 = get_file_hash(temp_path) + hash2 = get_file_hash(temp_path) + self.assertEqual(hash1, hash2) + self.assertEqual(len(hash1), 32) # MD5 hex length + finally: + temp_path.unlink() + + def test_get_file_hash_different_content(self): + """Should return different hash for different content.""" + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: + f.write("content 1") + temp_path1 = Path(f.name) + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: + f.write("content 2") + temp_path2 = Path(f.name) + + try: + hash1 = get_file_hash(temp_path1) + hash2 = get_file_hash(temp_path2) + self.assertNotEqual(hash1, hash2) + finally: + temp_path1.unlink() + temp_path2.unlink() + + def test_get_combined_hash(self): + """Should combine hashes of both files.""" + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.ts') as f: + f.write("schema content") + schema_path = Path(f.name) + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.ts') as f: + f.write("types content") + types_path = Path(f.name) + + try: + combined = get_combined_hash(schema_path, types_path) + self.assertEqual(len(combined), 32) + + # Should change when schema changes + with open(schema_path, 'w') as f: + f.write("modified schema") + new_combined = get_combined_hash(schema_path, types_path) + self.assertNotEqual(combined, new_combined) + finally: + schema_path.unlink() + types_path.unlink() + + def test_combined_hash_with_missing_file(self): + """Should handle missing files gracefully.""" + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.ts') as f: + f.write("schema content") + schema_path = Path(f.name) + + try: + # One file missing + combined = get_combined_hash(schema_path, Path("/nonexistent.ts")) + self.assertEqual(len(combined), 32) + finally: + schema_path.unlink() + + +class TestRealSchemaHash(unittest.TestCase): + """Integration tests with real schema files.""" + + def setUp(self): + """Set up paths to real schema files.""" + self.script_dir = Path(__file__).parent + self.project_root = self.script_dir.parent.parent + self.schema_path = self.project_root / "packages" / "shared-schema" / "src" / "schema.ts" + self.types_path = self.project_root / "packages" / "shared-schema" / "src" / "types.ts" + + def test_real_schema_hash(self): + """Should successfully hash real schema file.""" + if not self.schema_path.exists(): + self.skipTest("Schema file not found") + + hash_value = get_file_hash(self.schema_path) + self.assertEqual(len(hash_value), 32) + self.assertNotEqual(hash_value, "") + + def test_real_combined_hash(self): + """Should successfully create combined hash of real files.""" + if not self.schema_path.exists(): + self.skipTest("Schema file not found") + + combined = get_combined_hash(self.schema_path, self.types_path) + self.assertEqual(len(combined), 32) + + # Hash should be consistent + combined2 = get_combined_hash(self.schema_path, self.types_path) + self.assertEqual(combined, combined2) + + +class TestHashStorage(unittest.TestCase): + """Test hash storage and retrieval.""" + + def test_store_and_retrieve_hash(self): + """Should store and retrieve hash correctly.""" + with tempfile.TemporaryDirectory() as tmpdir: + hash_file = Path(tmpdir) / ".schema_hash" + + # Store + test_hash = "abc123def456" + hash_file.parent.mkdir(parents=True, exist_ok=True) + hash_file.write_text(test_hash) + + # Retrieve + stored = hash_file.read_text().strip() + self.assertEqual(stored, test_hash) + + def test_missing_hash_file(self): + """Should handle missing hash file.""" + hash_file = Path("/nonexistent/.schema_hash") + if hash_file.exists(): + stored = hash_file.read_text().strip() + else: + stored = "" + self.assertEqual(stored, "") + + +if __name__ == '__main__': + # Run tests with verbosity + unittest.main(verbosity=2) diff --git a/package.json b/package.json index e76b801a..580b43ec 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "db:migrate": "npm run db:migrate --workspace=@boardsesh/db", "db:studio": "npm run db:studio --workspace=@boardsesh/db", "controller:codegen": "node embedded/scripts/generate-graphql-types.mjs", + "controller:codegen:test": "node --test embedded/scripts/generate-graphql-types.test.mjs && python3 embedded/scripts/test_prebuild.py", "controller:build": "cd embedded/projects/board-controller && pio run", "controller:upload": "cd embedded/projects/board-controller && pio run -t upload", "controller:monitor": "cd embedded/projects/board-controller && pio device monitor" From b7393ecac091c17f2cf1bc57a9dcd644fc9f339b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 12:39:38 +0000 Subject: [PATCH 3/7] fix: Address review feedback for GraphQL codegen Fixes several issues identified in code review: 1. Memory leak prevention: Added freeLedUpdate() helper function to properly deallocate the commands array allocated by parseLedUpdate() 2. Dangling pointer documentation: Added clear documentation about string pointer lifetime - const char* fields point into JsonDocument memory and become invalid when the document is destroyed 3. Removed timestamp from generated header to avoid noisy diffs on every regeneration - the file can now be committed without changes unless the schema actually changed 4. Fixed sentinel value comparison: Added ROLE_NOT_SET constant (-1) and updated serializeLedCommandInput() to use explicit sentinel comparison instead of >= 0 5. Removed leftover console.log from test file 6. Added new tests verifying: - freeLedUpdate helper is generated - ROLE_NOT_SET constant and usage - Pointer lifetime documentation - No timestamp in generated output https://claude.ai/code/session_01CkAwoY8k1wbNeCMJ14Xfzv --- .../libs/graphql-types/src/graphql_types.h | 42 +++++++++++++++-- embedded/scripts/generate-graphql-types.mjs | 47 +++++++++++++++++-- .../scripts/generate-graphql-types.test.mjs | 18 +++++-- 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/embedded/libs/graphql-types/src/graphql_types.h b/embedded/libs/graphql-types/src/graphql_types.h index e49708f8..490deb22 100644 --- a/embedded/libs/graphql-types/src/graphql_types.h +++ b/embedded/libs/graphql-types/src/graphql_types.h @@ -1,7 +1,6 @@ /** * Auto-generated GraphQL Types for ESP32 Firmware * - * Generated: 2026-02-03T05:56:00.785Z * Source: packages/shared-schema/src/schema.ts * * DO NOT EDIT MANUALLY - This file is generated by: @@ -152,9 +151,25 @@ constexpr const char* SEND_DEVICE_LOGS = } // namespace GraphQLOps +// ============================================ +// Constants +// ============================================ + +/** Sentinel value indicating role field is not set */ +constexpr int32_t ROLE_NOT_SET = -1; + // ============================================ // JSON Parsing Helpers (ArduinoJson) // ============================================ +// +// IMPORTANT: String pointer lifetime +// ---------------------------------- +// The climbUuid, climbName, and other const char* fields returned by parse +// functions point directly into the JsonDocument's memory. These pointers +// become invalid when the JsonDocument is destroyed or modified. +// +// If you need to keep string values beyond the JsonDocument's lifetime, +// copy them to your own buffers before the document goes out of scope. #include @@ -172,7 +187,12 @@ inline bool parseLedCommand(JsonObject& obj, LedCommand& cmd) { /** * Parse a LedUpdate from a JsonObject - * Note: Caller must free commands array when done + * + * IMPORTANT: This allocates memory for the commands array. + * You MUST call freeLedUpdate() when done to avoid memory leaks. + * + * String pointers (climbUuid, climbName) point into the JsonDocument + * and become invalid when the document is destroyed. */ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { JsonArray commands = obj["commands"]; @@ -193,8 +213,23 @@ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { return true; } +/** + * Free memory allocated by parseLedUpdate() + * Safe to call even if commands is nullptr + */ +inline void freeLedUpdate(LedUpdate& update) { + if (update.commands != nullptr) { + delete[] update.commands; + update.commands = nullptr; + update.commandsCount = 0; + } +} + /** * Parse a ClimbMatchResult from a JsonObject + * + * String pointers (climbUuid, climbName) point into the JsonDocument + * and become invalid when the document is destroyed. */ inline bool parseClimbMatchResult(JsonObject& obj, ClimbMatchResult& result) { result.matched = obj["matched"] | false; @@ -205,13 +240,14 @@ inline bool parseClimbMatchResult(JsonObject& obj, ClimbMatchResult& result) { /** * Serialize a LedCommandInput to a JsonObject + * Set cmd.role to ROLE_NOT_SET (-1) to omit the role field */ inline void serializeLedCommandInput(JsonObject& obj, const LedCommandInput& cmd) { obj["position"] = cmd.position; obj["r"] = cmd.r; obj["g"] = cmd.g; obj["b"] = cmd.b; - if (cmd.role >= 0) { + if (cmd.role != ROLE_NOT_SET) { obj["role"] = cmd.role; } } diff --git a/embedded/scripts/generate-graphql-types.mjs b/embedded/scripts/generate-graphql-types.mjs index 587ef29b..1fbf9dc3 100644 --- a/embedded/scripts/generate-graphql-types.mjs +++ b/embedded/scripts/generate-graphql-types.mjs @@ -63,6 +63,9 @@ const FIELD_TYPE_OVERRIDES = { 'LedCommandInput.b': 'uint8_t', }; +// Sentinel value for optional role field (use -1 to indicate "not set") +const ROLE_NOT_SET = -1; + /** * Parse GraphQL schema and extract types * @param {string} schemaContent @@ -249,12 +252,9 @@ constexpr const char* SEND_DEVICE_LOGS = * @returns {string} */ function generateHeader(types) { - const timestamp = new Date().toISOString(); - let content = `/** * Auto-generated GraphQL Types for ESP32 Firmware * - * Generated: ${timestamp} * Source: packages/shared-schema/src/schema.ts * * DO NOT EDIT MANUALLY - This file is generated by: @@ -314,9 +314,25 @@ namespace GraphQLTypename { // Add helper functions for JSON parsing content += ` +// ============================================ +// Constants +// ============================================ + +/** Sentinel value indicating role field is not set */ +constexpr int32_t ROLE_NOT_SET = ${ROLE_NOT_SET}; + // ============================================ // JSON Parsing Helpers (ArduinoJson) // ============================================ +// +// IMPORTANT: String pointer lifetime +// ---------------------------------- +// The climbUuid, climbName, and other const char* fields returned by parse +// functions point directly into the JsonDocument's memory. These pointers +// become invalid when the JsonDocument is destroyed or modified. +// +// If you need to keep string values beyond the JsonDocument's lifetime, +// copy them to your own buffers before the document goes out of scope. #include @@ -334,7 +350,12 @@ inline bool parseLedCommand(JsonObject& obj, LedCommand& cmd) { /** * Parse a LedUpdate from a JsonObject - * Note: Caller must free commands array when done + * + * IMPORTANT: This allocates memory for the commands array. + * You MUST call freeLedUpdate() when done to avoid memory leaks. + * + * String pointers (climbUuid, climbName) point into the JsonDocument + * and become invalid when the document is destroyed. */ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { JsonArray commands = obj["commands"]; @@ -355,8 +376,23 @@ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { return true; } +/** + * Free memory allocated by parseLedUpdate() + * Safe to call even if commands is nullptr + */ +inline void freeLedUpdate(LedUpdate& update) { + if (update.commands != nullptr) { + delete[] update.commands; + update.commands = nullptr; + update.commandsCount = 0; + } +} + /** * Parse a ClimbMatchResult from a JsonObject + * + * String pointers (climbUuid, climbName) point into the JsonDocument + * and become invalid when the document is destroyed. */ inline bool parseClimbMatchResult(JsonObject& obj, ClimbMatchResult& result) { result.matched = obj["matched"] | false; @@ -367,13 +403,14 @@ inline bool parseClimbMatchResult(JsonObject& obj, ClimbMatchResult& result) { /** * Serialize a LedCommandInput to a JsonObject + * Set cmd.role to ROLE_NOT_SET (-1) to omit the role field */ inline void serializeLedCommandInput(JsonObject& obj, const LedCommandInput& cmd) { obj["position"] = cmd.position; obj["r"] = cmd.r; obj["g"] = cmd.g; obj["b"] = cmd.b; - if (cmd.role >= 0) { + if (cmd.role != ROLE_NOT_SET) { obj["role"] = cmd.role; } } diff --git a/embedded/scripts/generate-graphql-types.test.mjs b/embedded/scripts/generate-graphql-types.test.mjs index bc7dfb0f..7c09f73c 100644 --- a/embedded/scripts/generate-graphql-types.test.mjs +++ b/embedded/scripts/generate-graphql-types.test.mjs @@ -423,6 +423,21 @@ describe('Integration: Parse and Generate', () => { assert.ok(content.includes('uint8_t r;'), 'Should use uint8_t for red'); assert.ok(content.includes('uint8_t g;'), 'Should use uint8_t for green'); assert.ok(content.includes('uint8_t b;'), 'Should use uint8_t for blue'); + + // Verify freeLedUpdate helper is generated + assert.ok(content.includes('inline void freeLedUpdate'), 'Should have freeLedUpdate helper'); + assert.ok(content.includes('delete[] update.commands'), 'freeLedUpdate should delete commands array'); + + // Verify ROLE_NOT_SET sentinel constant + assert.ok(content.includes('ROLE_NOT_SET'), 'Should have ROLE_NOT_SET constant'); + assert.ok(content.includes('cmd.role != ROLE_NOT_SET'), 'Should use ROLE_NOT_SET for comparison'); + + // Verify pointer lifetime documentation + assert.ok(content.includes('String pointer lifetime'), 'Should document pointer lifetime'); + assert.ok(content.includes('JsonDocument'), 'Should mention JsonDocument lifetime'); + + // Verify no timestamp (to avoid noisy diffs) + assert.ok(!content.includes('Generated:'), 'Should not have timestamp in header'); }); }); @@ -464,6 +479,3 @@ describe('Edge Cases', () => { assert.strictEqual(climbUuid.isNullable, true); }); }); - -// Run summary -console.log('\n✅ All tests defined. Run with: node --test embedded/scripts/generate-graphql-types.test.mjs\n'); From c45c583c219b2c59bb37194d71d91b8a2bccc400 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 21:02:31 +0000 Subject: [PATCH 4/7] fix: Fix native test build and add allocation failure handling Addresses additional review feedback: 1. Fix native test build failure: - Added LEDCOMMAND_DEFINED include guard to both led_controller.h and generated graphql_types.h - This allows native tests to work without Arduino.h dependency - led_controller.h defines LedCommand for native tests - graphql_types.h skips definition if already defined 2. Handle memory allocation failure: - Changed new[] to new (std::nothrow) in parseLedUpdate() - Returns false if allocation fails instead of crashing - Added #include for std::nothrow 3. Document nullable angle behavior: - Added note that angle defaults to 0 if not present - Since 0 is valid, callers must use containsKey() if they need to distinguish "no angle" from "angle=0" 4. Added tests verifying: - LEDCOMMAND_DEFINED include guard is generated - std::nothrow is used for allocation - Allocation failure handling - Angle nullable documentation https://claude.ai/code/session_01CkAwoY8k1wbNeCMJ14Xfzv --- .../libs/graphql-types/src/graphql_types.h | 17 ++++++++++- .../libs/led-controller/src/led_controller.h | 16 ++++++++-- embedded/scripts/generate-graphql-types.mjs | 30 ++++++++++++++++++- .../scripts/generate-graphql-types.test.mjs | 13 ++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/embedded/libs/graphql-types/src/graphql_types.h b/embedded/libs/graphql-types/src/graphql_types.h index 490deb22..a743cf3f 100644 --- a/embedded/libs/graphql-types/src/graphql_types.h +++ b/embedded/libs/graphql-types/src/graphql_types.h @@ -29,6 +29,9 @@ namespace GraphQLTypename { // GraphQL Types for Controller // ============================================ +// Include guard: LedCommand may also be defined in led_controller.h for native tests +#ifndef LEDCOMMAND_DEFINED +#define LEDCOMMAND_DEFINED /** * Output type: LedCommand * Generated from GraphQL schema @@ -39,6 +42,7 @@ struct LedCommand { uint8_t g; uint8_t b; }; +#endif // LEDCOMMAND_DEFINED /** * Input type: LedCommandInput @@ -172,6 +176,7 @@ constexpr int32_t ROLE_NOT_SET = -1; // copy them to your own buffers before the document goes out of scope. #include +#include // for std::nothrow /** * Parse a LedCommand from a JsonObject @@ -193,6 +198,12 @@ inline bool parseLedCommand(JsonObject& obj, LedCommand& cmd) { * * String pointers (climbUuid, climbName) point into the JsonDocument * and become invalid when the document is destroyed. + * + * NOTE: angle defaults to 0 if not present. Since 0 is a valid angle, + * callers cannot distinguish "no angle" from "angle=0". If this matters, + * check obj.containsKey("angle") before calling. + * + * @return false if memory allocation fails, true otherwise */ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { JsonArray commands = obj["commands"]; @@ -201,7 +212,11 @@ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { update.commandsCount = 0; } else { update.commandsCount = commands.size(); - update.commands = new LedCommand[update.commandsCount]; + update.commands = new (std::nothrow) LedCommand[update.commandsCount]; + if (update.commands == nullptr) { + update.commandsCount = 0; + return false; // Allocation failed + } size_t i = 0; for (JsonObject cmd : commands) { parseLedCommand(cmd, update.commands[i++]); diff --git a/embedded/libs/led-controller/src/led_controller.h b/embedded/libs/led-controller/src/led_controller.h index be291e85..fcf8d58f 100644 --- a/embedded/libs/led-controller/src/led_controller.h +++ b/embedded/libs/led-controller/src/led_controller.h @@ -3,11 +3,23 @@ #include #include -#include // Generated GraphQL types (includes LedCommand) #define MAX_LEDS 500 -// LedCommand is now defined in graphql_types.h (auto-generated from GraphQL schema) +/** + * LED command structure matching GraphQL LedCommand type. + * This is defined here for native test compatibility. + * The same struct is also generated in graphql_types.h with include guards. + */ +#ifndef LEDCOMMAND_DEFINED +#define LEDCOMMAND_DEFINED +struct LedCommand { + int32_t position; + uint8_t r; + uint8_t g; + uint8_t b; +}; +#endif class LedController { public: diff --git a/embedded/scripts/generate-graphql-types.mjs b/embedded/scripts/generate-graphql-types.mjs index 1fbf9dc3..b4750de0 100644 --- a/embedded/scripts/generate-graphql-types.mjs +++ b/embedded/scripts/generate-graphql-types.mjs @@ -154,6 +154,9 @@ function graphqlTypeToCpp(graphqlType, isNullable, isArray) { return graphqlType; } +// Types that need include guards (defined elsewhere for native test compatibility) +const GUARDED_TYPES = ['LedCommand']; + /** * Generate C++ struct for a GraphQL type * @param {object} type @@ -166,6 +169,15 @@ function generateCppStruct(type) { } const lines = []; + const needsGuard = GUARDED_TYPES.includes(type.name); + const guardName = `${type.name.toUpperCase()}_DEFINED`; + + if (needsGuard) { + lines.push(`// Include guard: ${type.name} may also be defined in led_controller.h for native tests`); + lines.push(`#ifndef ${guardName}`); + lines.push(`#define ${guardName}`); + } + lines.push(`/**`); lines.push(` * ${type.kind === 'input' ? 'Input' : 'Output'} type: ${type.name}`); lines.push(` * Generated from GraphQL schema`); @@ -191,6 +203,11 @@ function generateCppStruct(type) { } lines.push(`};`); + + if (needsGuard) { + lines.push(`#endif // ${guardName}`); + } + return lines.join('\n'); } @@ -335,6 +352,7 @@ constexpr int32_t ROLE_NOT_SET = ${ROLE_NOT_SET}; // copy them to your own buffers before the document goes out of scope. #include +#include // for std::nothrow /** * Parse a LedCommand from a JsonObject @@ -356,6 +374,12 @@ inline bool parseLedCommand(JsonObject& obj, LedCommand& cmd) { * * String pointers (climbUuid, climbName) point into the JsonDocument * and become invalid when the document is destroyed. + * + * NOTE: angle defaults to 0 if not present. Since 0 is a valid angle, + * callers cannot distinguish "no angle" from "angle=0". If this matters, + * check obj.containsKey("angle") before calling. + * + * @return false if memory allocation fails, true otherwise */ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { JsonArray commands = obj["commands"]; @@ -364,7 +388,11 @@ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { update.commandsCount = 0; } else { update.commandsCount = commands.size(); - update.commands = new LedCommand[update.commandsCount]; + update.commands = new (std::nothrow) LedCommand[update.commandsCount]; + if (update.commands == nullptr) { + update.commandsCount = 0; + return false; // Allocation failed + } size_t i = 0; for (JsonObject cmd : commands) { parseLedCommand(cmd, update.commands[i++]); diff --git a/embedded/scripts/generate-graphql-types.test.mjs b/embedded/scripts/generate-graphql-types.test.mjs index 7c09f73c..8f75f2aa 100644 --- a/embedded/scripts/generate-graphql-types.test.mjs +++ b/embedded/scripts/generate-graphql-types.test.mjs @@ -438,6 +438,19 @@ describe('Integration: Parse and Generate', () => { // Verify no timestamp (to avoid noisy diffs) assert.ok(!content.includes('Generated:'), 'Should not have timestamp in header'); + + // Verify LedCommand has include guard for native test compatibility + assert.ok(content.includes('#ifndef LEDCOMMAND_DEFINED'), 'LedCommand should have include guard'); + assert.ok(content.includes('#define LEDCOMMAND_DEFINED'), 'LedCommand should define guard'); + assert.ok(content.includes('#endif // LEDCOMMAND_DEFINED'), 'LedCommand should close guard'); + + // Verify memory allocation failure handling + assert.ok(content.includes('std::nothrow'), 'Should use std::nothrow for allocation'); + assert.ok(content.includes('#include '), 'Should include header'); + assert.ok(content.includes('return false; // Allocation failed'), 'Should return false on allocation failure'); + + // Verify angle nullable documentation + assert.ok(content.includes('angle defaults to 0'), 'Should document angle nullable behavior'); }); }); From 4dbe541814f962989afecdaf750b191d7f829229 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 21:25:00 +0000 Subject: [PATCH 5/7] chore: Address minor review feedback 1. Switch from MD5 to SHA256 for hash-based change detection - More consistent with modern practices - Updated both prebuild.py and test_prebuild.py - Updated test assertions for 64-char SHA256 hex length 2. Add generated files to .gitignore - graphql_types.h is now generated during build only - .schema_hash cache file also ignored - Prevents merge conflicts when multiple PRs modify schema 3. Document angle nullable ambiguity in GraphQL schema - Added doc comment to LedUpdate.angle explaining that null means "no angle" vs 0 being a valid angle value - Source documentation prevents ambiguity at the source https://claude.ai/code/session_01CkAwoY8k1wbNeCMJ14Xfzv --- .gitignore | 4 + .../libs/graphql-types/src/graphql_types.h | 283 ------------------ embedded/scripts/prebuild.py | 6 +- embedded/scripts/test_prebuild.py | 16 +- packages/shared-schema/src/schema.ts | 5 + 5 files changed, 20 insertions(+), 294 deletions(-) delete mode 100644 embedded/libs/graphql-types/src/graphql_types.h diff --git a/.gitignore b/.gitignore index 0fcd9753..d97c3553 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,10 @@ embedded/**/.pio embedded/**/.vscode !embedded/**/.vscode/extensions.json +# Generated GraphQL types (regenerated from schema during build) +embedded/libs/graphql-types/src/graphql_types.h +embedded/libs/graphql-types/.schema_hash + # backend packages/backend/dist diff --git a/embedded/libs/graphql-types/src/graphql_types.h b/embedded/libs/graphql-types/src/graphql_types.h deleted file mode 100644 index a743cf3f..00000000 --- a/embedded/libs/graphql-types/src/graphql_types.h +++ /dev/null @@ -1,283 +0,0 @@ -/** - * Auto-generated GraphQL Types for ESP32 Firmware - * - * Source: packages/shared-schema/src/schema.ts - * - * DO NOT EDIT MANUALLY - This file is generated by: - * npm run controller:codegen - * - * These types are compatible with ArduinoJson for JSON serialization. - */ - -#ifndef GRAPHQL_TYPES_H -#define GRAPHQL_TYPES_H - -#include -#include -#include - -// ============================================ -// GraphQL Type Constants -// ============================================ - -namespace GraphQLTypename { - constexpr const char* LED_UPDATE = "LedUpdate"; - constexpr const char* CONTROLLER_PING = "ControllerPing"; -} - -// ============================================ -// GraphQL Types for Controller -// ============================================ - -// Include guard: LedCommand may also be defined in led_controller.h for native tests -#ifndef LEDCOMMAND_DEFINED -#define LEDCOMMAND_DEFINED -/** - * Output type: LedCommand - * Generated from GraphQL schema - */ -struct LedCommand { - int32_t position; - uint8_t r; - uint8_t g; - uint8_t b; -}; -#endif // LEDCOMMAND_DEFINED - -/** - * Input type: LedCommandInput - * Generated from GraphQL schema - */ -struct LedCommandInput { - int32_t position; - uint8_t r; - uint8_t g; - uint8_t b; - int32_t role; -}; - -/** - * Output type: LedUpdate - * Generated from GraphQL schema - */ -struct LedUpdate { - LedCommand* commands; - size_t commandsCount; - const char* climbUuid; - const char* climbName; - int32_t angle; -}; - -/** - * Output type: ControllerPing - * Generated from GraphQL schema - */ -struct ControllerPing { - const char* timestamp; -}; - -/** - * Output type: ClimbMatchResult - * Generated from GraphQL schema - */ -struct ClimbMatchResult { - bool matched; - const char* climbUuid; - const char* climbName; -}; - -/** - * Input type: DeviceLogEntry - * Generated from GraphQL schema - */ -struct DeviceLogEntry { - float ts; - const char* level; - const char* component; - const char* message; - const char* metadata; -}; - -/** - * Output type: SendDeviceLogsResponse - * Generated from GraphQL schema - */ -struct SendDeviceLogsResponse { - bool success; - int32_t accepted; -}; - -// // Union type: ControllerEvent = LedUpdate | ControllerPing -// Use __typename field to determine actual type - - -// ============================================ -// GraphQL Operations for ESP32 Controller -// ============================================ - -namespace GraphQLOps { - -/** - * Subscription: Controller Events - * Receives LED updates and ping events from the backend - */ -constexpr const char* CONTROLLER_EVENTS_SUBSCRIPTION = - "subscription ControllerEvents($sessionId: ID!) { " - "controllerEvents(sessionId: $sessionId) { " - "... on LedUpdate { __typename commands { position r g b } climbUuid climbName angle } " - "... on ControllerPing { __typename timestamp } " - "} }"; - -/** - * Mutation: Set Climb From LED Positions - * Sends LED positions from Bluetooth to match a climb - */ -constexpr const char* SET_CLIMB_FROM_LED_POSITIONS = - "mutation SetClimbFromLeds($sessionId: ID!, $positions: [LedCommandInput!]!) { " - "setClimbFromLedPositions(sessionId: $sessionId, positions: $positions) { " - "matched climbUuid climbName } }"; - -/** - * Mutation: Controller Heartbeat - * Keep-alive ping to update lastSeenAt - */ -constexpr const char* CONTROLLER_HEARTBEAT = - "mutation ControllerHeartbeat($sessionId: ID!) { " - "controllerHeartbeat(sessionId: $sessionId) }"; - -/** - * Mutation: Send Device Logs - * Forward device logs to backend for Axiom ingestion - */ -constexpr const char* SEND_DEVICE_LOGS = - "mutation SendDeviceLogs($input: SendDeviceLogsInput!) { " - "sendDeviceLogs(input: $input) { success accepted } }"; - -} // namespace GraphQLOps - -// ============================================ -// Constants -// ============================================ - -/** Sentinel value indicating role field is not set */ -constexpr int32_t ROLE_NOT_SET = -1; - -// ============================================ -// JSON Parsing Helpers (ArduinoJson) -// ============================================ -// -// IMPORTANT: String pointer lifetime -// ---------------------------------- -// The climbUuid, climbName, and other const char* fields returned by parse -// functions point directly into the JsonDocument's memory. These pointers -// become invalid when the JsonDocument is destroyed or modified. -// -// If you need to keep string values beyond the JsonDocument's lifetime, -// copy them to your own buffers before the document goes out of scope. - -#include -#include // for std::nothrow - -/** - * Parse a LedCommand from a JsonObject - */ -inline bool parseLedCommand(JsonObject& obj, LedCommand& cmd) { - if (!obj.containsKey("position")) return false; - cmd.position = obj["position"]; - cmd.r = obj["r"] | 0; - cmd.g = obj["g"] | 0; - cmd.b = obj["b"] | 0; - return true; -} - -/** - * Parse a LedUpdate from a JsonObject - * - * IMPORTANT: This allocates memory for the commands array. - * You MUST call freeLedUpdate() when done to avoid memory leaks. - * - * String pointers (climbUuid, climbName) point into the JsonDocument - * and become invalid when the document is destroyed. - * - * NOTE: angle defaults to 0 if not present. Since 0 is a valid angle, - * callers cannot distinguish "no angle" from "angle=0". If this matters, - * check obj.containsKey("angle") before calling. - * - * @return false if memory allocation fails, true otherwise - */ -inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { - JsonArray commands = obj["commands"]; - if (commands.isNull()) { - update.commands = nullptr; - update.commandsCount = 0; - } else { - update.commandsCount = commands.size(); - update.commands = new (std::nothrow) LedCommand[update.commandsCount]; - if (update.commands == nullptr) { - update.commandsCount = 0; - return false; // Allocation failed - } - size_t i = 0; - for (JsonObject cmd : commands) { - parseLedCommand(cmd, update.commands[i++]); - } - } - update.climbUuid = obj["climbUuid"] | nullptr; - update.climbName = obj["climbName"] | nullptr; - update.angle = obj["angle"] | 0; - return true; -} - -/** - * Free memory allocated by parseLedUpdate() - * Safe to call even if commands is nullptr - */ -inline void freeLedUpdate(LedUpdate& update) { - if (update.commands != nullptr) { - delete[] update.commands; - update.commands = nullptr; - update.commandsCount = 0; - } -} - -/** - * Parse a ClimbMatchResult from a JsonObject - * - * String pointers (climbUuid, climbName) point into the JsonDocument - * and become invalid when the document is destroyed. - */ -inline bool parseClimbMatchResult(JsonObject& obj, ClimbMatchResult& result) { - result.matched = obj["matched"] | false; - result.climbUuid = obj["climbUuid"] | nullptr; - result.climbName = obj["climbName"] | nullptr; - return true; -} - -/** - * Serialize a LedCommandInput to a JsonObject - * Set cmd.role to ROLE_NOT_SET (-1) to omit the role field - */ -inline void serializeLedCommandInput(JsonObject& obj, const LedCommandInput& cmd) { - obj["position"] = cmd.position; - obj["r"] = cmd.r; - obj["g"] = cmd.g; - obj["b"] = cmd.b; - if (cmd.role != ROLE_NOT_SET) { - obj["role"] = cmd.role; - } -} - -/** - * Serialize a DeviceLogEntry to a JsonObject - */ -inline void serializeDeviceLogEntry(JsonObject& obj, const DeviceLogEntry& entry) { - obj["ts"] = entry.ts; - obj["level"] = entry.level; - obj["component"] = entry.component; - obj["message"] = entry.message; - if (entry.metadata) { - obj["metadata"] = entry.metadata; - } -} - -#endif // GRAPHQL_TYPES_H diff --git a/embedded/scripts/prebuild.py b/embedded/scripts/prebuild.py index 9861d198..62d70331 100644 --- a/embedded/scripts/prebuild.py +++ b/embedded/scripts/prebuild.py @@ -28,18 +28,18 @@ def get_file_hash(filepath: Path) -> str: - """Calculate MD5 hash of a file.""" + """Calculate SHA256 hash of a file.""" if not filepath.exists(): return "" with open(filepath, "rb") as f: - return hashlib.md5(f.read()).hexdigest() + return hashlib.sha256(f.read()).hexdigest() def get_combined_hash() -> str: """Get combined hash of schema and types files.""" schema_hash = get_file_hash(SCHEMA_PATH) types_hash = get_file_hash(TYPES_PATH) - return hashlib.md5(f"{schema_hash}{types_hash}".encode()).hexdigest() + return hashlib.sha256(f"{schema_hash}{types_hash}".encode()).hexdigest() def get_stored_hash() -> str: diff --git a/embedded/scripts/test_prebuild.py b/embedded/scripts/test_prebuild.py index c988d73c..4e7a7760 100644 --- a/embedded/scripts/test_prebuild.py +++ b/embedded/scripts/test_prebuild.py @@ -12,18 +12,18 @@ def get_file_hash(filepath: Path) -> str: - """Calculate MD5 hash of a file.""" + """Calculate SHA256 hash of a file.""" if not filepath.exists(): return "" with open(filepath, "rb") as f: - return hashlib.md5(f.read()).hexdigest() + return hashlib.sha256(f.read()).hexdigest() def get_combined_hash(schema_path: Path, types_path: Path) -> str: """Get combined hash of schema and types files.""" schema_hash = get_file_hash(schema_path) types_hash = get_file_hash(types_path) - return hashlib.md5(f"{schema_hash}{types_hash}".encode()).hexdigest() + return hashlib.sha256(f"{schema_hash}{types_hash}".encode()).hexdigest() class TestHashFunctions(unittest.TestCase): @@ -44,7 +44,7 @@ def test_get_file_hash_existing(self): hash1 = get_file_hash(temp_path) hash2 = get_file_hash(temp_path) self.assertEqual(hash1, hash2) - self.assertEqual(len(hash1), 32) # MD5 hex length + self.assertEqual(len(hash1), 64) # SHA256 hex length finally: temp_path.unlink() @@ -78,7 +78,7 @@ def test_get_combined_hash(self): try: combined = get_combined_hash(schema_path, types_path) - self.assertEqual(len(combined), 32) + self.assertEqual(len(combined), 64) # SHA256 hex length # Should change when schema changes with open(schema_path, 'w') as f: @@ -98,7 +98,7 @@ def test_combined_hash_with_missing_file(self): try: # One file missing combined = get_combined_hash(schema_path, Path("/nonexistent.ts")) - self.assertEqual(len(combined), 32) + self.assertEqual(len(combined), 64) # SHA256 hex length finally: schema_path.unlink() @@ -119,7 +119,7 @@ def test_real_schema_hash(self): self.skipTest("Schema file not found") hash_value = get_file_hash(self.schema_path) - self.assertEqual(len(hash_value), 32) + self.assertEqual(len(hash_value), 64) # SHA256 hex length self.assertNotEqual(hash_value, "") def test_real_combined_hash(self): @@ -128,7 +128,7 @@ def test_real_combined_hash(self): self.skipTest("Schema file not found") combined = get_combined_hash(self.schema_path, self.types_path) - self.assertEqual(len(combined), 32) + self.assertEqual(len(combined), 64) # SHA256 hex length # Hash should be consistent combined2 = get_combined_hash(self.schema_path, self.types_path) diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 80696de2..9c14d194 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -1433,6 +1433,11 @@ export const typeDefs = /* GraphQL */ ` commands: [LedCommand!]! climbUuid: String climbName: String + """ + Board angle in degrees. Nullable - null means angle not specified. + Note: 0 is a valid angle value, so null should be used to indicate "no angle" + rather than defaulting to 0. + """ angle: Int } From 6766806e62dd8612e52f5be4064ec9cef81d8952 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 21:39:05 +0000 Subject: [PATCH 6/7] fix: Add parseLedCommand error handling and optimize hash computation - Check return value of parseLedCommand in generated parseLedUpdate loop - Free allocated memory and return false if command parsing fails - Simplify hash computation to hash file contents directly in one pass - Remove obsolete get_file_hash tests from test_prebuild.py https://claude.ai/code/session_01CkAwoY8k1wbNeCMJ14Xfzv --- embedded/scripts/generate-graphql-types.mjs | 11 +++- embedded/scripts/prebuild.py | 19 +++---- embedded/scripts/test_prebuild.py | 62 ++++----------------- 3 files changed, 26 insertions(+), 66 deletions(-) diff --git a/embedded/scripts/generate-graphql-types.mjs b/embedded/scripts/generate-graphql-types.mjs index b4750de0..74961d52 100644 --- a/embedded/scripts/generate-graphql-types.mjs +++ b/embedded/scripts/generate-graphql-types.mjs @@ -379,7 +379,7 @@ inline bool parseLedCommand(JsonObject& obj, LedCommand& cmd) { * callers cannot distinguish "no angle" from "angle=0". If this matters, * check obj.containsKey("angle") before calling. * - * @return false if memory allocation fails, true otherwise + * @return false if memory allocation fails or command parsing fails, true otherwise */ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { JsonArray commands = obj["commands"]; @@ -395,7 +395,14 @@ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { } size_t i = 0; for (JsonObject cmd : commands) { - parseLedCommand(cmd, update.commands[i++]); + if (!parseLedCommand(cmd, update.commands[i])) { + // Parsing failed - free memory and return false + delete[] update.commands; + update.commands = nullptr; + update.commandsCount = 0; + return false; + } + i++; } } update.climbUuid = obj["climbUuid"] | nullptr; diff --git a/embedded/scripts/prebuild.py b/embedded/scripts/prebuild.py index 62d70331..55fcde73 100644 --- a/embedded/scripts/prebuild.py +++ b/embedded/scripts/prebuild.py @@ -27,19 +27,14 @@ CODEGEN_SCRIPT = SCRIPT_DIR / "generate-graphql-types.mjs" -def get_file_hash(filepath: Path) -> str: - """Calculate SHA256 hash of a file.""" - if not filepath.exists(): - return "" - with open(filepath, "rb") as f: - return hashlib.sha256(f.read()).hexdigest() - - def get_combined_hash() -> str: - """Get combined hash of schema and types files.""" - schema_hash = get_file_hash(SCHEMA_PATH) - types_hash = get_file_hash(TYPES_PATH) - return hashlib.sha256(f"{schema_hash}{types_hash}".encode()).hexdigest() + """Get combined hash of schema and types files by hashing contents directly.""" + hasher = hashlib.sha256() + for filepath in [SCHEMA_PATH, TYPES_PATH]: + if filepath.exists(): + with open(filepath, "rb") as f: + hasher.update(f.read()) + return hasher.hexdigest() def get_stored_hash() -> str: diff --git a/embedded/scripts/test_prebuild.py b/embedded/scripts/test_prebuild.py index 4e7a7760..1261d298 100644 --- a/embedded/scripts/test_prebuild.py +++ b/embedded/scripts/test_prebuild.py @@ -11,61 +11,19 @@ from pathlib import Path -def get_file_hash(filepath: Path) -> str: - """Calculate SHA256 hash of a file.""" - if not filepath.exists(): - return "" - with open(filepath, "rb") as f: - return hashlib.sha256(f.read()).hexdigest() - - def get_combined_hash(schema_path: Path, types_path: Path) -> str: - """Get combined hash of schema and types files.""" - schema_hash = get_file_hash(schema_path) - types_hash = get_file_hash(types_path) - return hashlib.sha256(f"{schema_hash}{types_hash}".encode()).hexdigest() + """Get combined hash of schema and types files by hashing contents directly.""" + hasher = hashlib.sha256() + for filepath in [schema_path, types_path]: + if filepath.exists(): + with open(filepath, "rb") as f: + hasher.update(f.read()) + return hasher.hexdigest() class TestHashFunctions(unittest.TestCase): """Test hash calculation functions.""" - def test_get_file_hash_nonexistent(self): - """Should return empty string for non-existent file.""" - result = get_file_hash(Path("/nonexistent/file.txt")) - self.assertEqual(result, "") - - def test_get_file_hash_existing(self): - """Should return consistent hash for same content.""" - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: - f.write("test content") - temp_path = Path(f.name) - - try: - hash1 = get_file_hash(temp_path) - hash2 = get_file_hash(temp_path) - self.assertEqual(hash1, hash2) - self.assertEqual(len(hash1), 64) # SHA256 hex length - finally: - temp_path.unlink() - - def test_get_file_hash_different_content(self): - """Should return different hash for different content.""" - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: - f.write("content 1") - temp_path1 = Path(f.name) - - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: - f.write("content 2") - temp_path2 = Path(f.name) - - try: - hash1 = get_file_hash(temp_path1) - hash2 = get_file_hash(temp_path2) - self.assertNotEqual(hash1, hash2) - finally: - temp_path1.unlink() - temp_path2.unlink() - def test_get_combined_hash(self): """Should combine hashes of both files.""" with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.ts') as f: @@ -114,13 +72,13 @@ def setUp(self): self.types_path = self.project_root / "packages" / "shared-schema" / "src" / "types.ts" def test_real_schema_hash(self): - """Should successfully hash real schema file.""" + """Should successfully hash real schema file using combined hash.""" if not self.schema_path.exists(): self.skipTest("Schema file not found") - hash_value = get_file_hash(self.schema_path) + # Test with just schema (types may or may not exist) + hash_value = get_combined_hash(self.schema_path, Path("/nonexistent.ts")) self.assertEqual(len(hash_value), 64) # SHA256 hex length - self.assertNotEqual(hash_value, "") def test_real_combined_hash(self): """Should successfully create combined hash of real files.""" From 336a79357d89094eb33cfc2cacef78aaafcfa372 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 23:28:49 +0000 Subject: [PATCH 7/7] fix: Address remaining review feedback - Add ANGLE_NOT_SET sentinel (-32768) for nullable angle field - Prevent duplicate codegen runs during single build with flag - Add newline at end of prebuild.py - Document regex limitation for nested braces - Update test for new angle sentinel behavior https://claude.ai/code/session_01CkAwoY8k1wbNeCMJ14Xfzv --- embedded/scripts/generate-graphql-types.mjs | 15 ++++++++++----- embedded/scripts/generate-graphql-types.test.mjs | 2 +- embedded/scripts/prebuild.py | 9 +++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/embedded/scripts/generate-graphql-types.mjs b/embedded/scripts/generate-graphql-types.mjs index 74961d52..a2604a98 100644 --- a/embedded/scripts/generate-graphql-types.mjs +++ b/embedded/scripts/generate-graphql-types.mjs @@ -63,8 +63,9 @@ const FIELD_TYPE_OVERRIDES = { 'LedCommandInput.b': 'uint8_t', }; -// Sentinel value for optional role field (use -1 to indicate "not set") +// Sentinel values for optional fields (use specific values to indicate "not set") const ROLE_NOT_SET = -1; +const ANGLE_NOT_SET = -32768; // INT16_MIN - unlikely valid angle value /** * Parse GraphQL schema and extract types @@ -78,6 +79,8 @@ function parseGraphQLSchema(schemaContent) { const cleanSchema = schemaContent.replace(/"""[\s\S]*?"""/g, ''); // Parse type definitions + // Note: This regex doesn't handle nested braces (e.g., directives with @deprecated(reason: "...")) + // If schema evolution requires nested constructs, this parser will need enhancement const typeRegex = /(type|input)\s+(\w+)\s*\{([^}]+)\}/g; let match; @@ -338,6 +341,9 @@ namespace GraphQLTypename { /** Sentinel value indicating role field is not set */ constexpr int32_t ROLE_NOT_SET = ${ROLE_NOT_SET}; +/** Sentinel value indicating angle field is not set (null in GraphQL) */ +constexpr int32_t ANGLE_NOT_SET = ${ANGLE_NOT_SET}; + // ============================================ // JSON Parsing Helpers (ArduinoJson) // ============================================ @@ -375,9 +381,8 @@ inline bool parseLedCommand(JsonObject& obj, LedCommand& cmd) { * String pointers (climbUuid, climbName) point into the JsonDocument * and become invalid when the document is destroyed. * - * NOTE: angle defaults to 0 if not present. Since 0 is a valid angle, - * callers cannot distinguish "no angle" from "angle=0". If this matters, - * check obj.containsKey("angle") before calling. + * NOTE: angle is set to ANGLE_NOT_SET (-32768) if not present in JSON. + * Check update.angle != ANGLE_NOT_SET to determine if angle was provided. * * @return false if memory allocation fails or command parsing fails, true otherwise */ @@ -407,7 +412,7 @@ inline bool parseLedUpdate(JsonObject& obj, LedUpdate& update) { } update.climbUuid = obj["climbUuid"] | nullptr; update.climbName = obj["climbName"] | nullptr; - update.angle = obj["angle"] | 0; + update.angle = obj.containsKey("angle") ? (int32_t)obj["angle"] : ANGLE_NOT_SET; return true; } diff --git a/embedded/scripts/generate-graphql-types.test.mjs b/embedded/scripts/generate-graphql-types.test.mjs index 8f75f2aa..a71b97e8 100644 --- a/embedded/scripts/generate-graphql-types.test.mjs +++ b/embedded/scripts/generate-graphql-types.test.mjs @@ -450,7 +450,7 @@ describe('Integration: Parse and Generate', () => { assert.ok(content.includes('return false; // Allocation failed'), 'Should return false on allocation failure'); // Verify angle nullable documentation - assert.ok(content.includes('angle defaults to 0'), 'Should document angle nullable behavior'); + assert.ok(content.includes('ANGLE_NOT_SET'), 'Should document angle nullable behavior with sentinel value'); }); }); diff --git a/embedded/scripts/prebuild.py b/embedded/scripts/prebuild.py index 55fcde73..5e342123 100644 --- a/embedded/scripts/prebuild.py +++ b/embedded/scripts/prebuild.py @@ -26,6 +26,9 @@ HASH_FILE = SCRIPT_DIR.parent / "libs" / "graphql-types" / ".schema_hash" CODEGEN_SCRIPT = SCRIPT_DIR / "generate-graphql-types.mjs" +# Flag to prevent duplicate runs during a single build +_codegen_ran = False + def get_combined_hash() -> str: """Get combined hash of schema and types files by hashing contents directly.""" @@ -89,6 +92,11 @@ def run_codegen(): def before_build(source, target, env): """Pre-build hook to check and regenerate types if needed.""" + global _codegen_ran + if _codegen_ran: + return + _codegen_ran = True + print("\n[GraphQL Codegen] Checking if types need regeneration...") # Check if schema exists @@ -121,3 +129,4 @@ def before_build(source, target, env): # Also run on first build (library build) env.AddPreAction("$BUILD_DIR/src/main.cpp.o", before_build) +