diff --git a/AGENTS.md b/AGENTS.md index 1bc4b57..344abaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,13 +19,15 @@ For consumer-app usage patterns, read `docs/ai/SDK_USAGE.md` first and `docs/ai/ ```powershell dotnet build .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx dotnet build .\Samples\Samples.slnx -dotnet test .\Sdk\tests\UnitTests\UnitTests.csproj --no-build +dotnet test --project .\Sdk\tests\UnitTests\UnitTests.csproj --no-build dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait Category=ibt dotnet pack .\Sdk\SVappsLAB.iRacingTelemetrySDK\SVappsLAB.iRacingTelemetrySDK.csproj ``` Run `Category=live` smoke tests only with iRacing running in an active Windows session. Run `Category=manual` tests only when the local developer setup matches the test requirements. +`global.json` pins `test.runner` to `Microsoft.Testing.Platform`, so `dotnet test` needs `--project`/`--solution` (a bare positional path is rejected) and does not understand VSTest-style `--filter`. Use `dotnet run --project`, not `dotnet test`, for `SmokeTests` - its `launchSettings.json` injects a default `--filter-class` that `dotnet test` combines with (rather than overrides with) any `--filter-trait` you add, silently yielding "zero tests ran". See `Sdk/tests/README.md` for details. + ## Style - Target .NET 8 for the SDK. diff --git a/NUGET.md b/NUGET.md index 6b798b7..694aadf 100644 --- a/NUGET.md +++ b/NUGET.md @@ -1,18 +1,30 @@ -# iRacing Telemetry SDK for C# .NET +# iRacing SDK for C# .NET -High-performance .NET SDK for accessing **live telemetry data** from iRacing simulator and **IBT file playback**. Features compile-time code generation for strongly-typed telemetry access with lock-free performance optimizations. +High-performance .NET SDK for accessing **live telemetry data** from iRacing simulator, **IBT file playback**, and **simulator control**. Features compile-time code generation for strongly-typed telemetry access with lock-free performance optimizations, plus a command API for driving cameras, replay, pit service, and more. ## Why Use This SDK? - **Type Safety**: Enum-based telemetry variables with IntelliSense/Copilot support and compile-time validation - **High Performance**: Processes 600,000+ telemetry records/second with lock-free data streaming architecture - **Background Processing**: Dedicated threads for telemetry collection and processing - your app's processing speed never blocks the streaming telemetry data -- **Modern Async API**: Async data streams with async/await patterns and automatic backpressure handling +- **Modern Async API**: Async data streams with async/await patterns, bounded buffering, and automatic overload handling +- **Dynamic Variable Lookup**: Look up any telemetry variable by name at runtime with `GetValue(string)` - no compile-time struct required - **Live Telemetry**: Real-time access to speed, RPM, tire data, and 200+ variables during iRacing sessions - **IBT File Support**: Cross-platform playback of historical telemetry using the same strongly-typed API - **Robust Buffering**: 60-sample ring buffer (1 second at 60Hz) ensures reliable data delivery even when your app can't keep pace - **Simulator Control**: Send commands *to* the simulator - cameras, replay, pit service, chat, telemetry recording, and more +## How It Compares + +Most .NET iRacing libraries are thin wrappers over the simulator's shared-memory layout — variables looked up by string name at runtime, data delivered through events on the caller's thread. This SDK treats telemetry as a **typed, high-throughput data stream** instead: + +- **Compile-time generated structs** instead of runtime string/dictionary lookups — typos are build errors, and only the variables you declare are ever decoded +- **Non-blocking async data streams with bounded backpressure** instead of callbacks that block further frame processing until your handler returns — your processing speed never stalls data collection +- **The same strongly-typed API for live and IBT playback** — most alternatives are live-only, or treat file playback as a separate path +- **Built-in `System.Diagnostics.Metrics`** integration (rates, dropped records, histograms), so telemetry health plugs directly into your existing observability stack + +Full comparison and design rationale: [Architecture and Design](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/ARCHITECTURE.md) + ## Architecture Benefits **Production-Ready Data Streaming:** @@ -142,6 +154,7 @@ See the [SimControl sample](https://github.com/SVappsLAB/iRacingTelemetrySDK/tre - **[Getting Started Guide](https://github.com/SVappsLAB/iRacingTelemetrySDK#readme)** - Setup and basic usage - **[Sample Projects](https://github.com/SVappsLAB/iRacingTelemetrySDK/tree/main/Samples)** - Basic monitoring, data export, track analysis +- **[Architecture and Design](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/ARCHITECTURE.md)** - Threading model, buffering, performance, and metrics - **[Advanced Usage](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior - **[Migration Guide](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/MIGRATION_GUIDE.md)** - Upgrading from previous versions - **[GitHub Repository](https://github.com/SVappsLAB/iRacingTelemetrySDK)** - Source code and releases diff --git a/README.md b/README.md index 85835c1..eb08d53 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e ## Table of Contents - [Features](#features) +- [How It Compares](#how-it-compares) - [Requirements](#requirements) - [Quick Example](#quick-example) - [Getting Started](#getting-started) @@ -17,10 +18,9 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e - [Controlling the Simulator](#controlling-the-simulator) - [Samples](#samples) - [Advanced Usage](./docs/ADVANCED.md) +- [Architecture and Design](./docs/ARCHITECTURE.md) - [Documentation](#documentation) - [AI-Assisted Development](#ai-assisted-development) -- [Performance and Design](#performance-and-design) -- [Performance Monitoring](#performance-monitoring) - [Building from Source](#building-from-source) - [License](#license) @@ -31,15 +31,45 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e - **Background Processing**: Dedicated threads for telemetry collection and processing - your app's processing speed never blocks the streaming telemetry data - **Live Telemetry**: Real-time access to 200+ variables including speed, RPM, tire data during iRacing sessions - **IBT File Playback**: Analyze historical telemetry using the same API as live data -- **Modern Async API**: Async data streams with automatic backpressure handling +- **Modern Async API**: Async data streams with bounded buffering and automatic overload handling +- **Dynamic Variable Lookup**: Look up any telemetry variable by name at runtime via `GetValue(string)` - useful when the variable set isn't known at compile time - **Built-in Metrics**: Integrated performance monitoring via System.Diagnostics.Metrics - **Pause and Resume**: Control data flow while background processing continues - **Sim Control**: Remotely control the simulator (pit commands, replay, cameras, chat, and more) +## How It Compares + +There are several .NET libraries for reading iRacing telemetry. +Most are thin wrappers over the iRacing shared-memory layout: variables are done by string name at runtime, receive data through events on the caller's thread. + +This SDK takes a different approach. It treats telemetry as a **typed, high-throughput data stream** rather than a bag of named values. + +| | **iRacingTelemetrySDK** | **Typical .NET iRacing libraries** | +|---|---|---| +| **Variable access** | Compile-time generated `TelemetryData` struct — only the variables you declare | Runtime lookup by string name or dictionary indexing | +| **Type safety** | Enum-based selection, validated at build time, full IntelliSense | Strings resolved at runtime; typos surface as runtime errors or nulls | +| **API model** | Async data streams with `async`/`await`, bounded buffering, and automatic overload handling | Blocking event handlers or manual polling loops | +| **Threading** | Dedicated background tasks for collection and YAML parsing — your handler never blocks the data source | Callbacks commonly block further frame processing until they return | +| **Backpressure** | Bounded 60-sample ring buffer with drop-oldest; slow consumers cost bounded data, never memory | Not supported | +| **Live + IBT parity** | Identical strongly-typed API for both | Frequently separate code paths, or live-only | +| **Throughput** | 600,000+ records/sec on IBT playback | Unknown | +| **Observability** | Built-in `System.Diagnostics.Metrics` counters and histograms | Not supported | +| **Simulator control** | Pit, replay, camera, chat, and broadcast commands included | Varies | +| **AI agent support** | Dedicated agent-facing usage and reference docs | Rare | + + +**Why it's fast:** + +- **Source-generated structs** eliminate runtime reflection and string lookups from the hot path — only the variables you asked for are ever decoded +- **Lock-free bounded streams** keep telemetry current under load instead of queueing unboundedly behind a slow consumer +- **Minimal-allocation reads** — each sample is copied once into a reused buffer (guarding against iRacing overwriting it mid-read), then decoded field-by-field via `ReadOnlySpan` with no further allocations, keeping steady-state GC pressure near zero +- **Independent background tasks** mean CPU-intensive session-info YAML parsing never stalls the 60Hz telemetry path + +Full details, including the threading model, buffering semantics, and built-in metrics: **[Architecture and Design](./docs/ARCHITECTURE.md)** + ## Requirements - **.NET 8.0+** -- **Windows** for live iRacing telemetry ## Quick Example @@ -212,20 +242,51 @@ All telemetry properties are nullable (`float?`, `int?`, `bool?`) to accurately **Recommended patterns for handling null values:** ```csharp -// ✅ Null-conditional formatting +// Null-conditional formatting var speedDisplay = $"Speed: {data.Speed?.ToString("F1") ?? "N/A"}"; -// ✅ Direct arithmetic (preserves null semantics) +// Direct arithmetic (preserves null semantics) var speedMph = data.Speed * 2.23694f; // Result is null if Speed is null -// ✅ Explicit null handling +// Explicit null handling var speed = data.Speed ?? 0f; var hasValue = data.Speed.HasValue; -// ✅ Boolean checks +// Boolean checks if (data.IsOnTrackCar == true) { /* ... */ } ``` +### Dynamic Variable Lookup + +For scenarios where the telemetry variables you need aren't known until runtime, like a dashboard that lets end users pick which values to display, or an integration (such as a game engine) that prefers a string-keyed lookup over a compile-time generated struct - use `GetValue(string)` instead of (or alongside) the strongly-typed `T`: + +```csharp +// no RequiredTelemetryVars needed - use the DynamicTelemetryData placeholder +// GetValue requires Synchronous delivery mode - see below +var clientOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }; +await using var client = TelemetryClient.Create(logger, ibtOptions: null, clientOptions); + +var handlers = new TelemetryHandlers +{ + OnTelemetryUpdate = _ => + { + if (client.GetValue("Speed") is float speed) + { + Console.WriteLine($"Speed: {speed}"); + } + return Task.CompletedTask; + } +}; + +await client.Monitor(handlers, cts.Token); +``` + +`GetValue` matches names case-insensitively and works for any variable reported by `GetTelemetryVariables()`. It returns an 'object' or a 'null' if the variable is unknown. + +The SDK only works in one delivery mode at a time, you can't create the client in one mode and read data in the other. `GetValue` is only usable when the client is created with `new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }`. + +Note: in `Synchronous` mode, each sample is blocking and awaited directly by your handler before the next record is read. + ## Controlling the Simulator In addition to reading telemetry, the SDK can send commands *to* the simulator. Commands are grouped by feature @@ -264,6 +325,7 @@ See [Samples Directory](./Samples/README.md) for ready-to-run example projects i ## Documentation +- **[Architecture and Design](./docs/ARCHITECTURE.md)** - Threading model, data streaming and buffering, performance characteristics, and built-in metrics - **[Advanced Usage](./docs/ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior - **[Migration Guide](./docs/MIGRATION_GUIDE.md)** - Upgrading from early pre-1.0 releases @@ -282,140 +344,6 @@ https://raw.githubusercontent.com/SVappsLAB/iRacingTelemetrySDK/main/docs/ai/SDK and https://raw.githubusercontent.com/SVappsLAB/iRacingTelemetrySDK/main/docs/ai/SDK_REFERENCE.md for advanced patterns. ``` -## Performance and Design - -The SDK is designed for high performance with bounded async data streaming that keeps telemetry current under load. - -### Data Streaming Benefits - -**Data Safety:** -- **60-sample ring buffer** with FIFO (First-In-First-Out) and destructive-read semantics -- Provides **up to 1 second** of buffering at iRacing's 60Hz update rate -- When the buffer fills, **oldest unread samples are automatically dropped** to make room for new data -- **Drop-oldest strategy** ensures the SDK never blocks and prioritizes the most recent telemetry - -**Performance:** -- **Non-blocking bounded streams** reduce contention and keep telemetry current under load -- **Asynchronous processing** keeps your main thread responsive -- **~2x performance improvement** over traditional event-based approaches -- **600,000+ records/sec** processing capability for IBT files - -**Flexibility:** -- **Two consumption patterns**: Handler callbacks (`Monitor(handlers, ct)`) or direct stream access -- **Independent streams**: Consume only the data types you need -- **Cancellation support**: Graceful shutdown with `CancellationToken` - -### Architecture Overview - -**Key Design Principles:** - -- **Strongly Typed Telemetry Data**: Use strong type checking throughout -- **Non-blocking Streams**: All real-time data flows through asynchronous data streams -- **Separation of Concerns**: Telemetry data (high-frequency, time-critical) and Session Info processing (low frequency, CPU-intensive) run independently -- **Background Processing**: CPU-intensive YAML parsing runs in an independent async processing task so telemetry data handling stays responsive. - -```mermaid -graph TB - subgraph "Data Sources" - iRacing[iRacing Simulator
60Hz Live Data] - IBT[IBT File
Historical Data] - end - - subgraph "Task1 - Telemetry Data" - MainTask[Telemetry Data Stream] - end - - subgraph "Task2 - Session Info" - SessionTask[Session Info Processing
YAML Parsing] - end - - subgraph "High Performance Data Streams" - TelemetryStream[TelemetryData] - SessionStream[SessionData] - RawStream[SessionDataYaml] - end - - iRacing --> MainTask - IBT --> MainTask - - MainTask --> TelemetryStream - MainTask --> SessionTask - SessionTask --> SessionStream - SessionTask --> RawStream - - classDef dataSource fill:#e1f5fe - classDef processing fill:#f3e5f5 - classDef stream fill:#e8f5e8 - - class iRacing,IBT dataSource - class MainTask,SessionTask processing - class TelemetryStream,SessionStream,RawStream stream -``` - - -### Performance Monitoring - -The SDK includes built-in support for System.Diagnostics.Metrics to help monitor performance and diagnose issues. - -#### Available Metrics - -The SDK exposes several metrics automatically: - -- **telemetry_records_processed_total**: Counter of processed telemetry records -- **telemetry_records_dropped_total**: Counter of dropped records (when channels are full) -- **telemetry_processing_duration_microseconds**: Histogram of telemetry processing time -- **sessioninfo_records_processed_total**: Counter of session info updates processed -- **sessioninfo_processing_duration_milliseconds**: Histogram of session info processing time - -#### Enabling Metrics with Dependency Injection - -```csharp -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -[RequiredTelemetryVars([TelemetryVar.Speed, TelemetryVar.RPM])] -public class Program -{ - public static async Task Main(string[] args) - { - var host = Host.CreateDefaultBuilder(args) - .ConfigureServices((context, services) => - { - services.AddMetrics(); // Enable metrics support - services.AddLogging(logging => logging.AddConsole()); - }) - .Build(); - - var logger = host.Services.GetRequiredService>(); - var meterFactory = host.Services.GetRequiredService(); - - // Create client with metrics support - var clientOptions = new ClientOptions { MeterFactory = meterFactory }; - await using var client = TelemetryClient.Create(logger, null, clientOptions); - - // Your telemetry code here... - } -} -``` - -#### Monitoring with dotnet-counters - -Use any monitoring tool that supports System.Diagnostics.Metrics, like OpenTelemetry or the free Microsoft provided `dotnet-counters` tool to monitor SDK performance in real-time: - -```bash -# Monitor all SDK metrics for a running application named "YourApp" -dotnet-counters monitor --name "YourApp" --counters SVappsLAB.iRacingTelemetrySDK - -# Sample output: -# [SVappsLAB.iRacingTelemetrySDK] -# telemetry_records_processed_total 45,231 -# telemetry_records_dropped_total 0 -# sessioninfo_records_processed_total 12 -``` - -This helps identify performance bottlenecks, monitor processing rates, and detect if records are being dropped due to slow consumption. - ## Building from Source If you've cloned or forked the repository, you can build, test, and package the SDK with the standard .NET CLI from the repository root. @@ -437,7 +365,7 @@ Tests are split into categories so you can run subsets based on your environment ```bash # unit tests only -dotnet test .\Sdk\tests\UnitTests\UnitTests.csproj +dotnet test --project .\Sdk\tests\UnitTests\UnitTests.csproj # repeatable offline smoke tests using bundled IBT files dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait Category=ibt @@ -446,19 +374,18 @@ dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait Category=live # all test projects, including tests that may require live/manual setup -dotnet test .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx +dotnet test --solution .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx ``` - See [Sdk/tests/README.md](./Sdk/tests/README.md) for manual test commands and filtering notes. ### Running the samples ```bash # live iRacing data -dotnet run --project .\Samples\MinimalExample\MinimalExample.csproj +dotnet run --project .\Samples\MinimalExampleAsync\MinimalExampleAsync.csproj # IBT file playback -dotnet run --project .\Samples\MinimalExample\MinimalExample.csproj -- path\to\file.ibt +dotnet run --project .\Samples\MinimalExampleAsync\MinimalExampleAsync.csproj -- path\to\file.ibt ``` See the [Samples](./Samples/README.md) directory for the individual example projects. diff --git a/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj b/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj index d1dbc82..c19ab02 100644 --- a/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj +++ b/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj @@ -9,7 +9,7 @@ - + diff --git a/Samples/LocationAndWarnings/LocationAndWarnings.csproj b/Samples/LocationAndWarnings/LocationAndWarnings.csproj index d1dbc82..c19ab02 100644 --- a/Samples/LocationAndWarnings/LocationAndWarnings.csproj +++ b/Samples/LocationAndWarnings/LocationAndWarnings.csproj @@ -9,7 +9,7 @@ - + diff --git a/Samples/MinimalExample/MinimalExample.csproj b/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj similarity index 96% rename from Samples/MinimalExample/MinimalExample.csproj rename to Samples/MinimalExampleAsync/MinimalExampleAsync.csproj index d1dbc82..c19ab02 100644 --- a/Samples/MinimalExample/MinimalExample.csproj +++ b/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj @@ -9,7 +9,7 @@ - + diff --git a/Samples/MinimalExample/Program.cs b/Samples/MinimalExampleAsync/Program.cs similarity index 88% rename from Samples/MinimalExample/Program.cs rename to Samples/MinimalExampleAsync/Program.cs index 4bc5f5b..2660b37 100644 --- a/Samples/MinimalExample/Program.cs +++ b/Samples/MinimalExampleAsync/Program.cs @@ -1,5 +1,5 @@ /** - * Copyright (C) 2024-2025 Scott Velez + * Copyright (C) 2024-2026 Scott Velez * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ using Microsoft.Extensions.Logging; using SVappsLAB.iRacingTelemetrySDK; -namespace MinimalExample; +namespace MinimalExampleAsync; // 1. Define the telemetry variables you want to track [RequiredTelemetryVars([TelemetryVar.Speed, TelemetryVar.RPM])] internal class Program @@ -26,14 +26,15 @@ public static async Task Main(string[] args) { // 2. Create logger var logger = LoggerFactory.Create(builder => builder.AddConsole()) - .CreateLogger("MinimalExample"); + .CreateLogger("MinimalExampleAsync"); // 3. Choose data source IBTOptions? ibtOptions = null; // null for live telemetry from iRacing // = new IBTOptions("gt3_spa.ibt"); IBT filepath for file playback ibtOptions = new IBTOptions(args[0]); - // 4. Create telemetry client + // 4. Create telemetry client - using the default high throughput Async delivery mode. + // This mode is best for applications that know the telemetry variables they want to track at compile time. await using var client = TelemetryClient.Create(logger, ibtOptions); // 5. Use cancellation token for proper shutdown diff --git a/Samples/MinimalExampleAsync/Properties/launchSettings.json b/Samples/MinimalExampleAsync/Properties/launchSettings.json new file mode 100644 index 0000000..d610fc3 --- /dev/null +++ b/Samples/MinimalExampleAsync/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "MinimalExampleAsync": { + "commandName": "Project", + "commandLineArgs": "\"..\\..\\..\\..\\data\\formulair04_tsukuba 2kfull 2024-01-09 17-26-10.ibt\"" + } + } +} \ No newline at end of file diff --git a/Samples/MinimalExampleSync/MinimalExampleSync.csproj b/Samples/MinimalExampleSync/MinimalExampleSync.csproj new file mode 100644 index 0000000..16850ca --- /dev/null +++ b/Samples/MinimalExampleSync/MinimalExampleSync.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + diff --git a/Samples/MinimalExampleSync/Program.cs b/Samples/MinimalExampleSync/Program.cs new file mode 100644 index 0000000..5ff1a9c --- /dev/null +++ b/Samples/MinimalExampleSync/Program.cs @@ -0,0 +1,82 @@ +/** + * Copyright (C) 2024-2026 Scott Velez + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +using Microsoft.Extensions.Logging; +using SVappsLAB.iRacingTelemetrySDK; + +namespace MinimalExampleSync; + +internal class Program +{ + public static async Task Main(string[] args) + { + // 1. Create logger + var logger = LoggerFactory.Create(builder => builder.AddConsole()) + .CreateLogger("MinimalExampleSync"); + + // 2. Choose data source + IBTOptions? ibtOptions = null; // null for live telemetry from iRacing + // = new IBTOptions("gt3_spa.ibt"); IBT filepath for file playback + ibtOptions = new IBTOptions(args[0]); + + // 3. Create telemetry client in Synchronous delivery mode. + // Sync mode is slower than the default Async mode, but it can be used if the telemetry variables + // are not known ahead of time. Such as applications that allow the user to select which + // telemetry variables to display at runtime. + // + // See MinimalExampleAsync for the default, higher-throughput mode. + var clientOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }; + await using var client = TelemetryClient.Create(logger, ibtOptions, clientOptions); + + // 4. Use cancellation token for proper shutdown + using var cts = new CancellationTokenSource(); + + // 5. Enable graceful shutdown with Ctrl+C + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + // 6. Define handlers + var handlers = new TelemetryHandlers + { + OnTelemetryUpdate = _ => + { + // 8. look up telemetry dynamically by name, instead of strongly-typed properties + var speed = client.GetValue("Speed"); + var rpm = client.GetValue("RPM"); + Console.WriteLine($"Speed: {speed}, RPM: {rpm}"); + return Task.CompletedTask; + }, + OnSessionInfoUpdate = session => + { + var driverCount = session.DriverInfo?.Drivers?.Count ?? 0; + Console.WriteLine($"Drivers: {driverCount}"); + return Task.CompletedTask; + }, + OnConnectStateChanged = state => + { + Console.WriteLine($"Connection: {state}"); + return Task.CompletedTask; + }, + OnError = error => + { + Console.WriteLine($"Error: {error.Message}"); + return Task.CompletedTask; + } + }; + + // 9. Monitor telemetry data stream until cancellation + await client.Monitor(handlers, cts.Token); + } +} diff --git a/Samples/MinimalExample/Properties/launchSettings.json b/Samples/MinimalExampleSync/Properties/launchSettings.json similarity index 83% rename from Samples/MinimalExample/Properties/launchSettings.json rename to Samples/MinimalExampleSync/Properties/launchSettings.json index 267b794..008d904 100644 --- a/Samples/MinimalExample/Properties/launchSettings.json +++ b/Samples/MinimalExampleSync/Properties/launchSettings.json @@ -1,8 +1,8 @@ -{ - "profiles": { - "MinimalExample": { - "commandName": "Project", - "commandLineArgs": "\"..\\..\\..\\..\\data\\formulair04_tsukuba 2kfull 2024-01-09 17-26-10.ibt\"" - } - } -} \ No newline at end of file +{ + "profiles": { + "MinimalExampleSync": { + "commandName": "Project", + "commandLineArgs": "\"..\\..\\..\\..\\data\\formulair04_tsukuba 2kfull 2024-01-09 17-26-10.ibt\"" + } + } +} diff --git a/Samples/README.md b/Samples/README.md index 07611ab..1451a4c 100644 --- a/Samples/README.md +++ b/Samples/README.md @@ -6,9 +6,14 @@ All of these examples can be used with a running 'live' instance of iRacing, or All samples define a `TelemetryHandlers` variable and pass it to `Monitor(handlers, ct)` for telemetry consumption. For setup and basic usage see the [main README](../README.md); for direct stream access and other advanced patterns see [Advanced Usage](../docs/ADVANCED.md). -* [MinimalExample](./MinimalExample/) +* [MinimalExampleAsync](./MinimalExampleAsync/) - The smallest end-to-end example. A good starting point that shows how to create a client and handle telemetry, session info, connection state, and error callbacks via `Monitor(handlers, ct)`. + A small end-to-end example. A good starting point that shows how to create a client and handle telemetry, session info, connection state, and error callbacks via `Monitor(handlers, ct)`. Uses the default `TelemetryDeliveryMode.Async` and the strongly-typed `TelemetryData` struct for strong static typing and maximum efficency and performance. + +* [MinimalExampleSync](./MinimalExampleSync/) + + Same shape as MinimalExampleAsync, but looks up telemetry variables dynamically with `GetValue("")` instead of a strongly-typed property. + Synchronous mode allows you to query for any variable, at runtime, at the cost of throughput. See [SDK_REFERENCE.md](../docs/ai/SDK_REFERENCE.md#telemetry-delivery-mode) for details on the tradeoffs. * [DumpVariables_DumpSessionInfo](./DumpVariables_DumpSessionInfo/) diff --git a/Samples/Samples.slnx b/Samples/Samples.slnx index f41b7a2..6795240 100644 --- a/Samples/Samples.slnx +++ b/Samples/Samples.slnx @@ -7,7 +7,8 @@ - + + diff --git a/Samples/SimControl/SimControl.csproj b/Samples/SimControl/SimControl.csproj index 69ddc39..7df6a9b 100644 --- a/Samples/SimControl/SimControl.csproj +++ b/Samples/SimControl/SimControl.csproj @@ -9,6 +9,6 @@ - + diff --git a/Samples/SpeedRPMGear/SpeedRPMGear.csproj b/Samples/SpeedRPMGear/SpeedRPMGear.csproj index d12b54c..2a80474 100644 --- a/Samples/SpeedRPMGear/SpeedRPMGear.csproj +++ b/Samples/SpeedRPMGear/SpeedRPMGear.csproj @@ -11,7 +11,7 @@ - + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj index 7baa1a5..f23159d 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj @@ -7,7 +7,7 @@ - + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx b/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx index f27b5d7..3890314 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx @@ -10,6 +10,9 @@ + + + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs index 01b2db9..ff7cb09 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs @@ -35,7 +35,7 @@ internal enum irsdk_StatusField internal class VarHeaderDictionary : Dictionary { - public VarHeaderDictionary() : base(StringComparer.InvariantCultureIgnoreCase) + public VarHeaderDictionary() : base(StringComparer.OrdinalIgnoreCase) { } } @@ -52,6 +52,15 @@ internal abstract unsafe class DataProviderBase : IAsyncDisposable VarHeaderDictionary? _varHeaders; int _lastSessionInfoUpdate = -1; // latest session info update counter + // last layout we logged. used to detect when a region moves or resizes, so + // we only log the layout when it actually changes (see LogLayoutIfChanged) + int _loggedNumVars = -1; + int _loggedBufLen = -1; + int _loggedVarHeaderOffset = -1; + int _loggedSessionInfoOffset = -1; + int _loggedSessionInfoLen = -1; + int _loggedFirstBufOffset = -1; + protected MemoryMappedFile? _mmFile; protected MemoryMappedViewAccessor? _viewAccessor; @@ -112,8 +121,71 @@ public irsdk_header GetHeader() _telemetryDataBuffer = new byte[_header.bufLen]; } + if (_logger.IsEnabled(LogLevel.Debug)) + LogLayoutIfChanged(); + return _header; } + + // log where each region of the memory mapped data lives + protected void LogLayoutIfChanged() + { + var firstBufOffset = _header.GetVarBuf(0).bufOffset; + + var changed = + _header.numVars != _loggedNumVars || + _header.bufLen != _loggedBufLen || + _header.varHeaderOffset != _loggedVarHeaderOffset || + _header.sessionInfoOffset != _loggedSessionInfoOffset || + _header.sessionInfoLen != _loggedSessionInfoLen || + firstBufOffset != _loggedFirstBufOffset; + + if (!changed) + return; + + var isFirstLog = _loggedNumVars == -1; + + _loggedNumVars = _header.numVars; + _loggedBufLen = _header.bufLen; + _loggedVarHeaderOffset = _header.varHeaderOffset; + _loggedSessionInfoOffset = _header.sessionInfoOffset; + _loggedSessionInfoLen = _header.sessionInfoLen; + _loggedFirstBufOffset = firstBufOffset; + + LogLayout(isFirstLog ? "initial" : "changed"); + } + + void LogLayout(string reason) + { + var source = GetType().Name; + + _logger.LogDebug("{source} layout ({reason}): ver={ver}, status={status}, tickRate={tickRate}, sessionInfoUpdate={sessionInfoUpdate}", + source, reason, _header.ver, _header.status, _header.tickRate, _header.sessionInfoUpdate); + + _logger.LogDebug("{source} layout ({reason}): sizeof(header)={headerSize}, sizeof(diskSubHeader)={diskSubHeaderSize}, sizeof(varHeader)={varHeaderSize}, sizeof(varBuf)={varBufSize}", + source, reason, sizeof(irsdk_header), sizeof(irsdk_diskSubHeader), sizeof(irsdk_varHeader), sizeof(irsdk_varBuf)); + + // varHeader array: numVars entries, each sizeof(irsdk_varHeader) bytes + var varHeaderBytes = (long)_header.numVars * sizeof(irsdk_varHeader); + _logger.LogDebug("{source} layout ({reason}): varHeaderOffset={varHeaderOffset}, numVars={numVars}, varHeaderBytes={varHeaderBytes}, varHeaderEnd={varHeaderEnd}", + source, reason, _header.varHeaderOffset, _header.numVars, varHeaderBytes, _header.varHeaderOffset + varHeaderBytes); + + // session info yaml. 'sessionInfoLen' is the length in use, which may be smaller than what the irsdk reserved + _logger.LogDebug("{source} layout ({reason}): sessionInfoOffset={sessionInfoOffset}, sessionInfoLen={sessionInfoLen}, sessionInfoEnd={sessionInfoEnd}", + source, reason, _header.sessionInfoOffset, _header.sessionInfoLen, (long)_header.sessionInfoOffset + _header.sessionInfoLen); + + // telemetry buffers + var numBuf = Math.Min(_header.numBuf, irSDKDefines.Constants.IRSDK_MAX_BUFS); + _logger.LogDebug("{source} layout ({reason}): numBuf={numBuf}, bufLen={bufLen}, curBuf={curBuf}, curBufTickCount={curBufTickCount}", + source, reason, _header.numBuf, _header.bufLen, _header.curBuf, _header.curBufTickCount); + + for (var i = 0; i < numBuf; i++) + { + var varBuf = _header.GetVarBuf(i); + _logger.LogDebug("{source} layout ({reason}): varBuf[{index}] bufOffset={bufOffset}, bufEnd={bufEnd}, tickCount={tickCount}, tickCountBegin={tickCountBegin}, pad={pad}", + source, reason, i, varBuf.bufOffset, (long)varBuf.bufOffset + _header.bufLen, varBuf.tickCount, varBuf.tickCountBegin, varBuf.pad); + } + } public string GetSessionInfoYaml() { var header = GetHeader(); @@ -126,7 +198,15 @@ public string GetSessionInfoYaml() public object? GetVarValue(string varName) { - if (!_varHeaders!.TryGetValue(varName, out irsdk_varHeader vh)) + // headers/buffer are populated lazily by GetHeader() once the provider has read at least one + // header from the data source + if (_varHeaders == null || _telemetryDataBuffer == null) + { + _logger.LogDebug("Telemetry data not yet available; ignoring lookup for [{varName}]", varName); + return null; + } + + if (!_varHeaders.TryGetValue(varName, out irsdk_varHeader vh)) { _logger.LogDebug("Telemetry variable [{varName}] not found in data provider", varName); return null; @@ -180,6 +260,20 @@ public string GetSessionInfoYaml() return val; } + // maps irsdk_VarType -> CLR Type mapping + internal static Type GetClrType(irsdk_VarType type, int count) => type switch + { + // a single char returns the raw byte; a char buffer decodes to one string (a text field, not + // an array of separate strings) + irsdk_VarType.irsdk_char => count > 1 ? typeof(string) : typeof(byte), + irsdk_VarType.irsdk_bool => count > 1 ? typeof(bool[]) : typeof(bool), + // bitField is packed into the same int/int[] path as irsdk_int above + irsdk_VarType.irsdk_int or irsdk_VarType.irsdk_bitField => count > 1 ? typeof(int[]) : typeof(int), + irsdk_VarType.irsdk_float => count > 1 ? typeof(float[]) : typeof(float), + irsdk_VarType.irsdk_double => count > 1 ? typeof(double[]) : typeof(double), + _ => throw new NotImplementedException($"{type} not implemented") + }; + // wait for iRacing to signal there is new data public abstract Task WaitForDataReady(TimeSpan timeSpan, CancellationToken cancellationToken = default); @@ -190,9 +284,13 @@ public string GetSessionInfoYaml() // with IBT files, the 'recNum' tells us which data record in the mmf we should read + // + // IBT files always have a single buffer, and don't populate 'curBuf' (it is + // only written by the sim, for live data). read varBuf[0] directly, rather + // than resolving through the live-only field protected void CopyNewTelemetryDataToBuffer(int recNum = 0) { - var offset = _header.GetMostRecentBuffer().bufOffset + recNum * _header.bufLen; + var offset = _header.GetVarBuf(0).bufOffset + recNum * _header.bufLen; var ros = new ReadOnlySpan(_dataPtr + offset, _header.bufLen); ros.CopyTo(_telemetryDataBuffer); } diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs index 1a767ed..f9959e4 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs @@ -55,14 +55,42 @@ public override void OpenDataSource() _viewAccessor = _mmFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); _viewAccessor!.SafeMemoryMappedViewHandle.AcquirePointer(ref _dataPtr); - // read header + // read header _header = GetHeader(); _numRecords = GetNumRecordsInIBTFile(); + if (_logger.IsEnabled(LogLevel.Debug)) + LogIBTFileLayout(); + _governor.StartPlayback(); } + // the base class logs the regions common to live and IBT data. + // add the the disk sub header, and the record region till end of the file + void LogIBTFileLayout() + { + var diskSubHeader = GetDiskSubHeader(); + + _logger.LogDebug("IBT layout: file={file}", _ibtOptions.IbtFilePath); + + // the disk sub header sits immediately after the header + _logger.LogDebug("IBT layout: diskSubHeaderOffset={diskSubHeaderOffset}, diskSubHeaderEnd={diskSubHeaderEnd}, sessionStartDate={sessionStartDate}, sessionStartTime={sessionStartTime}, sessionEndTime={sessionEndTime}, sessionLapCount={sessionLapCount}, sessionRecordCount={sessionRecordCount}", + sizeof(irsdk_header), sizeof(irsdk_header) + sizeof(irsdk_diskSubHeader), + diskSubHeader.sessionStartDate, diskSubHeader.sessionStartTime, diskSubHeader.sessionEndTime, + diskSubHeader.sessionLapCount, diskSubHeader.sessionRecordCount); + + // records are written one after the other, starting at the first (only) buffer offset + var firstRecordOffset = _header.GetVarBuf(0).bufOffset; + var recordBytes = (long)diskSubHeader.sessionRecordCount * _header.bufLen; + var computedFileLength = firstRecordOffset + recordBytes; + var actualFileLength = new FileInfo(_ibtOptions.IbtFilePath).Length; + + _logger.LogDebug("IBT layout: firstRecordOffset={firstRecordOffset}, recordCount={recordCount}, bufLen={bufLen}, recordBytes={recordBytes}, computedFileLength={computedFileLength}, actualFileLength={actualFileLength}, matches={matches}", + firstRecordOffset, diskSubHeader.sessionRecordCount, _header.bufLen, recordBytes, + computedFileLength, actualFileLength, computedFileLength == actualFileLength); + } + public int GetNumRecordsInIBTFile() { var numRecs = GetDiskSubHeader().sessionRecordCount; diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/LiveDataProvider.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/LiveDataProvider.cs index d5bb4a4..4b1e22d 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/LiveDataProvider.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/LiveDataProvider.cs @@ -42,7 +42,9 @@ public override void OpenDataSource() _viewAccessor = _mmFile.CreateViewAccessor(); _viewAccessor!.SafeMemoryMappedViewHandle.AcquirePointer(ref _dataPtr); - // read header + _logger.LogDebug("live layout: memoryMappedFile={memMapFile}", IRSDK_MemMapFileName); + + // read header _header = GetHeader(); // data ready event diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/ITelemetryClient.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/ITelemetryClient.cs index 08b7b31..af8336b 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/ITelemetryClient.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/ITelemetryClient.cs @@ -14,10 +14,6 @@ * limitations under the License. **/ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using SVappsLAB.iRacingTelemetrySDK.SimControl; namespace SVappsLAB.iRacingTelemetrySDK; @@ -161,6 +157,12 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// and may cause undefined behavior. For callback-based consumption, use the /// overload. /// + /// + /// Not available in Synchronous delivery mode: when the client is created with + /// set to , samples + /// are delivered directly to instead of being queued on + /// this stream. Accessing this property in that mode throws . + /// /// /// /// @@ -178,18 +180,61 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// /// A read-only list of telemetry variables. Returns an empty list if called before /// the data provider has been initialized (typically during or after the first - /// Monitor() call in live mode, or immediately after construction for IBT mode). + /// Monitor() call, for both live and IBT mode). /// /// /// Initialization Timing: In live mode, variable headers become /// available only after iRacing is running and the first data update occurs. In IBT mode, - /// they're available immediately after construction. + /// they become available once Monitor() opens the file /// Thread Safety: This method is thread-safe and can be called /// concurrently with Monitor(), though results may vary depending on initialization state. /// /// Thrown if the client has been disposed. IReadOnlyList GetTelemetryVariables(); + /// + /// Gets the current value of a telemetry variable by name. + /// + /// + /// The iRacing telemetry variable name (e.g. "Speed", "RPM", "CarIdxTrackSurface"). Names are matched + /// case-insensitively and are not limited to the variables declared on - any + /// variable reported by can be looked up this way. + /// + /// + /// The current value, boxed as a scalar (e.g. a ) or an array (e.g. float[]) + /// depending on the variable, or if is not a known + /// variable or telemetry data is not yet available. + /// + /// + /// + /// Use this for scenarios where the set of variables is only known at runtime - for example, a dashboard + /// that lets end users pick which telemetry values to display, or an integration (such as a game engine) + /// that prefers a dynamic, string-keyed lookup over a compile-time generated struct. See + /// for a placeholder that avoids the need for + /// a RequiredTelemetryVars-annotated struct entirely. + /// + /// + /// Prefer the strongly-typed for variables known at compile time - it is + /// allocation-free. + /// + /// + /// Only usable in mode. The SDK operates + /// in exactly one of two modes, chosen when the client is created via , + /// and does not allow mixing them: in the default mode, production + /// and consumption run on independent tasks, so there would be no way to guarantee this method reflects the + /// same record as the sample passed to + /// - calling it in that mode throws + /// . In mode, this + /// method is guaranteed to reflect exactly the sample passed to the currently-running handler, because the + /// next record isn't read until the handler returns. + /// + /// + /// Thrown if the client has been disposed. + /// + /// Thrown if the client was created with set to + /// . + /// + object? GetValue(string varName); /// /// Monitors the telemetry data and writes data to streams when new data is available. @@ -208,7 +253,14 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// Concurrent Calls: Calling Monitor() while already running will throw /// InvalidOperationException. Ensure the previous Monitor() call has completed before starting /// a new client instance. + /// Synchronous delivery mode: this overload supplies no + /// handler, so it is incompatible with + /// - use + /// instead. /// + /// + /// Thrown immediately if the client was created with . + /// Task Monitor(CancellationToken ct); /// @@ -227,14 +279,24 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// stops monitoring and returns normally after handlers finish. /// /// - /// Handler exceptions fault this method directly. SDK processing errors are delivered to - /// when that handler is provided. + /// Handler exceptions fault this method directly - this holds for both + /// and . SDK + /// processing errors are delivered to when that handler is + /// provided; handler exceptions are never routed there. /// /// /// Handlers are awaited sequentially per stream. Keep handlers fast, especially telemetry /// handlers that may run at 60Hz. /// + /// + /// Synchronous delivery mode: requires + /// to be set; otherwise telemetry samples would be silently discarded. + /// /// + /// + /// Thrown immediately if the client was created with and + /// does not set . + /// Task Monitor(TelemetryHandlers handlers, CancellationToken ct); /// @@ -252,11 +314,23 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// /// Pauses stream data writing. Processing continues, but stream writes are suppressed. /// + /// + /// Thread Safety: This method is thread-safe and can be called from any thread. + /// Idempotency: Safe to call multiple times. Calling Pause() when already paused has no effect. + /// Eventual Consistency: Changes are not immediate. A few telemetry samples may be written + /// to streams before the pause takes effect + /// void Pause(); /// /// Resumes stream data writing. /// + /// + /// Thread Safety: This method is thread-safe and can be called from any thread. + /// Idempotency: Safe to call multiple times. Calling Resume() when not paused has no effect. + /// Eventual Consistency: Changes are not immediate. A few telemetry samples may be suppressed + /// before the resume takes effect. + /// void Resume(); /// diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj b/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj index 8f35c1d..1631603 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj @@ -24,8 +24,8 @@ - - + + @@ -43,7 +43,7 @@ iRacing iRacingSDK irsdk IBT telemetry SDK API sim-control replay broadcast NUGET.md LICENSE - 2.2.0 + 2.3.0 Scott Velez SVappsLAB Copyright (c) 2026 SVappsLAB diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs index e235079..775c8ff 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs @@ -14,22 +14,17 @@ * limitations under the License. **/ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.Metrics; -using System.IO; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using SVappsLAB.iRacingTelemetrySDK.DataProviders; -using SVappsLAB.iRacingTelemetrySDK.irSDKDefines; using SVappsLAB.iRacingTelemetrySDK.Metrics; using SVappsLAB.iRacingTelemetrySDK.SimControl; using SVappsLAB.iRacingTelemetrySDK.YamlParsing; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Threading.Channels; namespace SVappsLAB.iRacingTelemetrySDK; @@ -55,6 +50,35 @@ public record class TelemetryVariable // unit. something like "kg/m^2" public string Units { get; init; } = default!; } + +/// +/// Placeholder telemetry data type for use with when you only need +/// dynamic, string-based variable lookup () +/// and don't want to declare a RequiredTelemetryVars-annotated struct. +/// +/// +/// Useful when embedding the SDK somewhere the Roslyn source generator is inconvenient to use (e.g. some +/// game engine build pipelines), or when building a fully dynamic dashboard where the set of telemetry +/// values is chosen by the end user at runtime rather than fixed at compile time. +/// +/// await using var client = TelemetryClient<DynamicTelemetryData>.Create(logger); +/// await client.Monitor(new TelemetryHandlers<DynamicTelemetryData> +/// { +/// OnTelemetryUpdate = _ => +/// { +/// if (client.GetValue("Speed") is float speed) +/// { +/// // use speed +/// } +/// return Task.CompletedTask; +/// } +/// }, cancellationToken); +/// +/// +public readonly struct DynamicTelemetryData +{ +} + /** * Options for IBT file processing. * @@ -82,11 +106,44 @@ public IBTOptions(string ibtFilePath, int playBackSpeedMultiplier = int.MaxValue } } +/// +/// Controls how telemetry samples are handed off from the background processing loop to +/// , and what that means for +/// . +/// +public enum TelemetryDeliveryMode +{ + /// + /// This is the highly preferred default, high-throughput mode. + /// + Async, + + /// + /// This is the slower, blocking, optional sychronous mode. + /// Only supported via + /// with + /// set - both Monitor(CancellationToken) + /// and a handlers object without OnTelemetryUpdate throw + /// immediately, since telemetry would otherwise be silently discarded. The + /// async-enumerable stream is not populated in this mode. + /// + Synchronous +} + /// /// Configuration options that control various aspects of telemetry processing. /// public class ClientOptions { + /// + /// Controls how telemetry samples are delivered to handlers, and whether + /// is usable at all. Defaults to + /// , in which GetValue throws + /// - use if you + /// need GetValue. + /// + public TelemetryDeliveryMode DeliveryMode { get; init; } = TelemetryDeliveryMode.Async; + /// /// Optional factory for creating metrics to monitor telemetry processing performance. /// Tracks processing duration and record counts. @@ -136,8 +193,17 @@ public class TelemetryClient : ITelemetryClient, IAsyncDisposable where T // telemetry variables caching private IReadOnlyList? _cachedTelemetryVariables; + private VarHeaderDictionary? _cachedTelemetryVariablesSourceHeaders; private readonly object _telemetryVariablesLock = new object(); + private readonly TelemetryDeliveryMode _deliveryMode; + // set only when _deliveryMode == Synchronous; invoked inline by the producer loop + private Func? _syncTelemetryHandler; + + // internal usage only. thrown when _syncTelemetryHandler itself throws. used to distguish + // between handler fault and provider error + private sealed class SyncHandlerFaultException(Exception innerException) : Exception(innerException.Message, innerException); + /// public IAsyncEnumerable ConnectStates => GetConnectStatesEnumerable(); @@ -151,7 +217,12 @@ public class TelemetryClient : ITelemetryClient, IAsyncDisposable where T public IAsyncEnumerable SessionData => GetSessionDataEnumerable(); /// - public IAsyncEnumerable TelemetryData => GetTelemetryDataEnumerable(); + public IAsyncEnumerable TelemetryData => + _deliveryMode == TelemetryDeliveryMode.Synchronous + ? throw new InvalidOperationException( + $"{nameof(TelemetryData)} is not available when {nameof(ClientOptions.DeliveryMode)} is " + + $"{nameof(TelemetryDeliveryMode.Synchronous)} - use {nameof(Monitor)}({nameof(TelemetryHandlers)}, CancellationToken) instead.") + : GetTelemetryDataEnumerable(); public bool IsPaused => _isPaused; @@ -238,6 +309,7 @@ private TelemetryClient(ILogger logger, ClientOptions? clientOptions = null, IBT _logger = logger; _ibtOptions = ibtOptions; + _deliveryMode = clientOptions?.DeliveryMode ?? TelemetryDeliveryMode.Async; // initialize bounded channels var boundedChannelOptions = new BoundedChannelOptions(CHANNEL_SIZE) @@ -272,24 +344,17 @@ private TelemetryClient(ILogger logger, ClientOptions? clientOptions = null, IBT _simControl = new Lazy(() => new SimController(_logger, () => IsConnected)); } - /// - /// starts monitoring telemetry data from either live iRacing or IBT file playback. - /// this method can only be called once per TelemetryClient instance. - /// - /// cancellation token to stop monitoring - /// number of telemetry records processed - /// thrown if Monitor() is called while already running - /// thrown if the client has been disposed - /// - /// IMPORTANT: This method can only be called ONCE per TelemetryClient instance. - /// After Monitor() completes (via cancellation or IBT file EOF), the client cannot be restarted. - /// Create a new client instance for subsequent monitoring sessions. - /// Concurrent Calls: Calling Monitor() while already running will throw - /// InvalidOperationException. Ensure the previous Monitor() call has completed before starting - /// a new client instance. - /// + /// public async Task Monitor(CancellationToken ct) { + if (_deliveryMode == TelemetryDeliveryMode.Synchronous) + { + throw new InvalidOperationException( + $"{nameof(TelemetryDeliveryMode)}.{nameof(TelemetryDeliveryMode.Synchronous)} requires an " + + $"{nameof(TelemetryHandlers.OnTelemetryUpdate)} handler - use " + + $"{nameof(Monitor)}({nameof(TelemetryHandlers)}, CancellationToken) instead."); + } + BeginMonitor(); return await RunMonitor(ct).ConfigureAwait(false); } @@ -299,6 +364,13 @@ public async Task Monitor(TelemetryHandlers handlers, CancellationToken { ArgumentNullException.ThrowIfNull(handlers); + if (_deliveryMode == TelemetryDeliveryMode.Synchronous && handlers.OnTelemetryUpdate == null) + { + throw new InvalidOperationException( + $"{nameof(TelemetryDeliveryMode)}.{nameof(TelemetryDeliveryMode.Synchronous)} requires " + + $"{nameof(TelemetryHandlers.OnTelemetryUpdate)} to be set."); + } + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); BeginMonitor(); @@ -370,9 +442,14 @@ private async Task RunMonitor(CancellationToken ct) } catch (Exception ex) { - _logger.LogError(ex, "Error in processing tasks"); - monitorException = ex; - throw; + // unwrap a synchronous-handler fault so Monitor() sees the original + var toThrow = ex is SyncHandlerFaultException fault ? fault.InnerException! : ex; + + _logger.LogError(toThrow, "Error in processing tasks"); + monitorException = toThrow; + + ExceptionDispatchInfo.Capture(toThrow).Throw(); + throw; // unreachable - Throw() always throws, but the compiler can't tell } } finally @@ -388,7 +465,13 @@ private Task SubscribeToHandlers(TelemetryHandlers handlers, CancellationToke var tasks = new List(); if (handlers.OnTelemetryUpdate != null) - tasks.Add(RunHandlerLoop(HandleTelemetryData(handlers.OnTelemetryUpdate), faultCts)); + { + if (_deliveryMode == TelemetryDeliveryMode.Synchronous) + // delivered inline by the producer loop instead - see DeliverTelemetryData + _syncTelemetryHandler = handlers.OnTelemetryUpdate; + else + tasks.Add(RunHandlerLoop(HandleTelemetryData(handlers.OnTelemetryUpdate), faultCts)); + } if (handlers.OnSessionInfoUpdate != null) tasks.Add(RunHandlerLoop(HandleSessionData(handlers.OnSessionInfoUpdate), faultCts)); @@ -509,20 +592,36 @@ private async Task StartDataProcessorTask(CancellationToken ct) public IReadOnlyList GetTelemetryVariables() { - // fast path - already cached - if (_cachedTelemetryVariables != null) + ObjectDisposedException.ThrowIf(_disposed, this); + + // the provider re-reads and reassigns its header dictionary whenever the var-buffer layout + // changes (see DataProviderBase.GetHeader()), so reference identity doubles as a cheap version + // check - a different instance means the cache is stale and must be rebuilt + var varHeaders = _dataProvider.GetVarHeaders(); + + // fast path - already cached against the current header set + if (_cachedTelemetryVariables != null && ReferenceEquals(_cachedTelemetryVariablesSourceHeaders, varHeaders)) return _cachedTelemetryVariables; + if (varHeaders == null) + { + // not yet initialized (e.g. live mode before iRacing connects) - nothing to cache, so a + // later call once headers are available isn't stuck returning this empty list forever + _logger.LogDebug("Telemetry variable headers not available yet"); + return Array.Empty(); + } + lock (_telemetryVariablesLock) { // double-check pattern - if (_cachedTelemetryVariables != null) + if (_cachedTelemetryVariables != null && ReferenceEquals(_cachedTelemetryVariablesSourceHeaders, varHeaders)) return _cachedTelemetryVariables; try { - IReadOnlyList list = BuildTelemetryVariablesList(); + IReadOnlyList list = BuildTelemetryVariablesList(varHeaders); _cachedTelemetryVariables = list; + _cachedTelemetryVariablesSourceHeaders = varHeaders; return list; } catch (ObjectDisposedException) @@ -537,33 +636,33 @@ public IReadOnlyList GetTelemetryVariables() } } - private unsafe IReadOnlyList BuildTelemetryVariablesList() + /// + public object? GetValue(string varName) { - var list = new List(); - var varHeaders = _dataProvider.GetVarHeaders(); + ObjectDisposedException.ThrowIf(_disposed, this); - if (varHeaders == null) + if (_deliveryMode == TelemetryDeliveryMode.Async) { - _logger.LogWarning("Variable headers are null, returning empty list"); - return list; + throw new InvalidOperationException( + $"{nameof(GetValue)} is not available when {nameof(ClientOptions.DeliveryMode)} is " + + $"{nameof(TelemetryDeliveryMode.Async)} - create the client with " + + $"{nameof(TelemetryDeliveryMode.Synchronous)} delivery mode instead."); } + return _dataProvider.GetVarValue(varName); + } + + private unsafe IReadOnlyList BuildTelemetryVariablesList(VarHeaderDictionary varHeaders) + { + var list = new List(); + foreach (var vh in varHeaders.Values) { try { var tVar = new TelemetryVariable { - Type = vh.type switch - { - irsdk_VarType.irsdk_char => vh.count > 1 ? typeof(string[]) : typeof(string), - irsdk_VarType.irsdk_bool => vh.count > 1 ? typeof(bool[]) : typeof(bool), - irsdk_VarType.irsdk_int => vh.count > 1 ? typeof(int[]) : typeof(int), - irsdk_VarType.irsdk_bitField => vh.count > 1 ? typeof(uint[]) : typeof(uint), - irsdk_VarType.irsdk_float => vh.count > 1 ? typeof(float[]) : typeof(float), - irsdk_VarType.irsdk_double => vh.count > 1 ? typeof(double[]) : typeof(double), - _ => throw new NotImplementedException($"{vh.type} not implemented") - }, + Type = DataProviderBase.GetClrType(vh.type, vh.count), Length = vh.count, IsTimeValue = vh.countAsTime, @@ -583,16 +682,7 @@ private unsafe IReadOnlyList BuildTelemetryVariablesList() return list; } - /// - /// Gets a value indicating whether the client is connected to the telemetry source. - /// - /// - /// true if connected to iRacing (live mode) or an IBT file is open; otherwise false. - /// - /// - /// This property is safe to call from any thread and will return false if the client - /// has been disposed or is not yet initialized. - /// + /// public bool IsConnected { get @@ -617,30 +707,14 @@ public bool IsConnected } } - /// - /// Pauses stream data writing. Processing continues, but stream writes are suppressed. - /// - /// - /// Thread Safety: This method is thread-safe and can be called from any thread. - /// Idempotency: Safe to call multiple times. Calling Pause() when already paused has no effect. - /// Eventual Consistency: Changes are not immediate. A few telemetry samples may be written - /// to streams before the pause takes effect (typically 1-2 samples, ~16-32ms at 60Hz). - /// + /// public void Pause() { _isPaused = true; _logger.LogDebug("Telemetry client paused"); } - /// - /// Resumes stream data writing. - /// - /// - /// Thread Safety: This method is thread-safe and can be called from any thread. - /// Idempotency: Safe to call multiple times. Calling Resume() when not paused has no effect. - /// Eventual Consistency: Changes are not immediate. A few telemetry samples may be suppressed - /// before the resume takes effect (typically 1-2 samples, ~16-32ms at 60Hz). - /// + /// public void Resume() { _isPaused = false; @@ -714,8 +788,17 @@ private async Task Shutdown() } catch (Exception ex) { - _logger.LogError(ex, "Error shutting down main processing task"); - shutdownException ??= ex; + if (ex is SyncHandlerFaultException fault) + { + // in Synchronous mode, a handler fault lives on _dataProcessingTask itself and was + // already thrown so don't surface it a second time here. + _logger.LogDebug(fault.InnerException, "Synchronous telemetry handler fault already reported by Monitor()"); + } + else + { + _logger.LogError(ex, "Error shutting down main processing task"); + shutdownException ??= ex; + } } CompleteAllChannels(); @@ -856,6 +939,13 @@ private async Task ProcessLiveData(CancellationToken ct) _logger.LogDebug("Live data monitoring cancelled after {numUpdates} updates", numUpdates); return numUpdates; } + catch (SyncHandlerFaultException) + { + // synchronous telemetry handler threw - fault Monitor() directly, same as Async + // mode, instead of treating it as a retryable SDK/provider error + _logger.LogDebug("Synchronous telemetry handler threw after {numUpdates} updates; faulting monitoring", numUpdates); + throw; + } catch (Exception e) { _logger.LogError(e, "Error processing live data"); @@ -931,9 +1021,7 @@ private async Task WaitForData(CancellationToken ct) // suppress events if paused or disposed if (!IsPaused) { - // write to channel - var telemetryData = GetTelemetryDataSample(); - _telemetryDataChannel.Writer.TryWrite(telemetryData); + await DeliverTelemetryData(GetTelemetryDataSample()).ConfigureAwait(false); } return true; // data processed @@ -947,6 +1035,34 @@ private T GetTelemetryDataSample() T val = _telemetryAccessor.CreateTelemetryDataSample(_dataProvider); return val; } + + // keep this method synchronous to avoid building an async + private Task DeliverTelemetryData(T telemetryData) + { + if (_deliveryMode == TelemetryDeliveryMode.Synchronous) + { + return _syncTelemetryHandler != null + ? InvokeSyncTelemetryHandler(telemetryData) + : Task.CompletedTask; + } + + _telemetryDataChannel.Writer.TryWrite(telemetryData); + return Task.CompletedTask; + } + + private async Task InvokeSyncTelemetryHandler(T telemetryData) + { + try + { + await _syncTelemetryHandler!(telemetryData).ConfigureAwait(false); + } + catch (Exception ex) + { + // this is the caller's handler exception, not an SDK/provider error - wrap it so + // catch blocks fault Monitor() instead of treating it as a retryable error routed to OnError + throw new SyncHandlerFaultException(ex); + } + } private async Task ProcessIbtData(CancellationToken token) { int numRecords = 0; @@ -990,9 +1106,7 @@ private async Task ProcessIbtData(CancellationToken token) // suppress events if paused or disposed if (!IsPaused) { - // write to channel - var telemetryData = GetTelemetryDataSample(); - _telemetryDataChannel.Writer.TryWrite(telemetryData); + await DeliverTelemetryData(GetTelemetryDataSample()).ConfigureAwait(false); } // Apply playback speed delay if configured @@ -1019,6 +1133,11 @@ private async Task ProcessIbtData(CancellationToken token) _logger.LogDebug("IBT data processing cancelled after {numRecords} records", numRecords); return numRecords; } + catch (SyncHandlerFaultException) + { + _logger.LogDebug("Synchronous telemetry handler threw after {numRecords} records; faulting monitoring", numRecords); + throw; + } catch (Exception e) { _logger.LogError(e, "Error playing IBT file after {numRecords} records", numRecords); diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryHandlers.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryHandlers.cs index 57afca2..37894bf 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryHandlers.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryHandlers.cs @@ -14,9 +14,6 @@ * limitations under the License. **/ -using System; -using System.Threading.Tasks; - namespace SVappsLAB.iRacingTelemetrySDK; /// diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/irSDK_defines.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/irSDK_defines.cs index bf7f353..7da1f54 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/irSDK_defines.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/irSDK_defines.cs @@ -90,8 +90,8 @@ internal unsafe struct irsdk_header public irsdk_varBuf varBuf4; #region methods - public irsdk_varBuf GetMostRecentBuffer() => GetVarBuf(GetMostRecentBufferIndex()); - + // NOTE: 'curBuf' is a live-only field. IBT files leave it zero, so this is + // only meaningful for live data. IBT readers should use varBuf[0] directly public int GetMostRecentBufferIndex() { // use curBuf to find the most recently written buffer diff --git a/Sdk/tests/README.md b/Sdk/tests/README.md index 400e6a8..c5b430c 100644 --- a/Sdk/tests/README.md +++ b/Sdk/tests/README.md @@ -5,7 +5,7 @@ Unit tests cover individual classes and functions without requiring iRacing or live telemetry. ```powershell -dotnet test .\Sdk\tests\UnitTests\UnitTests.csproj +dotnet test --project .\Sdk\tests\UnitTests\UnitTests.csproj ``` ## Offline IBT Smoke Tests @@ -38,7 +38,19 @@ dotnet run --project .\Sdk\tests\UnitTests\UnitTests.csproj -- --filter-trait Ca The full solution test run may include tests that require live iRacing data or local manual-test setup. ```powershell -dotnet test .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx +dotnet test --solution .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx ``` -Do not rely on `dotnet test --filter unit`, `--filter ibt`, or `--filter live` with the current Microsoft.Testing.Platform setup; those VSTest-style filters are ignored. Use `--filter-trait Category=...` with `dotnet run --project` for filtered MTP test runs. +This repo pins `test.runner` to `Microsoft.Testing.Platform` in `global.json` (required on the .NET 10 SDK, +where `dotnet test`'s legacy VSTest bridge no longer supports MTP-based projects like these xUnit v3 ones). +Two consequences: + +- `dotnet test` requires `--project ` or `--solution ` - a bare positional path is rejected. +- `--filter unit` / `--filter ibt` / `--filter live` (VSTest-style filters) are not supported at all; use + `--filter-trait Category=...` instead (append it after `--`, e.g. `dotnet test --project X.csproj -- + --filter-trait Category=unit`). + +For `SmokeTests` specifically, prefer `dotnet run --project` over `dotnet test` as shown above: +`SmokeTests`' `Properties/launchSettings.json` supplies a default `--filter-class` argument that `dotnet +test` combines with (rather than overrides with) any `--filter-trait` you pass on the command line, which +silently produces "zero tests ran" instead of an error. `dotnet run --project` does not have this problem. diff --git a/Sdk/tests/SmokeTests/Base/Base.DynamicLookup.cs b/Sdk/tests/SmokeTests/Base/Base.DynamicLookup.cs new file mode 100644 index 0000000..5a66c0b --- /dev/null +++ b/Sdk/tests/SmokeTests/Base/Base.DynamicLookup.cs @@ -0,0 +1,190 @@ +/** + * Copyright (C) 2024-2026 Scott Velez + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +**/ + +using SVappsLAB.iRacingTelemetrySDK; + +namespace SmokeTests; + +public abstract partial class Base where T : class +{ + /// + /// verifies the string-based dynamic lookup (GetValue) surface. Requires . + /// + protected async Task BaseVerifyDynamicLookupShape(ITelemetryClient client, int timeoutSecs = 5) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs)); + + var sampleChecked = false; + + await client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = telemetryData => + { + // only need one sample + if (sampleChecked) + return Task.CompletedTask; + sampleChecked = true; + + // scalar float - type only, plus case-insensitive name matching + Assert.IsType(client.GetValue("RPM")); + Assert.IsType(client.GetValue("rpm")); + Assert.IsType(client.GetValue("Rpm")); + + // scalar int + Assert.IsType(client.GetValue("SessionNum")); + + // array (enum-typed in the generated struct, plain int[] via dynamic lookup) + // CarIdxTrackSurface can legitimately be unavailable (null) - see Base.cs's BasicMonitoring check + var dynamicTrackSurface = client.GetValue("CarIdxTrackSurface"); + if (telemetryData.CarIdxTrackSurface == null) + { + Assert.Null(dynamicTrackSurface); + } + else + { + var trackSurface = Assert.IsType(dynamicTrackSurface); + Assert.Equal(telemetryData.CarIdxTrackSurface.Length, trackSurface.Length); + } + + // unknown variable name + Assert.Null(client.GetValue("NotARealVariableName")); + + cts.Cancel(); + return Task.CompletedTask; + }, + }, + cts.Token); + + Assert.True(sampleChecked, "Telemetry data was not received within the timeout period."); + } + + /// + /// verifies the string-based dynamic lookup (GetValue) returns values that exactly agree with the + /// strongly-typed, source-generated struct for the same sample. Only valid under + /// , which guarantees GetValue() reflects the sample + /// delivered to the handler for the handler's full duration. + /// + protected async Task BaseVerifyDynamicLookupMatchesTypedValues(ITelemetryClient client, int timeoutSecs = 5) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs)); + + var sampleChecked = false; + + await client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = telemetryData => + { + // only need one sample + if (sampleChecked) + return Task.CompletedTask; + sampleChecked = true; + + // scalar float + Assert.Equal(telemetryData.RPM, Assert.IsType(client.GetValue("RPM"))); + + // scalar int + Assert.Equal(telemetryData.SessionNum, Assert.IsType(client.GetValue("SessionNum"))); + + // array (enum-typed in the generated struct, plain int[] via dynamic lookup) + // CarIdxTrackSurface can legitimately be unavailable (null) - see Base.cs's BasicMonitoring check + var dynamicTrackSurface = client.GetValue("CarIdxTrackSurface"); + if (telemetryData.CarIdxTrackSurface == null) + { + Assert.Null(dynamicTrackSurface); + } + else + { + var trackSurface = Assert.IsType(dynamicTrackSurface); + Assert.Equal(telemetryData.CarIdxTrackSurface.Length, trackSurface.Length); + for (var i = 0; i < trackSurface.Length; i++) + { + Assert.Equal((int)telemetryData.CarIdxTrackSurface[i], trackSurface[i]); + } + } + + // unknown variable name + Assert.Null(client.GetValue("NotARealVariableName")); + + cts.Cancel(); + return Task.CompletedTask; + }, + }, + cts.Token); + + Assert.True(sampleChecked, "Telemetry data was not received within the timeout period."); + } + + /// + /// verifies that every variable's advertised from + /// agrees with the runtime type + /// actually returns for that variable. Catches drift like + /// advertising string[] while returning a single decoded string, or uint while + /// returning int. Requires , since GetValue() + /// throws in . + /// + protected async Task BaseVerifyTelemetryVariableTypesMatchGetValue(ITelemetryClient client, int timeoutSecs = 5) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs)); + + var sampleChecked = false; + + await client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = _ => + { + // only need one sample + if (sampleChecked) + return Task.CompletedTask; + sampleChecked = true; + + var variables = client.GetTelemetryVariables(); + Assert.NotEmpty(variables); + + var mismatches = new List(); + foreach (var v in variables) + { + // a variable can legitimately have no value yet for this sample - GetValue() + // returning null for it isn't a type mismatch + var value = client.GetValue(v.Name); + if (value == null) + continue; + + var actualType = value.GetType(); + if (actualType != v.Type) + { + mismatches.Add($"{v.Name}: advertised {v.Type?.Name ?? "null"}, actual {actualType.Name}"); + } + } + + if (mismatches.Count > 0) + { + Assert.Fail( + $"GetTelemetryVariables().Type disagreed with GetValue()'s runtime type for " + + $"{mismatches.Count} variable(s):\n{string.Join("\n", mismatches)}"); + } + + cts.Cancel(); + return Task.CompletedTask; + }, + }, + cts.Token); + + Assert.True(sampleChecked, "Telemetry data was not received within the timeout period."); + } +} diff --git a/Sdk/tests/SmokeTests/IBT/IBT.cs b/Sdk/tests/SmokeTests/IBT/IBT.cs index 2ab6d8b..c001bdf 100644 --- a/Sdk/tests/SmokeTests/IBT/IBT.cs +++ b/Sdk/tests/SmokeTests/IBT/IBT.cs @@ -28,6 +28,9 @@ public class IBT : Base { const int TIMEOUT_SECS = 5; + // resolve relative to the test assembly's own location + private static readonly string IbtDataDirectory = Path.Combine(AppContext.BaseDirectory, "data", "ibt"); + public IBT(ITestOutputHelper output) : base(output) { } @@ -46,8 +49,7 @@ public static TheoryData>> { var testData = new TheoryData>>(); - var ibtDirectory = @"data\ibt"; - var ibtFiles = Directory.GetFiles(ibtDirectory, "*.ibt"); + var ibtFiles = Directory.GetFiles(IbtDataDirectory, "*.ibt"); foreach (var ibtFile in ibtFiles) { @@ -69,6 +71,89 @@ public override async Task BasicMonitoring(string _mode, Func.Create( + _logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + Assert.Null(client.GetValue("RPM")); + } + + [Fact] + public async Task GetValueInAsyncMode_Throws() + { + // the SDK only works in one delivery mode at a time - GetValue() is unusable in the default + // Async mode regardless of whether Monitor() has started, since production and consumption + // run on independent tasks and there is no way to guarantee it reflects the delivered sample. + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + await using var client = TelemetryClient.Create(_logger, new IBTOptions(ibtFile)); + + Assert.Throws(() => client.GetValue("RPM")); + } + + [Fact] + public async Task GetValueAfterDispose_Throws() + { + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + var client = TelemetryClient.Create(_logger, new IBTOptions(ibtFile)); + await client.DisposeAsync(); + + Assert.Throws(() => client.GetValue("RPM")); + } + + [Fact] + public async Task GetTelemetryVariablesBeforeMonitorStarted_DoesNotPermanentlyCacheEmpty() + { + // calling GetTelemetryVariables() before the provider has read headers must + // return an empty list without caching it - once Monitor() starts and headers become + // available, later calls must return the real list, not the stale empty one. + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + await using var client = TelemetryClient.Create(_logger, new IBTOptions(ibtFile)); + + Assert.Empty(client.GetTelemetryVariables()); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(TIMEOUT_SECS)); + var sampleReceived = false; + + await client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = _ => + { + sampleReceived = true; + cts.Cancel(); + return Task.CompletedTask; + } + }, + cts.Token); + + Assert.True(sampleReceived); + Assert.NotEmpty(client.GetTelemetryVariables()); + } + + [Fact] + public async Task GetTelemetryVariablesAfterDispose_Throws() + { + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + var client = TelemetryClient.Create(_logger, new IBTOptions(ibtFile)); + await client.DisposeAsync(); + + Assert.Throws(() => client.GetTelemetryVariables()); + } + [Fact] public async Task InvalidFileThrows() { @@ -99,6 +184,67 @@ public async Task VerifyAllVariablesAreCovered(string mode, Func> clientFactory) + { + _ = mode; // used only to name the test cases in the test runner display + + // GetValue() only works in Synchronous mode - see GetValueInAsyncMode_Throws. + await using var client = clientFactory(_logger); + await BaseVerifyDynamicLookupShape(client, TIMEOUT_SECS); + } + + [Theory] + [MemberData(nameof(SyncTestModes))] + public async Task VerifyTelemetryVariableTypesMatchGetValue(string mode, Func> clientFactory) + { + _ = mode; // used only to name the test cases in the test runner display + + // GetValue() only works in Synchronous mode - see GetValueInAsyncMode_Throws. + await using var client = clientFactory(_logger); + await BaseVerifyTelemetryVariableTypesMatchGetValue(client, TIMEOUT_SECS); + } + + /// + /// same file set as , but configured for + /// delivery, which guarantees GetValue() matches the + /// sample delivered to the handler even under unthrottled (as-fast-as-possible) IBT playback. + /// + public static TheoryData>> SyncTestModes + { + get + { + var testData = new TheoryData>>(); + + var ibtFiles = Directory.GetFiles(IbtDataDirectory, "*.ibt"); + + foreach (var ibtFile in ibtFiles) + { + var fileName = Path.GetFileNameWithoutExtension(ibtFile); + testData.Add( + $"IBT (sync) - {fileName}", + logger => TelemetryClient.Create( + logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }) + ); + } + + return testData; + } + } + + [Theory] + [MemberData(nameof(SyncTestModes))] + public async Task VerifyDynamicLookupMatchesTypedValues_SynchronousMode(string mode, Func> clientFactory) + { + _ = mode; // used only to name the test cases in the test runner display + + await using var client = clientFactory(_logger); + await BaseVerifyDynamicLookupMatchesTypedValues(client, TIMEOUT_SECS); + } + [Theory] [MemberData(nameof(TestModes))] public async Task MonitorCancellationCompletesDirectStreams(string mode, Func> clientFactory) @@ -145,11 +291,76 @@ public async Task HandlerExceptionFaultsMonitor(string mode, Func.Create( + _logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(TIMEOUT_SECS)); + + var errorsReceived = new List(); + + var actual = await Assert.ThrowsAsync(() => + client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = _ => throw new InvalidOperationException("handler failed"), + OnError = e => + { + errorsReceived.Add(e); + return Task.CompletedTask; + } + }, + cts.Token)); + + Assert.Equal("handler failed", actual.Message); + Assert.Empty(errorsReceived); + } + + [Fact] + public async Task SynchronousModeWithoutTelemetryHandler_MonitorNoHandlersOverload_Throws() + { + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + await using var client = TelemetryClient.Create( + _logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + await Assert.ThrowsAsync(() => client.Monitor(CancellationToken.None)); + } + + [Fact] + public async Task SynchronousModeWithoutTelemetryHandler_MonitorHandlersOverload_Throws() + { + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + await using var client = TelemetryClient.Create( + _logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + await Assert.ThrowsAsync(() => + client.Monitor( + new TelemetryHandlers + { + OnSessionInfoUpdate = _ => Task.CompletedTask + }, + CancellationToken.None)); + } + [Fact] public async Task MultipleHandlers_OneThrows_FaultsMonitorPromptly() { - // regression test: with multiple handlers registered, a throwing handler must fault Monitor promptly. - var ibtFile = Directory.GetFiles(@"data\ibt", "raygr22*").First(); + // with multiple handlers registered, a throwing handler must fault Monitor promptly. + var ibtFile = Directory.GetFiles(IbtDataDirectory, "raygr22*").First(); await using var client = TelemetryClient.Create( _logger, @@ -179,7 +390,7 @@ public async Task MultipleHandlers_OneThrows_FaultsMonitorPromptly() [Fact] public async Task HungHandlerFaultsMonitorAfterShutdownTimeout() { - var ibtFile = Directory.GetFiles(@"data\ibt", "*.ibt").First(); + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); await using var client = TelemetryClient.Create( _logger, @@ -205,7 +416,7 @@ public async Task HungHandlerFaultsMonitorAfterShutdownTimeout() [Fact] public async Task HandlerMonitorRejectsCompletedClientBeforeStartingHandlers() { - var ibtFile = Directory.GetFiles(@"data\ibt", "*.ibt").First(); + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); await using var client = TelemetryClient.Create( _logger, diff --git a/Sdk/tests/SmokeTests/Live/Live.cs b/Sdk/tests/SmokeTests/Live/Live.cs index 084d066..77b493f 100644 --- a/Sdk/tests/SmokeTests/Live/Live.cs +++ b/Sdk/tests/SmokeTests/Live/Live.cs @@ -62,4 +62,58 @@ public async Task VerifyModelMatchesRawYaml(string mode, Func.Create( + _logger, + ibtOptions: null, + clientOptions: new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + await BaseVerifyTelemetryVariableTypesMatchGetValue(client, TIMEOUT_SECS); + } + + [Fact] + public async Task HandlerExceptionFaultsMonitor_SynchronousMode() + { + // a synchronous-mode handler exception must fault Monitor() directly, + // exactly like Async mode, and must never be routed to OnError. Requires iRacing running + // with an active session sending telemetry (see Sdk/tests/README.md). + await using var client = TelemetryClient.Create( + _logger, + ibtOptions: null, + clientOptions: new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(TIMEOUT_SECS)); + + var errorsReceived = new List(); + + var actual = await Assert.ThrowsAsync(() => + client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = _ => throw new InvalidOperationException("handler failed"), + OnError = e => + { + errorsReceived.Add(e); + return Task.CompletedTask; + } + }, + cts.Token)); + + Assert.Equal("handler failed", actual.Message); + Assert.Empty(errorsReceived); + } + + [Fact] + public async Task SynchronousModeWithoutTelemetryHandler_Throws() + { + await using var client = TelemetryClient.Create( + _logger, + ibtOptions: null, + clientOptions: new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + await Assert.ThrowsAsync(() => client.Monitor(CancellationToken.None)); + } } diff --git a/Sdk/tests/SmokeTests/SmokeTests.csproj b/Sdk/tests/SmokeTests/SmokeTests.csproj index 3829258..63e61f4 100644 --- a/Sdk/tests/SmokeTests/SmokeTests.csproj +++ b/Sdk/tests/SmokeTests/SmokeTests.csproj @@ -46,13 +46,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/Sdk/tests/UnitTests/UnitTests.csproj b/Sdk/tests/UnitTests/UnitTests.csproj index a50b043..3a68bc5 100644 --- a/Sdk/tests/UnitTests/UnitTests.csproj +++ b/Sdk/tests/UnitTests/UnitTests.csproj @@ -27,15 +27,15 @@ - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ad2f390 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,315 @@ +# Architecture and Design + +This document describes the SDK's threading model, data pipeline, memory layout, buffering policy, and performance instrumentation. + +For installation and everyday usage, see the [README](../README.md). For direct stream access and multi-consumer patterns, see [Advanced Usage](./ADVANCED.md). + +## Table of Contents + +- [Design Principles](#design-principles) +- [Data Flow](#data-flow) +- [Data Layout](#data-layout) +- [Compile-Time Code Generation](#compile-time-code-generation) +- [Data Streaming and Buffering](#data-streaming-and-buffering) +- [Performance Characteristics](#performance-characteristics) +- [Performance Monitoring](#performance-monitoring) + +## Design Principles + +- **Compile-time telemetry types**: The source generator emits the requested telemetry schema; the runtime does not discover it through reflection. +- **Bounded, non-blocking streams**: Producers do not wait for slow consumers. Full channels discard the oldest unread item. +- **Independent processing paths**: Telemetry decoding and session-info parsing execute on separate tasks. +- **Recency over completeness**: The real-time path preserves current data at the cost of older unread samples under sustained backpressure. + +## Data Flow + +iRacing exposes live telemetry through shared memory at 60 Hz and persists recorded telemetry in IBT files. Both sources enter the same provider pipeline and feed independent output streams. + +```mermaid +flowchart LR + subgraph Sources + Live["iRacing
live shared memory"] + IBT["IBT file"] + end + + subgraph Task1["Task 1: Read data"] + Read["Read and decode"] + end + + subgraph Task2["Task 2: Process session info"] + Parse["Publish raw YAML
and parse it"] + end + + subgraph Streams["Application streams"] + Telemetry["TelemetryData"] + RawSession["SessionDataYaml"] + Session["SessionData"] + end + + Live --> Read + IBT --> Read + Read --> Telemetry + Read -- "session YAML" --> Parse + Parse --> RawSession + Parse --> Session +``` + +**Task1** handles source reads and telemetry decoding. **Task2** publishes raw session YAML and parses it into `SessionData`. The separation keeps YAML parsing latency out of the telemetry path. + +Live data and IBT playback share a data-provider abstraction and expose the same client API. + +## Data Layout + +The live memory-mapped file and IBT files use the same iRacing structures. The SDK reads both directly from unmanaged memory. + +The header is an index of offsets and counts. Region extents are derived from the referenced metadata. + +```mermaid +graph LR + subgraph HDR["irsdk_header - 112 bytes, at offset 0"] + VHO["varHeaderOffset
numVars"] + SIO["sessionInfoOffset
sessionInfoLen"] + BUF["varBuf[i].bufOffset
numBuf, bufLen"] + end + + VHO --> VH["irsdk_varHeader[numVars]
144 bytes each
name, type, offset, count"] + SIO --> SI["session info
YAML text"] + BUF --> TB["telemetry buffer
one row of bufLen bytes"] + + VH -. "describes the fields inside" .-> TB + + classDef header fill:#e1f5fe + classDef region fill:#e8f5e8 + class VHO,SIO,BUF header + class VH,SI,TB region +``` + +### IBT file layout + +An IBT file contains fixed metadata followed by a contiguous sequence of telemetry records: + +``` + ┌────────────────────────────────────┐ +0 │ irsdk_header 112 B │ + ├────────────────────────────────────┤ +112 │ irsdk_diskSubHeader 32 B │ IBT only + ├────────────────────────────────────┤ +144 │ irsdk_varHeader[numVars] │ numVars x 144 B + │ │ + ├────────────────────────────────────┤ +sessionInfo │ session info (YAML) │ sessionInfoLen B +Offset │ │ + ├────────────────────────────────────┤ +varBuf[0] │ record 0 │ bufLen B +.bufOffset ├────────────────────────────────────┤ + │ record 1 │ bufLen B + ├────────────────────────────────────┤ + │ ... │ + ├────────────────────────────────────┤ + │ record n-1 │ n = sessionRecordCount + └────────────────────────────────────┘ = end of file +``` + +Regions are packed without padding or alignment: + +``` +varHeaderOffset == 144 (immediately after the sub header) +sessionInfoOffset == 144 + numVars * 144 (immediately after the varHeaders) +bufOffset == sessionInfoOffset + sessionInfoLen (immediately after the YAML) +fileLength == bufOffset + sessionRecordCount * bufLen +``` + +Example layout with 264 variables and 123,937 records: + +``` +offset size region +───────────────────────────────────────────────────────────────── +0 112 irsdk_header +112 32 irsdk_diskSubHeader +144 38,016 irsdk_varHeader[264] 264 x 144 +38,160 26,463 session info (YAML) +64,623 127,655,110 records 123,937 x 1,030 + ───────────── + end of file 127,719,733 +``` + + +### Live in-memory layout + +The live map (`Local\IRSDKMemMapFileName`) uses the same header, session info, variable headers, and telemetry data, but reserves fixed-capacity regions. Header offsets are stable during a session. `sessionInfoLen` is the reserved capacity, not the current YAML string length. + + +``` + ┌────────────────────────────────────┐ +0 │ irsdk_header 112 B │ + ├────────────────────────────────────┤ +112 │ session info (YAML) │ 512 KB reserved + │ ...null padded... │ + ├────────────────────────────────────┤ +524,400 │ irsdk_varHeader[numVars] │ 576 KB reserved + │ ...unused... │ room for 4,096 entries + ├────────────────────────────────────┤ +1,114,224 │ telemetry buffer 0 │ 24 KB reserved + ├────────────────────────────────────┤ +1,138,800 │ telemetry buffer 1 │ 24 KB reserved + ├────────────────────────────────────┤ +1,163,376 │ telemetry buffer 2 │ 24 KB reserved + └────────────────────────────────────┘ 1,187,952 +``` + +iRacing rotates writes across `numBuf` telemetry buffers. + +| Field | Meaning | +|---|---| +| `curBuf` | index of the buffer most recently written | +| `curBufTickCount` | tick count of that buffer | +| `varBuf[i].tickCountBegin` | set *before* a write starts, where `tickCount` is set *after* it completes | + + +### Reading a telemetry row + +A telemetry row contains `bufLen` bytes of packed values. Each `irsdk_varHeader` entry defines a variable's type, offset, and element count: + +``` +irsdk_varHeader - 144 bytes a row of bufLen bytes +┌──────────────────────────┐ ┌─────────────────────────┐ +│ type (irsdk_VarType) │ 0 │ SessionTime (double) │ +│ offset ─────────────────┼──────┐ ├─────────────────────────┤ +│ count (array length) │ │ 8 │ SessionTick (int) │ +│ countAsTime │ │ ├─────────────────────────┤ +│ name[32] "Speed" │ │ 12 │ ... │ +│ desc[64] │ │ ├─────────────────────────┤ +│ unit[32] "m/s" │ └────────> n │ Speed (float) │ +└──────────────────────────┘ ├─────────────────────────┤ + │ ... │ + └─────────────────────────┘ +``` + +Element sizes are 1 byte for `irsdk_char` and `irsdk_bool`, 4 bytes for `irsdk_int`, `irsdk_bitField`, and `irsdk_float`, and 8 bytes for `irsdk_double`. A variable occupies `count * elementSize` bytes at `offset`; `count > 1` denotes an array, including the per-car `CarIdx*` variables. + +### Structure sizes + +| Structure | Size | Live | IBT | +|---|---|---|---| +| `irsdk_header` | 112 bytes | yes | yes | +| `irsdk_diskSubHeader` | 32 bytes | | yes, at offset 112 | +| `irsdk_varHeader` | 144 bytes | yes | yes | +| `irsdk_varBuf` | 16 bytes | yes | yes | + +## Compile-Time Code Generation + +For schemas known at compile time, the SDK uses a Roslyn source generator. A `RequiredTelemetryVars` attribute defines the requested schema: + +```csharp +[RequiredTelemetryVars([TelemetryVar.IsOnTrackCar, TelemetryVar.RPM, TelemetryVar.Speed, TelemetryVar.PlayerTrackSurface])] +``` + +The generator emits a `TelemetryData` record struct containing those fields: + +```csharp +public record struct TelemetryData +{ + public bool? IsOnTrackCar { get; init; } + public float? RPM { get; init; } + public float? Speed { get; init; } + public TrackLocation? PlayerTrackSurface { get; init; } +} +``` + +The generated type provides the following runtime characteristics: + +- No reflection or dictionary lookup in the telemetry path. +- Variable names and types are validated at compile time. +- Only requested variables are decoded. +- The generated API is available to the compiler and IDE tooling. + +Each sample is copied from mapped memory into a reused buffer. The live provider detects writes that overlap the copy and retries to avoid exposing a torn sample. Requested fields are then decoded from that buffer through `ReadOnlySpan`, without per-field copies or allocations. + +For schemas selected at runtime, `GetTelemetryVariables()` exposes the available variable metadata and `GetValue(string)` reads a value by name. Dynamic lookup requires `TelemetryDeliveryMode.Synchronous`; `DynamicTelemetryData` can be used as the client type when no generated fields are required. + +## Data Streaming and Buffering + +In the default `TelemetryDeliveryMode.Async` mode, application-facing streams use bounded channels with a drop-oldest policy. + +- Each application-facing channel has capacity for 60 items. For telemetry, this is equivalent to one second at the live 60 Hz rate. +- Reads are FIFO and destructive. +- A write to a full channel discards the oldest unread sample. +- Memory usage and producer latency remain bounded when a consumer falls behind. + +This policy favors recency over complete delivery. Consumers that require lossless processing must read the streams promptly and provide an appropriate downstream buffer; see [Advanced Usage](./ADVANCED.md). + +Async mode supports handler-based consumption through `Monitor(handlers, ct)` and direct access to each stream. In `TelemetryDeliveryMode.Synchronous`, telemetry samples are delivered inline to `OnTelemetryUpdate`; the producer does not read the next sample until the handler completes. `TelemetryData` is unavailable in synchronous mode. Both delivery modes accept cancellation through `CancellationToken`. + +## Performance Characteristics + +- IBT processing throughput exceeds 600,000 records per second in the referenced benchmark. +- The same benchmark measured approximately twice the throughput of the previous event-based implementation. +- Bounded channels prevent consumer backpressure from blocking source reads. +- The telemetry path has near-zero steady-state allocation. + +Actual throughput depends on the requested schema, consumer work, storage, and host hardware. + +## Performance Monitoring + +The SDK publishes runtime instrumentation through `System.Diagnostics.Metrics`. + +### Available Metrics + +Available instruments: + +- `telemetry_records_processed_total`: processed telemetry records +- `telemetry_processing_duration_microseconds`: telemetry processing duration +- `sessioninfo_records_processed_total`: processed session-info updates +- `sessioninfo_processing_duration_milliseconds`: session-info processing duration + +### Enabling Metrics with Dependency Injection + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +[RequiredTelemetryVars([TelemetryVar.Speed, TelemetryVar.RPM])] +public class Program +{ + public static async Task Main(string[] args) + { + var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => + { + services.AddMetrics(); + services.AddLogging(logging => logging.AddConsole()); + }) + .Build(); + + var logger = host.Services.GetRequiredService>(); + var meterFactory = host.Services.GetRequiredService(); + + var clientOptions = new ClientOptions { MeterFactory = meterFactory }; + await using var client = TelemetryClient.Create(logger, null, clientOptions); + } +} +``` + +### Monitoring with dotnet-counters + +Any `System.Diagnostics.Metrics` consumer can collect these instruments. For example, `dotnet-counters` can attach to a running process: + +```bash +# Monitor all SDK metrics for a running application named "YourApp" +dotnet-counters monitor --name "YourApp" --counters SVappsLAB.iRacingTelemetrySDK + +# Sample output: +# [SVappsLAB.iRacingTelemetrySDK] +# telemetry_records_processed_total 45,231 +# sessioninfo_records_processed_total 12 +``` + +Processing rates, latency distributions, and dropped-record counts identify whether the producer or a downstream consumer is the limiting stage. + +## Related Documentation + +- **[README](../README.md)** - Installation, quick start, and telemetry variables +- **[Advanced Usage](./ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior +- **[Migration Guide](./MIGRATION_GUIDE.md)** - Upgrading from early pre-1.0 releases +- **[SDK usage guide for agents](./ai/SDK_USAGE.md)** - Recommended usage for consumer applications diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index f7e4b1c..2564ed2 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -121,7 +121,7 @@ If a callback registered via `Monitor(handlers, ct)` does not return within 5 se - [ ] Drop `.WithCancellation(ct)` from normal `await foreach` loops on the client's streams. - [ ] Audit handlers for callbacks that may take longer than 5 seconds. -See `Samples/MinimalExample/` for a complete v2.0 reference. +See `Samples/MinimalExampleAsync/` for a complete v2.0 reference. --- diff --git a/docs/ai/SDK_REFERENCE.md b/docs/ai/SDK_REFERENCE.md index 33ef706..1d9d68f 100644 --- a/docs/ai/SDK_REFERENCE.md +++ b/docs/ai/SDK_REFERENCE.md @@ -35,12 +35,26 @@ await using var realtimeClient = TelemetryClient.Create(logger, r // Metrics support. var clientOptions = new ClientOptions { MeterFactory = meterFactory }; await using var metricsClient = TelemetryClient.Create(logger, ibtOptions, clientOptions); + +// Synchronous delivery - required to call GetValue(); it throws InvalidOperationException under +// the default Async mode. See "Telemetry Delivery Mode" below. +var syncOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }; +await using var syncClient = TelemetryClient.Create(logger, ibtOptions, syncOptions); ``` Validate IBT file paths before creating `IBTOptions` when paths come from user input. The client constructor throws `FileNotFoundException` for missing files. `playBackSpeedMultiplier: 1` runs IBT playback at real-time speed. The default, `int.MaxValue`, processes as fast as possible. Choose `1` for visualization-style apps and the default for batch analysis. +### Telemetry Delivery Mode + +`ClientOptions.DeliveryMode` controls how samples move from the background read loop to `OnTelemetryUpdate`, and the SDK only operates in one mode at a time - you cannot create a client in one mode and read its data with the other's mechanism: + +- **`TelemetryDeliveryMode.Async` (default).** Samples are pushed onto a 60-item drop-oldest channel and consumed by an independent task - high throughput, but production and consumption are decoupled. Because there'd be no way to guarantee it reflects the same record as the `T` sample the handler is currently processing, `GetValue()` is disallowed in this mode and throws `InvalidOperationException`. +- **`TelemetryDeliveryMode.Synchronous`.** Each sample is awaited directly by `OnTelemetryUpdate` from the same loop that reads it - the next record isn't read until the handler returns. `GetValue()` only works in this mode, and is guaranteed to match the delivered sample for the handler's full duration. Trade-off: processing/playback speed is bounded by handler speed, and the raw `TelemetryData` async-enumerable stream throws `InvalidOperationException` if accessed (there's nothing to queue - use the handler-based `Monitor` overload). + +Choose `Synchronous` only when code needs `GetValue()` - whether alone or mixed with the strongly-typed `T` in the same handler; otherwise `Async` is the better default. + ## Handler Callback Pattern Use this for most applications. diff --git a/docs/ai/SDK_USAGE.md b/docs/ai/SDK_USAGE.md index 4df8e20..6c2145a 100644 --- a/docs/ai/SDK_USAGE.md +++ b/docs/ai/SDK_USAGE.md @@ -78,6 +78,7 @@ public class Program 10. Live telemetry requires Windows and a running iRacing session. If unsure or running on Linux/macOS, default to IBT mode for portability. 11. Target .NET 8.0 or newer. 12. `OnError` is for SDK processing errors only. Exceptions thrown inside your handlers fault `Monitor(...)` directly; they are not sent to `OnError`. Wrap recoverable per-item work in `try/catch` inside the handler. +13. If the variable set is not known at compile time, use `TelemetryClient` and `GetValue(string)` instead of `RequiredTelemetryVars` + `TelemetryData`. `GetValue` requires `ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }` - it throws `InvalidOperationException` under the default `Async` mode. ## Error Handling @@ -127,6 +128,36 @@ sim.Camera.SwitchToPosition(CameraFocus.AtLeader, 1, 1); sim.Replay.Search(ReplaySearchMode.NextIncident); // only while out of the car ``` +## Dynamic Variable Lookup + +Use this only when the variable set is not known at compile time (e.g. a user-configurable dashboard) or when a `RequiredTelemetryVars`-annotated struct is impractical (e.g. some game engine build pipelines). Otherwise use the Default Pattern above. + +```csharp +// no RequiredTelemetryVars needed - use the DynamicTelemetryData placeholder +// GetValue requires Synchronous delivery mode - see below +var clientOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }; +await using var client = TelemetryClient.Create(logger, ibtOptions: null, clientOptions); + +var handlers = new TelemetryHandlers +{ + OnTelemetryUpdate = _ => + { + if (client.GetValue("Speed") is float speed) + { + Console.WriteLine($"Speed: {speed}"); + } + return Task.CompletedTask; + } +}; + +await client.Monitor(handlers, cts.Token); +``` + +- `GetValue(varName)` matches names case-insensitively and works for any variable reported by `GetTelemetryVariables()`, not just ones declared on `T`. +- Returns a boxed scalar (e.g. `float`) or array (e.g. `float[]`), or `null` if the variable is unknown or telemetry data isn't yet available. +- Prefer `T` for variables known at compile time; it's allocation-free. +- The SDK only works in one delivery mode at a time - `GetValue` is only usable when the client is created with `new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }`; calling it under the default `TelemetryDeliveryMode.Async` throws `InvalidOperationException`, since production and consumption run on independent tasks there and there'd be no way to guarantee `GetValue` matches the `T` sample delivered to the handler currently running. In `Synchronous` mode, each sample is awaited directly by the handler before the next record is read, so `GetValue` always matches the delivered sample - at the cost of throughput (see `SDK_REFERENCE.md`). + ## When To Use Direct Streams Use direct `IAsyncEnumerable` streams only when the user needs custom backpressure, separate consumers, or maximum IBT processing performance. For those patterns, read: diff --git a/global.json b/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +}