diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0c72e2..91007a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,19 @@ jobs: run: | ./gen/build/test_runner test_fixed_suite_roundtrip + - name: Pack NuGet (Core/Facade) + run: | + dotnet pack -c Release src/Signal.CANdy.Core/Signal.CANdy.Core.fsproj -o artifacts + dotnet pack -c Release src/Signal.CANdy/Signal.CANdy.fsproj -o artifacts + + - name: Upload NuGet packages + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: | + artifacts/*.nupkg + artifacts/*.snupkg + - name: Upload build artifacts (on failure) if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 688d227..bfeb955 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,24 +16,61 @@ jobs: with: fetch-depth: 0 + - name: Check if tag is on main branch + run: | + # Check if the tag points to a commit that's reachable from main + if ! git merge-base --is-ancestor ${{ github.sha }} origin/main 2>/dev/null; then + echo "Tag is not on main branch. Skipping release." + echo "SKIP_RELEASE=true" >> $GITHUB_ENV + else + echo "Tag is on main branch. Proceeding with release." + echo "SKIP_RELEASE=false" >> $GITHUB_ENV + fi + - name: Setup .NET + if: env.SKIP_RELEASE == 'false' uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - name: Build & Test (Release) + if: env.SKIP_RELEASE == 'false' run: | dotnet restore dotnet build --configuration Release --nologo dotnet test --configuration Release -v minimal --nologo - name: Generate artifacts (optional) + if: env.SKIP_RELEASE == 'false' run: | dotnet run --project src/Generator -- --dbc examples/sample.dbc --out gen --config examples/config.yaml - - name: Create GitHub Release + - name: Pack NuGet (Core/Facade) + if: env.SKIP_RELEASE == 'false' + run: | + dotnet pack -c Release src/Signal.CANdy.Core/Signal.CANdy.Core.fsproj -o artifacts + dotnet pack -c Release src/Signal.CANdy/Signal.CANdy.fsproj -o artifacts + + - name: Publish NuGet packages (stable only) + if: ${{ env.SKIP_RELEASE == 'false' && !contains(github.ref_name, '-') }} + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + dotnet nuget push artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate + + - name: Create GitHub Release (stable) + if: ${{ env.SKIP_RELEASE == 'false' && !contains(github.ref_name, '-') }} + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create GitHub Prerelease (preview tags) + if: ${{ env.SKIP_RELEASE == 'false' && contains(github.ref_name, '-') }} uses: softprops/action-gh-release@v2 with: generate_release_notes: true + prerelease: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9a33a83..d98c357 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ bin/ obj/ tmp/ .fake +artifacts/ # External test data: ignore all contents by default; keep folder and docs only external_test/* !external_test/.gitkeep diff --git a/README.md b/README.md index 862839a..2f99e60 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ [![F#](https://img.shields.io/badge/F%23-language-blue.svg)](https://fsharp.org/) [![Version](https://img.shields.io/github/v/release/InitusNovus/Signal-CANdy?include_prereleases)](https://github.com/InitusNovus/Signal-CANdy/releases) +[![NuGet SignalCandy](https://img.shields.io/nuget/v/SignalCandy.svg)](https://www.nuget.org/packages/SignalCandy/) +[![NuGet SignalCandy.Core](https://img.shields.io/nuget/v/SignalCandy.Core.svg)](https://www.nuget.org/packages/SignalCandy.Core/) + [![C99](https://img.shields.io/badge/C-99-blue.svg)](https://en.wikipedia.org/wiki/C99) [![CAN DBC](https://img.shields.io/badge/protocol-CAN%20DBC-green.svg)](https://en.wikipedia.org/wiki/CAN_bus) @@ -13,6 +16,18 @@ Languages: This README is in English. For Korean, see README.ko.md. This project generates portable C99 parser modules (headers/sources) from a `.dbc` file using an F# code generator. +## 📦 NuGet Packages + +- SignalCandy.Core — Core F# library (parsing, config, codegen) +- SignalCandy — C#-friendly facade over the Core + +Install: + +```pwsh +dotnet add package SignalCandy.Core --version 0.2.1 +dotnet add package SignalCandy --version 0.2.1 +``` + ## ⚡ Quick Start (5 minutes) 1) Check prerequisites diff --git a/DBC_Parser.sln b/Signal.CANdy.sln similarity index 52% rename from DBC_Parser.sln rename to Signal.CANdy.sln index ee4dcab..917cfa8 100644 --- a/DBC_Parser.sln +++ b/Signal.CANdy.sln @@ -11,6 +11,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{42F645A3 EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Generator.Tests", "tests\Generator.Tests\Generator.Tests.fsproj", "{4A234D75-019C-479E-8E35-17F85A776EF4}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Signal.CANdy.Core", "src\Signal.CANdy.Core\Signal.CANdy.Core.fsproj", "{5FA4B1F9-EC6B-47FF-84BC-44B9B2225731}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Signal.CANdy", "src\Signal.CANdy\Signal.CANdy.fsproj", "{E7B40CFD-65C1-41BB-80DE-F77723D6BFCE}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Signal.CANdy.CLI", "src\Signal.CANdy.CLI\Signal.CANdy.CLI.fsproj", "{2029AC91-E903-43ED-A2E1-53D261747CFA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -28,9 +34,24 @@ Global {4A234D75-019C-479E-8E35-17F85A776EF4}.Debug|Any CPU.Build.0 = Debug|Any CPU {4A234D75-019C-479E-8E35-17F85A776EF4}.Release|Any CPU.ActiveCfg = Release|Any CPU {4A234D75-019C-479E-8E35-17F85A776EF4}.Release|Any CPU.Build.0 = Release|Any CPU + {5FA4B1F9-EC6B-47FF-84BC-44B9B2225731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FA4B1F9-EC6B-47FF-84BC-44B9B2225731}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FA4B1F9-EC6B-47FF-84BC-44B9B2225731}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FA4B1F9-EC6B-47FF-84BC-44B9B2225731}.Release|Any CPU.Build.0 = Release|Any CPU + {E7B40CFD-65C1-41BB-80DE-F77723D6BFCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7B40CFD-65C1-41BB-80DE-F77723D6BFCE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7B40CFD-65C1-41BB-80DE-F77723D6BFCE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7B40CFD-65C1-41BB-80DE-F77723D6BFCE}.Release|Any CPU.Build.0 = Release|Any CPU + {2029AC91-E903-43ED-A2E1-53D261747CFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2029AC91-E903-43ED-A2E1-53D261747CFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2029AC91-E903-43ED-A2E1-53D261747CFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2029AC91-E903-43ED-A2E1-53D261747CFA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {0F49A024-9812-48BA-8758-9560824B6BDD} = {3DE60BDA-DC11-4655-A9ED-5342DCD5577D} {4A234D75-019C-479E-8E35-17F85A776EF4} = {42F645A3-CE8D-435E-A671-749CEB739F43} + {5FA4B1F9-EC6B-47FF-84BC-44B9B2225731} = {3DE60BDA-DC11-4655-A9ED-5342DCD5577D} + {E7B40CFD-65C1-41BB-80DE-F77723D6BFCE} = {3DE60BDA-DC11-4655-A9ED-5342DCD5577D} + {2029AC91-E903-43ED-A2E1-53D261747CFA} = {3DE60BDA-DC11-4655-A9ED-5342DCD5577D} EndGlobalSection EndGlobal diff --git a/src/Signal.CANdy.CLI/Program.fs b/src/Signal.CANdy.CLI/Program.fs new file mode 100644 index 0000000..fb0c914 --- /dev/null +++ b/src/Signal.CANdy.CLI/Program.fs @@ -0,0 +1,93 @@ +open System +open Signal.CANdy.Core +open Signal.CANdy.Core.Errors + +module Cli = + type Parsed = { + DbcPath: string option + OutDir: string option + ConfigPath: string option + ShowVersion: bool + ShowHelp: bool + Unknown: string list + } + + let empty: Parsed = + { DbcPath = None + OutDir = None + ConfigPath = None + ShowVersion = false + ShowHelp = false + Unknown = [] } + + let usage () = + String.concat "\n" [ + "Signal.CANdy.CLI — DBC → C code generator"; + ""; + "Usage:"; + " signal-candy --dbc --out [--config ]"; + " signal-candy --version"; + " signal-candy --help"; + ""; + "Options:"; + " --dbc Path to input DBC file (required)"; + " --out Output directory for generated C files (required)"; + " --config Optional YAML config (phys_type, range_check, dispatch, etc.)"; + " --version Print library version and exit"; + " --help, -h Show this help and exit" + ] + + let parse (argv: string array): Parsed = + let rec loop i (st: Parsed): Parsed = + if i >= argv.Length then st + else + match argv.[i] with + | "--dbc" when i + 1 < argv.Length -> loop (i + 2) { st with DbcPath = Some argv.[i + 1] } + | "--out" when i + 1 < argv.Length -> loop (i + 2) { st with OutDir = Some argv.[i + 1] } + | "--config" when i + 1 < argv.Length -> loop (i + 2) { st with ConfigPath = Some argv.[i + 1] } + | "--version" -> loop (i + 1) { st with ShowVersion = true } + | "--help" | "-h" -> loop (i + 1) { st with ShowHelp = true } + | unk -> loop (i + 1) { st with Unknown = st.Unknown @ [ unk ] } + loop 0 empty + +[] +let main argv: int = + let args = Cli.parse argv + + if args.ShowHelp then + printfn "%s" (Cli.usage ()) + 0 + elif args.ShowVersion then + printfn "%s" (Signal.CANdy.Core.Api.version ()) + 0 + elif args.Unknown |> List.isEmpty |> not then + eprintfn "Unknown arguments: %s" (String.Join(" ", args.Unknown)) + eprintfn "\n%s" (Cli.usage ()) + 2 + else + match args.DbcPath, args.OutDir with + | Some dbc, Some outDir -> + try + let cfgOpt = args.ConfigPath + let t = Signal.CANdy.Core.Api.generateFromPaths dbc outDir cfgOpt + let res = t.GetAwaiter().GetResult() + match res with + | Ok files -> + printfn "Code generation successful." + printfn "Headers: %d, Sources: %d, Others: %d" (files.Headers.Length) (files.Sources.Length) (files.Others.Length) + 0 + | Error err -> + let msg = + match err with + | CodeGenError.TemplateError s -> sprintf "Template error: %s" s + | CodeGenError.IoError s -> sprintf "IO error: %s" s + | CodeGenError.Unknown s -> sprintf "Error: %s" s + eprintfn "%s" msg + 1 + with ex -> + eprintfn "Unhandled error: %s" ex.Message + 1 + | _ -> + eprintfn "Missing required arguments." + eprintfn "\n%s" (Cli.usage ()) + 2 diff --git a/src/Signal.CANdy.CLI/Signal.CANdy.CLI.fsproj b/src/Signal.CANdy.CLI/Signal.CANdy.CLI.fsproj new file mode 100644 index 0000000..48b98c0 --- /dev/null +++ b/src/Signal.CANdy.CLI/Signal.CANdy.CLI.fsproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + + + + + + + + + + + diff --git a/src/Signal.CANdy.Core/Api.fs b/src/Signal.CANdy.Core/Api.fs new file mode 100644 index 0000000..f605228 --- /dev/null +++ b/src/Signal.CANdy.Core/Api.fs @@ -0,0 +1,59 @@ +module Signal.CANdy.Core.Api + +open System.Threading.Tasks +open Signal.CANdy.Core.Ir +open Signal.CANdy.Core.Config +open Signal.CANdy.Core.Errors +open Signal.CANdy.Core.Dbc +open Signal.CANdy.Core.Codegen + +/// Returns the current library snapshot version. Placeholder until full API is moved. +let version () = "0.2.1" + +/// Parse a DBC file into IR. Stub for now. +let parseDbc (path: string) : Result = + Signal.CANdy.Core.Dbc.parseDbcFile path + +/// Validate configuration object. Stub for now. +let validateConfig (config: Config) : Result = + Signal.CANdy.Core.Config.validate config + +/// Generate code (sync) using IR and Config. +let generateCode (ir: Ir) (outputPath: string) (config: Config) : Result = + Signal.CANdy.Core.Codegen.generate ir outputPath config + +/// Generate code (async) using IR and Config. +let generateCodeAsync (ir: Ir) (outputPath: string) (config: Config) : Task> = task { + return generateCode ir outputPath config +} + +/// Convenience: parse dbc, load config path (optional), and generate. +let generateFromPaths (dbcPath: string) (outputPath: string) (configPath: string option) : Task> = task { + // Load config (optional path -> YAML; otherwise sensible defaults) + let configResult : Result = + match configPath with + | Some p -> + match Signal.CANdy.Core.Config.loadFromYaml p with + | Ok cfg -> Ok cfg + | Error ve -> Error (CodeGenError.Unknown (sprintf "Config error: %A" ve)) + | None -> + Ok { + PhysType = "float" + PhysMode = "double" + RangeCheck = false + Dispatch = "binary_search" + CrcCounterCheck = false + MotorolaStartBit = "msb" + FilePrefix = "sc_" + } + + match configResult with + | Error e -> return Error e + | Ok cfg -> + // Parse DBC + match Signal.CANdy.Core.Dbc.parseDbcFile dbcPath with + | Error pe -> return Error (CodeGenError.Unknown (sprintf "Parse error: %A" pe)) + | Ok ir -> + // Delegate to codegen (currently stubbed) + return generateCode ir outputPath cfg +} diff --git a/src/Signal.CANdy.Core/Codegen.fs b/src/Signal.CANdy.Core/Codegen.fs new file mode 100644 index 0000000..d85751e --- /dev/null +++ b/src/Signal.CANdy.Core/Codegen.fs @@ -0,0 +1,437 @@ +namespace Signal.CANdy.Core + +open System +open System.IO +open Signal.CANdy.Core.Ir +open Signal.CANdy.Core.Errors + +module Codegen = + + module Utils = + // Build a macro-safe header guard from prefix + base name + let private guard (prefix: string) (baseName: string) = + let raw = (prefix + baseName).ToUpperInvariant() + raw + |> Seq.map (fun ch -> if Char.IsLetterOrDigit ch then ch else '_') + |> Seq.toArray + |> fun arr -> new string(arr) + + let utilsHeaderName (config: Signal.CANdy.Core.Config.Config) = sprintf "%sutils.h" config.FilePrefix + let utilsSourceName (config: Signal.CANdy.Core.Config.Config) = sprintf "%sutils.c" config.FilePrefix + + let utilsHContent (config: Signal.CANdy.Core.Config.Config) = + let banner = sprintf "/* Generated by Signal CANdy\n file_prefix=%s, phys_type=%s, phys_mode=%s, dispatch=%s, motorola_start_bit=%s */\n" config.FilePrefix config.PhysType config.PhysMode config.Dispatch config.MotorolaStartBit + let g = guard config.FilePrefix "utils_h" + banner + (sprintf "#ifndef %s\n#define %s\n\n#include \n#include \n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n// Little-endian bit extraction functions\nuint64_t get_bits_le(const uint8_t* data, uint16_t start_bit, uint16_t length);\n\n// Little-endian bit insertion functions\nvoid set_bits_le(uint8_t* data, uint16_t start_bit, uint16_t length, uint64_t value);\n\n// Big-endian (Motorola) bit extraction\nuint64_t get_bits_be(const uint8_t* data, uint16_t start_bit, uint16_t length);\n\n// Big-endian (Motorola) bit insertion\nvoid set_bits_be(uint8_t* data, uint16_t start_bit, uint16_t length, uint64_t value);\n\n#ifdef __cplusplus\n}\n#endif\n\n#endif // %s" g g g) + + let utilsCContent (config: Signal.CANdy.Core.Config.Config) = + let uH = utilsHeaderName config + let banner = sprintf "/* Generated by Signal CANdy\n file_prefix=%s, phys_type=%s, phys_mode=%s, dispatch=%s, motorola_start_bit=%s */\n" config.FilePrefix config.PhysType config.PhysMode config.Dispatch config.MotorolaStartBit + banner + "#include \"" + uH + "\"\n\n// Little-endian bit extraction\nuint64_t get_bits_le(const uint8_t* data, uint16_t start_bit, uint16_t length) {\n uint64_t value = 0;\n uint16_t byte_offset = start_bit / 8;\n uint16_t bit_offset = start_bit % 8;\n for (uint16_t i = 0; i < 8 && (byte_offset + i) < 8; ++i) {\n value |= (uint64_t)data[byte_offset + i] << (i * 8);\n }\n value >>= bit_offset;\n value &= (1ULL << length) - 1;\n return value;\n}\n\n// Little-endian bit insertion\nvoid set_bits_le(uint8_t* data, uint16_t start_bit, uint16_t length, uint64_t value) {\n uint16_t byte_offset = start_bit / 8;\n uint16_t bit_offset = start_bit % 8;\n uint64_t clear_mask = ((1ULL << length) - 1) << bit_offset;\n for (uint16_t i = 0; i < 8 && (byte_offset + i) < 8; ++i) {\n data[byte_offset + i] &= ~(uint8_t)(clear_mask >> (i * 8));\n }\n uint64_t insert_value = (value & ((1ULL << length) - 1)) << bit_offset;\n for (uint16_t i = 0; i < 8 && (byte_offset + i) < 8; ++i) {\n data[byte_offset + i] |= (uint8_t)(insert_value >> (i * 8));\n }\n}\n\n// Big-endian (Motorola) bit extraction (DBC semantics, sawtooth)\nuint64_t get_bits_be(const uint8_t* data, uint16_t start_bit, uint16_t length) {\n uint64_t value = 0;\n int byte = start_bit / 8;\n int bit = start_bit % 8; // 7..0 within byte, 7 is MSB\n for (uint16_t i = 0; i < length; ++i) {\n int curByte = byte;\n int curBit = bit - (int)i;\n while (curBit < 0) { curBit += 8; ++curByte; } // move to next higher byte\n uint8_t b = data[curByte];\n uint8_t bitval = (uint8_t)((b >> curBit) & 1u);\n value = (value << 1) | bitval; // assemble MSB-first\n }\n return value;\n}\n\n// Big-endian (Motorola) bit insertion (DBC semantics, sawtooth)\nvoid set_bits_be(uint8_t* data, uint16_t start_bit, uint16_t length, uint64_t value) {\n int byte = start_bit / 8;\n int bit = start_bit % 8;\n for (uint16_t i = 0; i < length; ++i) {\n int curByte = byte;\n int curBit = bit - (int)i;\n while (curBit < 0) { curBit += 8; ++curByte; } // move to next higher byte\n uint8_t bitval = (uint8_t)((value >> (length - 1 - i)) & 1u); // MSB-first\n data[curByte] = (uint8_t)((data[curByte] & (uint8_t)~(1u << curBit)) | (uint8_t)(bitval << curBit));\n }\n}" + + // Helper to choose C accessor based on byte order + let accessorNames (byteOrder: ByteOrder) = + match byteOrder with + | ByteOrder.Little -> ("get_bits_le", "set_bits_le") + | ByteOrder.Big -> ("get_bits_be", "set_bits_be") + + // Convert Motorola (BE) start bit from LSB-convention to MSB-convention using sawtooth numbering. + let internal motorolaMsbFromLsb (start: int) (length: int) : int = + let steps = max 0 (length - 1) + let mutable byteIdx = start / 8 + let mutable bitIdx = start % 8 // 0..7, where 7 is MSB + for _ in 1 .. steps do + if bitIdx < 7 then bitIdx <- bitIdx + 1 + else (byteIdx <- byteIdx + 1; bitIdx <- 7) + byteIdx * 8 + bitIdx + + // Choose effective start bit depending on config for Motorola signals; LE stays unchanged. + let chooseStartBit (signal: Signal) (config: Signal.CANdy.Core.Config.Config) : int = + let start = int signal.StartBit + let len = int signal.Length + match signal.ByteOrder with + | ByteOrder.Big -> + match config.MotorolaStartBit.ToLowerInvariant() with + | "lsb" -> motorolaMsbFromLsb start len + | _ -> start // default "msb" + | _ -> start + + // Detect if factor equals 10^-n within tolerance and return integer scale (10^n) + let tryPowerOfTenScale (factor: float) : int64 option = + if factor <= 0.0 then None else + let eps = 1e-12 + let rec loop n = + if n > 9 then None + else + let scaleF = pown 10.0 n + if abs (factor - (1.0 / scaleF)) < eps then Some (int64 (pown 10 n)) else loop (n + 1) + loop 0 + + module Message = + open Utils + + // Sanitize a string to an uppercase C identifier (A-Z0-9_), prefix with N_ if starting with a digit or empty + let private sanitizeEnumIdent (s: string) : string = + let up = s.ToUpperInvariant() + let mapped = + up + |> Seq.map (fun ch -> if Char.IsLetterOrDigit ch then ch else '_') + |> Seq.toArray + |> fun arr -> new string(arr) + let trimmed = + mapped.Trim([|'_'|]) + |> fun t -> if String.IsNullOrWhiteSpace t then "N" else t + let start = trimmed.[0] + if Char.IsDigit start then "N_" + trimmed else trimmed + + let private fieldDecl (s: Signal) = sprintf " float %s;" s.Name + + let private genDecodeForSignal (s: Signal) (doRangeCheck: bool) (config: Signal.CANdy.Core.Config.Config) = + let len = int s.Length + let startEff = chooseStartBit s config + let (getFn, _) = accessorNames s.ByteOrder + let raw = sprintf "raw_%s" s.Name + let signFix = if s.IsSigned then sprintf " if (%s & (1ULL << (%d - 1))) { %s |= ~((1ULL << %d) - 1); }" raw len raw len else "" + let physAssignFloatDouble = sprintf " msg->%s = (float)((double)%s * %.17g + %.17g);" s.Name raw s.Factor s.Offset + let physAssignFloatFloat = sprintf " msg->%s = (float)(((float)%s * (float)%.17g) + (float)%.17g);" s.Name raw s.Factor s.Offset + let physAssign = + match config.PhysType.ToLowerInvariant() with + | "fixed" -> + match Utils.tryPowerOfTenScale s.Factor with + | Some scale when abs (s.Offset - Math.Round(s.Offset)) < 1e-12 -> + sprintf " msg->%s = (float)(((double)%s + (%.0f)) / (double)%d);" s.Name raw (Math.Round(s.Offset * (float scale))) scale + | _ -> (match config.PhysMode.ToLowerInvariant() with | "fixed_float" -> physAssignFloatFloat | _ -> physAssignFloatDouble) + | _ -> (match config.PhysMode.ToLowerInvariant() with | "float" -> physAssignFloatFloat | _ -> physAssignFloatDouble) + let rangeCheck = + if doRangeCheck then + match s.Minimum, s.Maximum with + | Some minV, Some maxV -> Some (sprintf " if (msg->%s < %.17g || msg->%s > %.17g) { return false; }" s.Name minV s.Name maxV) + | Some minV, None -> Some (sprintf " if (msg->%s < %.17g) { return false; }" s.Name minV) + | None, Some maxV -> Some (sprintf " if (msg->%s > %.17g) { return false; }" s.Name maxV) + | _ -> None + else None + [ sprintf " uint64_t %s = 0;" raw + sprintf " // %s: start=%d len=%d factor=%.17g offset=%.17g" s.Name startEff len s.Factor s.Offset + sprintf " %s = %s(data, %d, %d);" raw getFn startEff len + if signFix <> "" then signFix else null + physAssign + match rangeCheck with | Some r -> r | None -> null ] + |> List.choose (fun x -> if isNull (box x) then None else Some x) + |> String.concat "\n" + + let private genEncodeForSignal (s: Signal) (doRangeCheck: bool) (config: Signal.CANdy.Core.Config.Config) = + let len = int s.Length + let startEff = chooseStartBit s config + let (_, setFn) = accessorNames s.ByteOrder + let rangeChecks = + if doRangeCheck then + match s.Minimum, s.Maximum with + | Some minV, Some maxV -> Some (sprintf " if (msg->%s < %.17g || msg->%s > %.17g) { return false; }" s.Name minV s.Name maxV) + | Some minV, None -> Some (sprintf " if (msg->%s < %.17g) { return false; }" s.Name minV) + | None, Some maxV -> Some (sprintf " if (msg->%s > %.17g) { return false; }" s.Name maxV) + | _ -> None + else None + let computeRawDouble = + sprintf " double tmp_%s = ((double)msg->%s - %.17g) / %.17g;\n int64_t raw_%s = (int64_t)(tmp_%s >= 0 ? tmp_%s + 0.5 : tmp_%s - 0.5);" s.Name s.Name s.Offset s.Factor s.Name s.Name s.Name s.Name + let computeRawFloat = + sprintf " float tmp_%s = ((float)msg->%s - (float)%.17g) / (float)%.17g;\n int64_t raw_%s = (int64_t)llroundf(tmp_%s);" s.Name s.Name s.Offset s.Factor s.Name s.Name + let computeRaw = + match config.PhysType.ToLowerInvariant() with + | "fixed" -> + match Utils.tryPowerOfTenScale s.Factor with + | Some scale when abs (s.Offset - Math.Round(s.Offset)) < 1e-12 -> + sprintf " int64_t raw_%s = (int64_t)llround(((double)msg->%s - %.0f) * (double)%d);" s.Name s.Name (Math.Round s.Offset) scale + | _ -> (match config.PhysMode.ToLowerInvariant() with | "fixed_float" -> computeRawFloat | _ -> computeRawDouble) + | _ -> (match config.PhysMode.ToLowerInvariant() with | "float" -> computeRawFloat | _ -> computeRawDouble) + let setBits = sprintf " %s(data, %d, %d, (uint64_t)raw_%s);" setFn startEff len s.Name + [ match rangeChecks with | Some r -> yield r | None -> () + yield computeRaw + yield setBits ] + |> String.concat "\n" + + let private partitionMultiplex (message: Message) = + let switchOpt = message.Signals |> List.tryFind (fun s -> s.MultiplexerIndicator = Some "M") + let baseSignals = message.Signals |> List.filter (fun s -> s.MultiplexerIndicator.IsNone) + let branches = + message.Signals + |> List.choose (fun s -> + match s.MultiplexerIndicator, s.MultiplexerSwitchValue with + | Some ind, Some v when ind = "m" -> Some (v, s) + | _ -> None) + |> List.groupBy fst + |> List.map (fun (k, xs) -> k, xs |> List.map snd) + switchOpt, baseSignals, branches + + let generateMessageFiles (message: Message) (outputPath: string) (config: Signal.CANdy.Core.Config.Config) = + let messageNameLower = message.Name.ToLowerInvariant() + let messageHPath = Path.Combine(outputPath, "include", sprintf "%s.h" messageNameLower) + let messageCPath = Path.Combine(outputPath, "src", sprintf "%s.c" messageNameLower) + + let banner = sprintf "/* Generated by Signal CANdy\n file_prefix=%s, phys_type=%s, phys_mode=%s, dispatch=%s, motorola_start_bit=%s */\n" config.FilePrefix config.PhysType config.PhysMode config.Dispatch config.MotorolaStartBit + + let signalDeclarationsH = message.Signals |> List.map fieldDecl |> String.concat "\n" + + let switchOpt, baseSignals, branches = partitionMultiplex message + let isMux = match switchOpt, branches with | Some _, _ :: _ -> true | _ -> false + let validMacro (sigName: string) = sprintf "%s_VALID_%s" (message.Name.ToUpperInvariant()) (sigName.ToUpperInvariant()) + + let signalDecodeFor s = genDecodeForSignal s config.RangeCheck config + let signalDecodeWithValid s = let body = signalDecodeFor s in if isMux then body + (sprintf "\n msg->valid |= %s;" (validMacro s.Name)) else body + + let signalDecodeC = + match switchOpt, branches with + | Some sw, (_ :: _) -> + let rawVar = sprintf "raw_%s" sw.Name + let swBlock = + let body = signalDecodeWithValid sw + body + (sprintf "\n msg->mux_active = (%s_mux_e)((int)%s);" message.Name rawVar) + let baseBlock = baseSignals |> List.map signalDecodeWithValid |> String.concat "\n\n" + let branchesBlock = + branches + |> List.map (fun (k, sigs) -> + let inner = sigs |> List.map signalDecodeWithValid |> String.concat "\n\n" + [ sprintf " if ((int)%s == %d) {" rawVar k; inner; " }" ] |> String.concat "\n") + |> String.concat "\n" + [ if isMux then " msg->valid = 0u;" else "" + swBlock + baseBlock + branchesBlock ] + |> List.filter (fun s -> not (String.IsNullOrWhiteSpace s)) + |> String.concat "\n\n" + | _ -> message.Signals |> List.map signalDecodeFor |> String.concat "\n\n" + + let signalEncodeC = + match switchOpt, branches with + | Some sw, (_ :: _) -> + let len = int sw.Length + let startEff = Utils.chooseStartBit sw config + let (_, setFn) = Utils.accessorNames sw.ByteOrder + let rangeChecks = + if config.RangeCheck then + match sw.Minimum, sw.Maximum with + | Some minV, Some maxV -> Some (sprintf " if (msg->%s < %.17g || msg->%s > %.17g) { return false; }" sw.Name minV sw.Name maxV) + | Some minV, None -> Some (sprintf " if (msg->%s < %.17g) { return false; }" sw.Name minV) + | None, Some maxV -> Some (sprintf " if (msg->%s > %.17g) { return false; }" sw.Name maxV) + | _ -> None + else None + let computeRawDouble = + sprintf " double tmp_%s = ((double)msg->%s - %.17g) / %.17g;\n int64_t raw_%s = (int64_t)(tmp_%s >= 0 ? tmp_%s + 0.5 : tmp_%s - 0.5);" sw.Name sw.Name sw.Offset sw.Factor sw.Name sw.Name sw.Name sw.Name + let computeRawFloat = + sprintf " float tmp_%s = ((float)msg->%s - (float)%.17g) / (float)%.17g;\n int64_t raw_%s = (int64_t)llroundf(tmp_%s);" sw.Name sw.Name sw.Offset sw.Factor sw.Name sw.Name + let computeRaw = + match config.PhysType.ToLowerInvariant() with + | "fixed" -> + match Utils.tryPowerOfTenScale sw.Factor with + | Some scale when abs (sw.Offset - Math.Round(sw.Offset)) < 1e-12 -> + sprintf " int64_t raw_%s = (int64_t)llround(((double)msg->%s - %.0f) * (double)%d);" sw.Name sw.Name (Math.Round sw.Offset) scale + | _ -> (match config.PhysMode.ToLowerInvariant() with | "fixed_float" -> computeRawFloat | _ -> computeRawDouble) + | _ -> (match config.PhysMode.ToLowerInvariant() with | "float" -> computeRawFloat | _ -> computeRawDouble) + let setBits = sprintf " %s(data, %d, %d, (uint64_t)raw_%s);" setFn startEff len sw.Name + let baseBlock = baseSignals |> List.map (fun s -> genEncodeForSignal s config.RangeCheck config) |> String.concat "\n\n" + let branchesBlock = + branches + |> List.map (fun (k, sigs) -> + let inner = sigs |> List.map (fun s -> genEncodeForSignal s config.RangeCheck config) |> String.concat "\n\n" + [ sprintf " if ((int)raw_%s == %d) {" sw.Name k; inner; " }" ] |> String.concat "\n") + |> String.concat "\n" + [ match rangeChecks with | Some r -> yield r | None -> () + yield computeRaw + yield setBits + yield baseBlock + yield branchesBlock ] + |> List.filter (fun s -> not (String.IsNullOrWhiteSpace s)) + |> String.concat "\n\n" + | _ -> message.Signals |> List.map (fun s -> genEncodeForSignal s config.RangeCheck config) |> String.concat "\n\n" + + let headerContent = + let headerLines = System.Collections.Generic.List() + headerLines.Add(banner) + headerLines.Add (sprintf "#ifndef %s_H" (message.Name.ToUpperInvariant())) + headerLines.Add (sprintf "#define %s_H" (message.Name.ToUpperInvariant())) + headerLines.Add "" + headerLines.Add "#include " + headerLines.Add "#include " + headerLines.Add "" + headerLines.Add "#ifdef __cplusplus" + headerLines.Add "extern \"C\" {" + headerLines.Add "#endif" + headerLines.Add "" + // Value-table enums and to_string prototypes + let vtSignals = message.Signals |> List.choose (fun s -> s.ValueTable |> Option.map (fun vt -> s, vt)) + vtSignals |> List.iter (fun (s, vt) -> + let enumName = sprintf "%s_%s_e" message.Name s.Name + headerLines.Add (sprintf "typedef enum {") + let mutable used = Set.empty + vt |> List.iter (fun (v, name) -> + let baseLabel = sanitizeEnumIdent name + let rec uniqueLabel lbl idx = + let candidate = if idx = 0 then lbl else sprintf "%s_%d" lbl idx + if used.Contains candidate then uniqueLabel lbl (idx+1) else candidate + let label = uniqueLabel baseLabel 0 + used <- used.Add label + headerLines.Add (sprintf " %s_%s_%s = %d," (message.Name.ToUpperInvariant()) (s.Name.ToUpperInvariant()) label v) + ) + headerLines.Add (sprintf "} %s;" enumName) + headerLines.Add "" + headerLines.Add (sprintf "const char* %s_%s_to_string(int v);" message.Name s.Name) + headerLines.Add "" + ) + let switchOpt2, _, branches2 = partitionMultiplex message + let isMux2 = match switchOpt2, branches2 with | Some _, _ :: _ -> true | _ -> false + if isMux2 then + let enumName = sprintf "%s_mux_e" message.Name + headerLines.Add (sprintf "typedef enum { ") + let enumEntries = branches2 |> List.map (fun (k, _) -> sprintf " %s_MUX_%d = %d" (message.Name.ToUpperInvariant()) k k) |> String.concat ",\n" + headerLines.Add enumEntries + headerLines.Add (sprintf "} %s;" enumName) + headerLines.Add "" + message.Signals |> List.iteri (fun idx s -> headerLines.Add (sprintf "#define %s (1u << %d)" (sprintf "%s_VALID_%s" (message.Name.ToUpperInvariant()) (s.Name.ToUpperInvariant())) idx)) + headerLines.Add "" + headerLines.Add "typedef struct {" + headerLines.Add signalDeclarationsH + if isMux2 then + headerLines.Add " uint32_t valid;" + headerLines.Add (sprintf " %s_mux_e mux_active;" message.Name) + headerLines.Add (sprintf "} %s_t;" message.Name) + headerLines.Add "" + headerLines.Add (sprintf "bool %s_decode(%s_t* msg, const uint8_t data[], uint8_t dlc);" message.Name message.Name) + headerLines.Add (sprintf "bool %s_encode(uint8_t data[], uint8_t* out_dlc, const %s_t* msg);" message.Name message.Name) + headerLines.Add "" + headerLines.Add "#ifdef __cplusplus" + headerLines.Add "}" + headerLines.Add "#endif" + headerLines.Add "" + headerLines.Add (sprintf "#endif // %s_H" (message.Name.ToUpperInvariant())) + String.concat "\n" (List.ofSeq headerLines) + + let sourceContent = + let src = System.Collections.Generic.List() + src.Add(banner) + src.Add (sprintf "#include \"%s.h\"" messageNameLower) + let utilsHeader = Utils.utilsHeaderName config + src.Add (sprintf "#include \"%s\"" utilsHeader) + src.Add "#include " + src.Add "#include " + src.Add "" + let vtSignals = message.Signals |> List.choose (fun s -> s.ValueTable |> Option.map (fun vt -> s, vt)) + vtSignals |> List.iter (fun (s, vt) -> + src.Add (sprintf "const char* %s_%s_to_string(int v) {" message.Name s.Name) + src.Add " switch (v) {" + vt |> List.iter (fun (v, name) -> src.Add (sprintf " case %d: return \"%s\";" v (name.Replace("\"","\\\"")))) + src.Add " default: return \"UNKNOWN\";" + src.Add " }" + src.Add "}" + src.Add "" + ) + src.Add (sprintf "bool %s_decode(%s_t* msg, const uint8_t data[], uint8_t dlc) {" message.Name message.Name) + src.Add (sprintf " if (dlc < %d) { return false; }" (int message.Length)) + src.Add signalDecodeC + src.Add " return true;" + src.Add "}" + src.Add "" + src.Add (sprintf "bool %s_encode(uint8_t data[], uint8_t* out_dlc, const %s_t* msg) {" message.Name message.Name) + src.Add " memset(data, 0, 8);" + src.Add (sprintf " *out_dlc = %d;" (int message.Length)) + src.Add signalEncodeC + src.Add " return true;" + src.Add "}" + String.concat "\n" (List.ofSeq src) + + File.WriteAllText(messageHPath, headerContent) + File.WriteAllText(messageCPath, sourceContent) + messageHPath, messageCPath + + module Registry = + let generateRegistryFiles (ir: Ir) (outputPath: string) (config: Signal.CANdy.Core.Config.Config) = + let regHName = sprintf "%sregistry.h" config.FilePrefix + let regCName = sprintf "%sregistry.c" config.FilePrefix + let registryHPath = Path.Combine(outputPath, "include", regHName) + let registryCPath = Path.Combine(outputPath, "src", regCName) + + let guard = + (config.FilePrefix + "registry_h").ToUpperInvariant() + |> Seq.map (fun ch -> if Char.IsLetterOrDigit ch then ch else '_') + |> Seq.toArray + |> fun arr -> new string(arr) + let banner = sprintf "/* Generated by Signal CANdy\n file_prefix=%s, phys_type=%s, phys_mode=%s, dispatch=%s, motorola_start_bit=%s */\n" config.FilePrefix config.PhysType config.PhysMode config.Dispatch config.MotorolaStartBit + let registryHContent = + banner + sprintf "#ifndef %s\n#define %s\n\n#include \n#include \n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\nbool decode_message(uint32_t id, const uint8_t data[], uint8_t dlc, void* msg);\n\n#ifdef __cplusplus\n}\n#endif\n\n#endif // %s" guard guard guard + File.WriteAllText(registryHPath, registryHContent) + + let includes = ir.Messages |> List.map (fun m -> sprintf "#include \"%s.h\"" (m.Name.ToLowerInvariant())) |> String.concat "\n" + + let body = + if config.Dispatch.ToLowerInvariant() = "direct_map" then + let cases = + ir.Messages + |> List.map (fun m -> sprintf " case %du: return %s_decode((%s_t*)msg, data, dlc);" (int m.Id) m.Name m.Name) + |> String.concat "\n" + sprintf "bool decode_message(uint32_t id, const uint8_t data[], uint8_t dlc, void* msg) {\n switch (id) {\n%s\n default: return false;\n }\n}" cases + else + let sorted = ir.Messages |> List.sortBy (fun m -> m.Id) + let entries = sorted |> List.map (fun m -> sprintf " { %du, (decode_func_t)%s_decode }" (int m.Id) m.Name) |> String.concat ",\n" + let table = sprintf "typedef bool (*decode_func_t)(void* msg, const uint8_t data[], uint8_t dlc);\n\ntypedef struct { uint32_t id; decode_func_t func; } decoder_entry_t;\n\nstatic const decoder_entry_t decoders[] = {\n%s\n};\n" entries + let search = + "bool decode_message(uint32_t id, const uint8_t data[], uint8_t dlc, void* msg) {\n int low = 0;\n int high = (int)(sizeof(decoders) / sizeof(decoder_entry_t)) - 1;\n while (low <= high) {\n int mid = low + (high - low) / 2;\n if (decoders[mid].id == id) {\n return decoders[mid].func(msg, data, dlc);\n }\n if (decoders[mid].id < id) low = mid + 1; else high = mid - 1;\n }\n return false;\n}\n" + table + search + let finalC = banner + "#include \n#include \n#include \"" + (sprintf "%sregistry.h" config.FilePrefix) + "\"\n" + includes + "\n\n" + body + File.WriteAllText(registryCPath, finalC) + registryHPath, registryCPath + + // Compatibility shims for legacy includes (utils.h, registry.h) + let private shimHeader (name: string) (target: string) = + let guard = (name.Replace('.', '_') + "_SHIM").ToUpperInvariant() + "#ifndef " + guard + "\n#define " + guard + "\n\n" + + "#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n" + + "#include \"" + target + "\"\n\n" + + "#ifdef __cplusplus\n}\n#endif\n\n" + + "#endif // " + guard + + // Main entry: generate code and return file lists + let generate (ir: Ir) (outputPath: string) (config: Signal.CANdy.Core.Config.Config) : Result = + try + // Ensure output directories + Directory.CreateDirectory (Path.Combine(outputPath, "include")) |> ignore + Directory.CreateDirectory (Path.Combine(outputPath, "src")) |> ignore + + // Clean stale prefixed common files + let keepUtilsH = Utils.utilsHeaderName config + let keepUtilsC = Utils.utilsSourceName config + let keepRegH = sprintf "%sregistry.h" config.FilePrefix + let keepRegC = sprintf "%sregistry.c" config.FilePrefix + let includeDir = Path.Combine(outputPath, "include") + let srcDir = Path.Combine(outputPath, "src") + if Directory.Exists includeDir then + Directory.GetFiles(includeDir, "*utils.h") |> Array.iter (fun f -> if Path.GetFileName(f) <> keepUtilsH then try File.Delete f with _ -> ()) + Directory.GetFiles(includeDir, "*registry.h") |> Array.iter (fun f -> if Path.GetFileName(f) <> keepRegH then try File.Delete f with _ -> ()) + if Directory.Exists srcDir then + Directory.GetFiles(srcDir, "*utils.c") |> Array.iter (fun f -> if Path.GetFileName(f) <> keepUtilsC then try File.Delete f with _ -> ()) + Directory.GetFiles(srcDir, "*registry.c") |> Array.iter (fun f -> if Path.GetFileName(f) <> keepRegC then try File.Delete f with _ -> ()) + + // Generate utils + let uH = Utils.utilsHeaderName config + let uC = Utils.utilsSourceName config + let uHPath = Path.Combine(outputPath, "include", uH) + let uCPath = Path.Combine(outputPath, "src", uC) + File.WriteAllText(uHPath, Utils.utilsHContent config) + File.WriteAllText(uCPath, Utils.utilsCContent config) + + // Emit compatibility shims + let shimUtilsPath = Path.Combine(outputPath, "include", "utils.h") + let shimRegPath = Path.Combine(outputPath, "include", "registry.h") + File.WriteAllText(shimUtilsPath, shimHeader "utils.h" uH) + File.WriteAllText(shimRegPath, shimHeader "registry.h" keepRegH) + + // Messages + let msgFiles = + ir.Messages + |> List.map (fun m -> Message.generateMessageFiles m outputPath config) + // Registry + let regHPath, regCPath = Registry.generateRegistryFiles ir outputPath config + + let sources = msgFiles |> List.map snd |> fun xs -> uCPath :: regCPath :: xs + let headers = msgFiles |> List.map fst |> fun xs -> uHPath :: regHPath :: shimUtilsPath :: shimRegPath :: xs + let others : string list = [] + Ok { Sources = sources; Headers = headers; Others = others } + with ex -> + Error (CodeGenError.Unknown (sprintf "Codegen exception: %s" ex.Message)) diff --git a/src/Signal.CANdy.Core/Config.fs b/src/Signal.CANdy.Core/Config.fs new file mode 100644 index 0000000..09e9b8d --- /dev/null +++ b/src/Signal.CANdy.Core/Config.fs @@ -0,0 +1,101 @@ +namespace Signal.CANdy.Core + +open System +open System.Collections.Generic +open System.Text.RegularExpressions +open YamlDotNet.Serialization +open Signal.CANdy.Core.Errors + +module Config = + type Config = { + PhysType: string + PhysMode: string + RangeCheck: bool + Dispatch: string + CrcCounterCheck: bool + MotorolaStartBit: string + FilePrefix: string + } + + // --- Validation helpers --- + let private validPhysTypes = [ "float"; "fixed" ] + let private validPhysModes = [ "double"; "float"; "fixed_double"; "fixed_float" ] + let private validDispatch = [ "binary_search"; "direct_map" ] + let private validMoto = [ "msb"; "lsb" ] + let private prefixRegex = Regex(@"^[a-zA-Z_][a-zA-Z0-9_]*$") + + let validate (cfg: Config) : Result = + if not (List.contains (cfg.PhysType.ToLowerInvariant()) validPhysTypes) then + Error (ValidationError.InvalidValue (sprintf "Invalid phys_type '%s'" cfg.PhysType)) + elif not (List.contains (cfg.PhysMode.ToLowerInvariant()) validPhysModes) then + Error (ValidationError.InvalidValue (sprintf "Invalid phys_mode '%s'" cfg.PhysMode)) + elif not (List.contains (cfg.Dispatch.ToLowerInvariant()) validDispatch) then + Error (ValidationError.InvalidValue (sprintf "Invalid dispatch '%s'" cfg.Dispatch)) + elif not (List.contains (cfg.MotorolaStartBit.ToLowerInvariant()) validMoto) then + Error (ValidationError.InvalidValue (sprintf "Invalid motorola_start_bit '%s'" cfg.MotorolaStartBit)) + elif not (prefixRegex.IsMatch cfg.FilePrefix) then + Error (ValidationError.InvalidValue (sprintf "Invalid file_prefix '%s'" cfg.FilePrefix)) + else + Ok cfg + + // --- YAML loading helpers --- + let private tryGetString (map: IDictionary) (keys: string list) : string option = + keys + |> List.tryPick (fun k -> + match map.TryGetValue(k) with + | true, v when not (isNull v) -> + match v with + | :? string as s -> Some s + | _ -> Some (string v) + | _ -> None) + + let private tryGetBool (map: IDictionary) (keys: string list) : bool option = + keys + |> List.tryPick (fun k -> + match map.TryGetValue(k) with + | true, v when not (isNull v) -> + match v with + | :? bool as b -> Some b + | :? string as s -> + match Boolean.TryParse(s) with | true, b -> Some b | _ -> None + | _ -> None + | _ -> None) + + /// Load a YAML config file and return a validated Config + let loadFromYaml (configPath: string) = + try + use reader = new System.IO.StreamReader(configPath) + let yaml = reader.ReadToEnd() + let deserializer = DeserializerBuilder().Build() + let map = deserializer.Deserialize>(yaml) + + let phys = tryGetString map [ "phys_type"; "PhysType" ] |> Option.defaultValue "float" + let physModeRaw = tryGetString map [ "phys_mode"; "PhysMode" ] + let physMode = + match physModeRaw with + | Some m -> m + | None -> + match phys.ToLowerInvariant() with + | "float" -> "double" + | "fixed" -> "fixed_double" + | _ -> "double" + + let range = tryGetBool map [ "range_check"; "RangeCheck" ] |> Option.defaultValue false + let disp = tryGetString map [ "dispatch"; "Dispatch" ] |> Option.defaultValue "binary_search" + let crc = tryGetBool map [ "crc_counter_check"; "CrcCounterCheck" ] |> Option.defaultValue false + let moto = tryGetString map [ "motorola_start_bit"; "MotorolaStartBit" ] |> Option.defaultValue "msb" + let filePrefix = tryGetString map [ "file_prefix"; "FilePrefix" ] |> Option.defaultValue "sc_" + + let cfg = { + PhysType = phys + PhysMode = physMode + RangeCheck = range + Dispatch = disp + CrcCounterCheck = crc + MotorolaStartBit = moto + FilePrefix = filePrefix + } + + validate cfg + with ex -> + Error (ValidationError.IoError ex.Message) diff --git a/src/Signal.CANdy.Core/Dbc.fs b/src/Signal.CANdy.Core/Dbc.fs new file mode 100644 index 0000000..15635f7 --- /dev/null +++ b/src/Signal.CANdy.Core/Dbc.fs @@ -0,0 +1,280 @@ +namespace Signal.CANdy.Core + +open System +open System.IO +open System.Text.RegularExpressions + +open Signal.CANdy.Core.Ir +open Signal.CANdy.Core.Errors + +module Dbc = + + let private isVectorInternalMessageName (name: string) = + name = "VECTOR__INDEPENDENT_SIG_MSG" + + // Compute covered bit positions (0..(DLC*8-1)) for a signal, respecting byte order. + // For BE (Motorola), use sawtooth numbering with MSB-based start bit. + let private coveredBits (s: Signal) : int list = + let start = int s.StartBit + let len = int s.Length + match s.ByteOrder with + | ByteOrder.Little -> [ for i in 0 .. len - 1 -> start + i ] + | ByteOrder.Big -> + let byte0 = start / 8 + let bit0 = start % 8 // 7..0 + [ for i in 0 .. len - 1 -> + let mutable curByte = byte0 + let mutable curBit = bit0 - i + while curBit < 0 do curBit <- curBit + 8; curByte <- curByte + 1 + curByte * 8 + curBit ] + + let private validateDuplicates (messages: Message list) : string option = + messages + |> List.groupBy (fun m -> m.Id) + |> List.tryPick (fun (id, ms) -> if List.length ms > 1 then Some (sprintf "Duplicate message ID %u found." id) else None) + + // Determine whether two signals can coexist in the same frame instance. + let private canCoexist (a: Signal) (b: Signal) : bool = + let aMuxI, aMuxV = a.MultiplexerIndicator, a.MultiplexerSwitchValue + let bMuxI, bMuxV = b.MultiplexerIndicator, b.MultiplexerSwitchValue + match aMuxI, aMuxV, bMuxI, bMuxV with + | Some indA, Some va, Some indB, Some vb when indA = "m" && indB = "m" && va <> vb -> false + | _ -> true + + let private validateOverlaps (messages: Message list) : string option = + let overlapsInMessage (m: Message) : string option = + let rec checkPairs (signals: Signal list) : string option = + match signals with + | [] | [_] -> None + | s::rest -> + let sBits = coveredBits s |> Set.ofList + let conflict = + rest + |> List.tryPick (fun t -> + if canCoexist s t then + let tBits = coveredBits t |> Set.ofList + let inter = Set.intersect sBits tBits + if not (Set.isEmpty inter) then Some (sprintf "Signal '%s' in message '%s' overlaps with other signals." t.Name m.Name) else None + else None) + match conflict with + | Some e -> Some e + | None -> checkPairs rest + checkPairs m.Signals + messages |> List.tryPick overlapsInMessage + + let private validateExceedsDlc (messages: Message list) : string option = + let exceedInMessage (m: Message) : string option = + let totalBits = int m.Length * 8 + m.Signals + |> List.tryPick (fun s -> + let bits = coveredBits s + if bits |> List.exists (fun b -> b < 0 || b >= totalBits) then + Some (sprintf "Signal '%s' in message '%s' exceeds the message DLC of %d bytes." s.Name m.Name (int m.Length)) + else None) + messages |> List.tryPick exceedInMessage + + let private validateDuplicateIdsFromText (filePath: string) : string option = + try + let lines = File.ReadAllLines(filePath) + let ids = + lines + |> Seq.choose (fun line -> + let t = line.Trim() + if t.StartsWith("BO_ ") then + let parts = t.Split([|' '; ':'|], StringSplitOptions.RemoveEmptyEntries) + if parts.Length >= 3 then + let name = parts.[2] + if isVectorInternalMessageName name then None else + match Int32.TryParse(parts.[1]) with + | true, id -> Some id + | _ -> None + else None + else None) + |> Seq.toList + ids + |> List.groupBy id + |> List.tryPick (fun (id, xs) -> if List.length xs > 1 then Some (sprintf "Duplicate message ID %d found." id) else None) + with _ -> None + + let private tryBuildSignalMuxMap (filePath: string) : Map = + let mutable currentMsg : string option = None + let mutable entries : (string*string*(string option * int option)) list = [] + try + for raw in File.ReadLines(filePath) do + let line = raw.Trim() + if line.StartsWith("BO_ ") then + let parts = line.Split([|' '; ':'|], StringSplitOptions.RemoveEmptyEntries) + if parts.Length >= 3 then currentMsg <- Some parts.[2] + elif line.StartsWith("SG_") then + match currentMsg with + | None -> () + | Some msgName -> + let colonIdx = line.IndexOf(':') + if colonIdx > 0 then + let left = line.Substring(0, colonIdx) + let parts = left.Split([|' '|], StringSplitOptions.RemoveEmptyEntries) + if parts.Length >= 2 then + let sigName = parts.[1] + let tokens = parts |> Array.skip 2 + let mutable muxInd : string option = None + let mutable muxVal : int option = None + for t in tokens do + if t = "M" then muxInd <- Some "M" + elif t.Length >= 1 && t.[0] = 'm' then + muxInd <- Some "m" + if t.Length > 1 then + let vStr = t.Substring(1) + match Int32.TryParse(vStr) with + | true, v -> muxVal <- Some v + | _ -> () + if muxInd.IsSome || muxVal.IsSome then + entries <- (msgName, sigName, (muxInd, muxVal)) :: entries + entries |> List.fold (fun acc (m,s,meta) -> acc |> Map.add (m,s) meta) Map.empty + with _ -> Map.empty + + let private tryBuildSignalMetaMap (filePath: string) : Map = + let mutable currentMsg : string option = None + let mutable entries : (string*string*(bool*ByteOrder)) list = [] + try + for raw in File.ReadLines(filePath) do + let line = raw.Trim() + if line.StartsWith("BO_ ") then + let parts = line.Split([|' '; ':'|], StringSplitOptions.RemoveEmptyEntries) + if parts.Length >= 3 then currentMsg <- Some parts.[2] + elif line.StartsWith("SG_") then + match currentMsg with + | None -> () + | Some msgName -> + let parts = line.Split([|' '|], StringSplitOptions.RemoveEmptyEntries) + if parts.Length >= 2 then + let sigName = parts.[1] + let colonIdx = line.IndexOf(':') + if colonIdx > 0 && colonIdx < line.Length - 1 then + let after = line.Substring(colonIdx + 1).Trim() + let atIdx = after.IndexOf('@') + if atIdx >= 0 && atIdx + 2 < after.Length then + let endianCh = after.[atIdx + 1] + let signCh = after.[atIdx + 2] + if (signCh = '+' || signCh = '-') && (endianCh = '0' || endianCh = '1') then + let isSigned = signCh = '-' + let order = if endianCh = '0' then ByteOrder.Big else ByteOrder.Little + entries <- (msgName, sigName, (isSigned, order)) :: entries + entries |> List.fold (fun acc (m,s,meta) -> acc |> Map.add (m,s) meta) Map.empty + with _ -> Map.empty + + let private buildIdNameMap (filePath: string) : Map = + let mutable m : Map = Map.empty + try + for raw in File.ReadLines(filePath) do + let line = raw.Trim() + if line.StartsWith("BO_ ") then + let parts = line.Split([|' '; ':'|], StringSplitOptions.RemoveEmptyEntries) + if parts.Length >= 3 then + match Int32.TryParse(parts.[1]) with + | true, id -> + let name = parts.[2] + if not (isVectorInternalMessageName name) then + m <- m |> Map.add id name + | _ -> () + m + with _ -> Map.empty + + let private tryBuildValueTableMap (filePath: string) : Map = + try + let idName = buildIdNameMap filePath + let mutable map : Map = Map.empty + let rx = Regex(@"^VAL_\s+(\d+)\s+(\S+)\s+(.*);\s*$") + let rxPair = Regex(@"([+-]?\d+)\s+""([^""]*)""") + for raw in File.ReadLines(filePath) do + let line = raw.Trim() + let m = rx.Match(line) + if m.Success then + let idStr = m.Groups.[1].Value + let sigName = m.Groups.[2].Value + let pairsStr = m.Groups.[3].Value + match Int32.TryParse(idStr) with + | true, id when idName.ContainsKey id -> + let msgName = idName.[id] + let pairs = + rxPair.Matches(pairsStr) + |> Seq.cast + |> Seq.choose (fun mm -> + match Int32.TryParse(mm.Groups.[1].Value) with + | true, v -> Some (v, mm.Groups.[2].Value) + | _ -> None) + |> Seq.toList + if pairs.Length > 0 then + map <- map |> Map.add (msgName, sigName) pairs + | _ -> () + map + with _ -> Map.empty + + /// Parse DBC file into Core IR with validation + let parseDbcFile (filePath: string) : Result = + match validateDuplicateIdsFromText filePath with + | Some err -> Error (ParseError.InvalidDbc err) + | None -> + try + let metaMap = tryBuildSignalMetaMap filePath + let muxMap = tryBuildSignalMuxMap filePath + let valMap = tryBuildValueTableMap filePath + let dbc = DbcParserLib.Parser.ParseFromPath(filePath) + + let messages = + dbc.Messages + |> Seq.filter (fun msg -> not (isVectorInternalMessageName msg.Name)) + |> Seq.map (fun msg -> + let signals = + msg.Signals + |> Seq.map (fun s -> + let minVal = if Double.IsNaN s.Minimum then None else Some s.Minimum + let maxVal = if Double.IsNaN s.Maximum then None else Some s.Maximum + let inferredSigned, inferredOrder = + match metaMap |> Map.tryFind (msg.Name, s.Name) with + | Some (isS, ord) -> isS, ord + | None -> (s.Minimum < 0.0), ByteOrder.Little + let muxInd, muxVal = + match muxMap |> Map.tryFind (msg.Name, s.Name) with + | Some (i, v) -> i, v + | None -> None, None + { + Name = s.Name + StartBit = s.StartBit + Length = s.Length + Factor = s.Factor + Offset = s.Offset + Minimum = minVal + Maximum = maxVal + Unit = s.Unit + IsSigned = inferredSigned + IsCrc = s.Name.ToLowerInvariant().Contains("crc") || s.Name.ToLowerInvariant().Contains("checksum") + IsCounter = s.Name.ToLowerInvariant().Contains("counter") || s.Name.ToLowerInvariant().Contains("alive") + ByteOrder = inferredOrder + MultiplexerIndicator = muxInd + MultiplexerSwitchValue = muxVal + ValueTable = (valMap |> Map.tryFind (msg.Name, s.Name)) + Receivers = [] + } + ) + |> List.ofSeq + + { + Name = msg.Name + Id = msg.ID + IsExtended = (msg.ID > 0x7FFu) + Length = msg.DLC + Signals = signals + Sender = msg.Transmitter + Receivers = [] + } + ) + |> List.ofSeq + + let combineValidators validators = + validators |> List.tryPick id + + match combineValidators [ validateDuplicates messages; validateOverlaps messages; validateExceedsDlc messages ] with + | Some err -> Error (ParseError.InvalidDbc err) + | None -> Ok { Messages = messages } + with ex -> + Error (ParseError.IoError ex.Message) diff --git a/src/Signal.CANdy.Core/Errors.fs b/src/Signal.CANdy.Core/Errors.fs new file mode 100644 index 0000000..6bb8171 --- /dev/null +++ b/src/Signal.CANdy.Core/Errors.fs @@ -0,0 +1,24 @@ +namespace Signal.CANdy.Core + +module Errors = + type ParseError = + | InvalidDbc of string + | IoError of string + | Unknown of string + + type CodeGenError = + | TemplateError of string + | IoError of string + | Unknown of string + + type ValidationError = + | InvalidValue of string + | MissingField of string + | IoError of string + | Unknown of string + + type GeneratedFiles = { + Sources: string list + Headers: string list + Others: string list + } diff --git a/src/Signal.CANdy.Core/Ir.fs b/src/Signal.CANdy.Core/Ir.fs new file mode 100644 index 0000000..f3707ca --- /dev/null +++ b/src/Signal.CANdy.Core/Ir.fs @@ -0,0 +1,45 @@ +namespace Signal.CANdy.Core + +module Ir = + + type ByteOrder = + | Little + | Big + + type SignalType = + | Signed + | Unsigned + | Float + + type Signal = { + Name: string + StartBit: uint16 + Length: uint16 + Factor: float + Offset: float + Minimum: float option + Maximum: float option + Unit: string + IsSigned: bool + IsCrc: bool + IsCounter: bool + ByteOrder: ByteOrder + MultiplexerIndicator: string option + MultiplexerSwitchValue: int option + ValueTable: (int * string) list option + Receivers: string list + } + + type Message = { + Name: string + Id: uint32 + IsExtended: bool + Length: uint16 + Signals: Signal list + Sender: string + Receivers: string list + } + + type Ir = { + Messages: Message list + } diff --git a/src/Signal.CANdy.Core/Library.fs b/src/Signal.CANdy.Core/Library.fs new file mode 100644 index 0000000..53caaab --- /dev/null +++ b/src/Signal.CANdy.Core/Library.fs @@ -0,0 +1,5 @@ +namespace Signal.CANdy.Core + +module Say = + let hello name = + printfn "Hello %s" name diff --git a/src/Signal.CANdy.Core/README.NuGet.md b/src/Signal.CANdy.Core/README.NuGet.md new file mode 100644 index 0000000..fe01c33 --- /dev/null +++ b/src/Signal.CANdy.Core/README.NuGet.md @@ -0,0 +1,48 @@ +# SignalCandy.Core + +Core library for SignalCandy: parse DBC files, validate config, and generate C99 encode/decode code. + +- Repo: https://github.com/InitusNovus/Signal-CANdy +- License: MIT + +## Install + +``` +dotnet add package SignalCandy.Core --version 0.2.1 +``` + +## Quick start (F#) + +```fsharp +open Signal.CANdy.Core + +let dbcPath = "examples/sample.dbc" +let outDir = "gen" + +match Api.parseDbc dbcPath with +| Ok ir -> + match Api.generateCode(ir, outDir, Config.defaults) with + | Ok files -> printfn "Generated: headers=%d sources=%d others=%d" (List.length files.Headers) (List.length files.Sources) (List.length files.Others) + | Error e -> printfn "CodeGen error: %A" e +| Error e -> printfn "Parse error: %A" e +``` + +Or use the higher-level path-based API (loads optional YAML config): + +```fsharp +open System.Threading.Tasks +open Signal.CANdy.Core + +let run () : Task = task { + let! result = Api.generateFromPaths("examples/sample.dbc", "gen", None) + match result with + | Ok files -> printfn "OK: %A" files + | Error e -> printfn "Error: %A" e +} +``` + +## What's inside +- DBC parsing with validations (duplicate IDs, overlaps, DLC bounds) +- YAML config loader/validator (YamlDotNet) +- C99 codegen (headers/sources + registry/utils) +- Result-based API with discriminated union errors diff --git a/src/Signal.CANdy.Core/Signal.CANdy.Core.fsproj b/src/Signal.CANdy.Core/Signal.CANdy.Core.fsproj new file mode 100644 index 0000000..0a2df69 --- /dev/null +++ b/src/Signal.CANdy.Core/Signal.CANdy.Core.fsproj @@ -0,0 +1,40 @@ + + + + net8.0 + true + InitusNovus + InitusNovus + SignalCandy.Core + Core library for SignalCandy: DBC parsing, config validation, and C99 code generation utilities. + https://github.com/InitusNovus/Signal-CANdy + git + CAN;DBC;codegen;C;F#;embedded + true + false + 0.2.1 + MIT + README.NuGet.md + true + snupkg + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/Signal.CANdy/Library.fs b/src/Signal.CANdy/Library.fs new file mode 100644 index 0000000..c455269 --- /dev/null +++ b/src/Signal.CANdy/Library.fs @@ -0,0 +1,96 @@ +namespace Signal.CANdy + +open System +open System.Threading.Tasks + +/// Exceptions for C#-friendly facade API +/// +/// Base exception for Signal.CANdy Facade. +/// +type SignalCandyException(message: string) = + inherit Exception(message) + +/// +/// Configuration validation related errors. +/// +type SignalCandyValidationException(message: string) = + inherit SignalCandyException(message) + +/// +/// DBC parsing related errors. +/// +type SignalCandyParseException(message: string) = + inherit SignalCandyException(message) + +/// +/// Code generation related errors. +/// +type SignalCandyCodeGenException(message: string) = + inherit SignalCandyException(message) + +/// C#-friendly Facade wrapping Signal.CANdy.Core +/// +/// High-level .NET-friendly API over Signal.CANdy.Core. +/// Prefer for most use-cases. +/// +type GeneratorFacade() = + /// + /// Library version string. + /// + member _.Version : string = Signal.CANdy.Core.Api.version () + + /// + /// Validates a configuration object; throws on error. + /// + member _.ValidateConfig(cfg: Signal.CANdy.Core.Config.Config) : unit = + match Signal.CANdy.Core.Config.validate cfg with + | Ok _ -> () + | Error e -> + let msg = match e with + | Signal.CANdy.Core.Errors.ValidationError.InvalidValue s -> s + | Signal.CANdy.Core.Errors.ValidationError.MissingField s -> s + | Signal.CANdy.Core.Errors.ValidationError.IoError s -> s + | Signal.CANdy.Core.Errors.ValidationError.Unknown s -> s + raise (SignalCandyValidationException(msg)) + + /// + /// Parses a DBC file and returns the intermediate representation; throws on error. + /// + member _.ParseDbc(path: string) : Signal.CANdy.Core.Ir.Ir = + match Signal.CANdy.Core.Api.parseDbc path with + | Ok ir -> ir + | Error e -> + let msg = match e with + | Signal.CANdy.Core.Errors.ParseError.InvalidDbc s -> s + | Signal.CANdy.Core.Errors.ParseError.IoError s -> s + | Signal.CANdy.Core.Errors.ParseError.Unknown s -> s + raise (SignalCandyParseException(msg)) + + /// + /// Generates C code from IR and configuration; throws on error. + /// + member _.GenerateCode(ir: Signal.CANdy.Core.Ir.Ir, outputPath: string, cfg: Signal.CANdy.Core.Config.Config) : Signal.CANdy.Core.Errors.GeneratedFiles = + match Signal.CANdy.Core.Api.generateCode ir outputPath cfg with + | Ok files -> files + | Error e -> + let msg = match e with + | Signal.CANdy.Core.Errors.CodeGenError.TemplateError s -> s + | Signal.CANdy.Core.Errors.CodeGenError.IoError s -> s + | Signal.CANdy.Core.Errors.CodeGenError.Unknown s -> s + raise (SignalCandyCodeGenException(msg)) + + /// + /// High-level convenience: loads optional YAML config, parses DBC, and generates code. + /// Throws on error. + /// + member _.GenerateFromPathsAsync(dbcPath: string, outputPath: string, configPath: string) : Task = task { + let! res = Signal.CANdy.Core.Api.generateFromPaths dbcPath outputPath (if String.IsNullOrWhiteSpace configPath then None else Some configPath) + match res with + | Ok files -> return files + | Error e -> + let msg = match e with + | Signal.CANdy.Core.Errors.CodeGenError.TemplateError s -> s + | Signal.CANdy.Core.Errors.CodeGenError.IoError s -> s + | Signal.CANdy.Core.Errors.CodeGenError.Unknown s -> s + return raise (SignalCandyCodeGenException(msg)) + } diff --git a/src/Signal.CANdy/README.NuGet.md b/src/Signal.CANdy/README.NuGet.md new file mode 100644 index 0000000..a00e4bb --- /dev/null +++ b/src/Signal.CANdy/README.NuGet.md @@ -0,0 +1,70 @@ +# SignalCandy + +C#-friendly facade over SignalCandy Core. Wraps Result-based F# API with exceptions and .NET-friendly types. + +- Repo: https://github.com/InitusNovus/Signal-CANdy +- License: MIT + +## Install + +``` +dotnet add package SignalCandy --version 0.2.1 +``` + +## Quick start (C#) + +```csharp +using System.Threading.Tasks; +using SignalCandy; + +class Demo +{ + static async Task Main() + { + var facade = new GeneratorFacade(); + var files = await facade.GenerateFromPathsAsync( + dbcPath: "examples/sample.dbc", + outputPath: "gen", + configPath: null + ); + System.Console.WriteLine($"Headers: {files.Headers.Count}, Sources: {files.Sources.Count}, Others: {files.Others.Count}"); + } +} +``` + +## What's inside +- Exceptions for validation/parse/codegen errors +- Simple async path-based API +- Access to Core IR and fine-grained APIs if needed + +## Error handling + +```csharp +using System; +using System.Threading.Tasks; +using SignalCandy; + +class Demo +{ + static async Task Main() + { + var g = new GeneratorFacade(); + try + { + await g.GenerateFromPathsAsync("examples/fixed_suite.dbc", "gen", "examples/config.yaml"); + } + catch (SignalCandyValidationException ex) + { + Console.Error.WriteLine($"Config error: {ex.Message}"); + } + catch (SignalCandyParseException ex) + { + Console.Error.WriteLine($"DBC parse error: {ex.Message}"); + } + catch (SignalCandyCodeGenException ex) + { + Console.Error.WriteLine($"Codegen error: {ex.Message}"); + } + } +} +``` diff --git a/src/Signal.CANdy/Signal.CANdy.fsproj b/src/Signal.CANdy/Signal.CANdy.fsproj new file mode 100644 index 0000000..c98ab5f --- /dev/null +++ b/src/Signal.CANdy/Signal.CANdy.fsproj @@ -0,0 +1,36 @@ + + + + net8.0 + true + InitusNovus + InitusNovus + SignalCandy + C#-friendly facade for SignalCandy Core, wrapping Result with exceptions. + https://github.com/InitusNovus/Signal-CANdy + git + CAN;DBC;codegen;C;F#;facade + true + false + 0.2.1 + MIT + README.NuGet.md + true + snupkg + true + + + + + + + + + + + + + + + +