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/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/led-controller/src/led_controller.h b/embedded/libs/led-controller/src/led_controller.h index 5ae6536b..fcf8d58f 100644 --- a/embedded/libs/led-controller/src/led_controller.h +++ b/embedded/libs/led-controller/src/led_controller.h @@ -7,14 +7,19 @@ #define MAX_LEDS 500 /** - * LED command structure matching GraphQL LedCommand type + * 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 { - int position; + int32_t position; uint8_t r; uint8_t g; uint8_t b; }; +#endif 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..a2604a98 --- /dev/null +++ b/embedded/scripts/generate-graphql-types.mjs @@ -0,0 +1,518 @@ +#!/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', +}; + +// 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 + * @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 + // 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; + + 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; +} + +// 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 + * @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 = []; + 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`); + 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(`};`); + + if (needsGuard) { + lines.push(`#endif // ${guardName}`); + } + + 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) { + let content = `/** + * 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 +// ============================================ + +`; + + // 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 += ` +// ============================================ +// Constants +// ============================================ + +/** 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) +// ============================================ +// +// 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 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 + */ +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) { + 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; + update.climbName = obj["climbName"] | nullptr; + update.angle = obj.containsKey("angle") ? (int32_t)obj["angle"] : ANGLE_NOT_SET; + 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 +`; + + 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/generate-graphql-types.test.mjs b/embedded/scripts/generate-graphql-types.test.mjs new file mode 100644 index 00000000..a71b97e8 --- /dev/null +++ b/embedded/scripts/generate-graphql-types.test.mjs @@ -0,0 +1,494 @@ +#!/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'); + + // 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'); + + // 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_NOT_SET'), 'Should document angle nullable behavior with sentinel value'); + }); +}); + +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); + }); +}); diff --git a/embedded/scripts/prebuild.py b/embedded/scripts/prebuild.py new file mode 100644 index 00000000..5e342123 --- /dev/null +++ b/embedded/scripts/prebuild.py @@ -0,0 +1,132 @@ +#!/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" + +# 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.""" + 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: + """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.""" + global _codegen_ran + if _codegen_ran: + return + _codegen_ran = True + + 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/embedded/scripts/test_prebuild.py b/embedded/scripts/test_prebuild.py new file mode 100644 index 00000000..1261d298 --- /dev/null +++ b/embedded/scripts/test_prebuild.py @@ -0,0 +1,125 @@ +#!/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_combined_hash(schema_path: Path, types_path: Path) -> str: + """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_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), 64) # SHA256 hex length + + # 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), 64) # SHA256 hex length + 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 using combined hash.""" + if not self.schema_path.exists(): + self.skipTest("Schema file not found") + + # 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 + + 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), 64) # SHA256 hex length + + # 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 fb28ca9d..580b43ec 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "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: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" 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 }