Unofficial C# SDK for the TofuPilot hardware testing platform (V2 API).
- Installation
- Authentication
- Client Setup
- Dependency Injection
- Resources
- Pagination
- Error Handling
- Configuration
- Enums
dotnet add package Hylaean.TofuPilotThe SDK authenticates via a Bearer token (API key). It can be provided directly or read from the TOFUPILOT_API_KEY environment variable.
// Explicit
using var client = new TofuPilotClient(apiKey: "tp_...");
// From environment variable TOFUPILOT_API_KEY
using var client = new TofuPilotClient();using Hylaean.TofuPilot;
// Defaults: API key from env, base URL https://www.tofupilot.app/api/
using var client = new TofuPilotClient();
// Custom base URL
using var client = new TofuPilotClient(
apiKey: "tp_...",
baseUrl: "https://your-instance.tofupilot.com/api/"
);The base URL can also be set via the TOFUPILOT_URL environment variable.
using Hylaean.TofuPilot.Configuration;
// Option 1: Configure with action
services.AddTofuPilot(options =>
{
options.ApiKey = "tp_...";
options.BaseUrl = "https://www.tofupilot.app/api/";
options.TimeoutSeconds = 30;
options.Retry = new RetryOptions
{
MaxRetries = 3,
InitialDelayMs = 1000,
MaxDelayMs = 30000
};
});
// Option 2: Bind from IConfiguration (reads section "TofuPilot")
services.AddTofuPilot(configuration);Then inject TofuPilotClient into your services:
public class MyService(TofuPilotClient client)
{
public async Task DoWork()
{
var runs = await client.Runs.ListAsync();
}
}All methods are async, accept an optional CancellationToken, and throw typed exceptions on failure.
Manage test runs — the core entity in TofuPilot.
Lists runs with optional filtering.
var runs = await client.Runs.ListAsync(new ListRunsRequest
{
Outcomes = [RunOutcome.PASS, RunOutcome.FAIL],
ProcedureIds = ["proc-uuid"],
SerialNumbers = ["SN-001"],
StartedAfter = DateTimeOffset.UtcNow.AddDays(-7),
Limit = 20,
SortBy = "started_at",
SortOrder = "desc"
});
foreach (var run in runs.Data)
Console.WriteLine($"{run.Id} — {run.Outcome}");ListRunsRequest fields:
| Field | Type | Description |
|---|---|---|
SearchQuery |
string? |
Full-text search |
Ids |
IReadOnlyList<string>? |
Filter by run IDs |
Outcomes |
IReadOnlyList<RunOutcome>? |
Filter by outcome |
ProcedureIds |
IReadOnlyList<string>? |
Filter by procedure |
ProcedureVersions |
IReadOnlyList<string>? |
Filter by procedure version |
SerialNumbers |
IReadOnlyList<string>? |
Filter by serial number |
PartNumbers |
IReadOnlyList<string>? |
Filter by part number |
RevisionNumbers |
IReadOnlyList<string>? |
Filter by revision number |
DurationMin |
double? |
Minimum duration |
DurationMax |
double? |
Maximum duration |
StartedAfter |
DateTimeOffset? |
Started after date |
StartedBefore |
DateTimeOffset? |
Started before date |
EndedAfter |
DateTimeOffset? |
Ended after date |
EndedBefore |
DateTimeOffset? |
Ended before date |
CreatedAfter |
DateTimeOffset? |
Created after date |
CreatedBefore |
DateTimeOffset? |
Created before date |
CreatedByUserIds |
IReadOnlyList<string>? |
Filter by creating user |
CreatedByStationIds |
IReadOnlyList<string>? |
Filter by creating station |
OperatedByIds |
IReadOnlyList<string>? |
Filter by operator |
Limit |
int? |
Page size (default 50) |
Cursor |
double? |
Pagination cursor |
SortBy |
string? |
Sort field (default "started_at") |
SortOrder |
string? |
"asc" or "desc" (default "desc") |
Creates a test run with phases, measurements, and logs.
var run = await client.Runs.CreateAsync(new CreateRunRequest
{
Outcome = RunOutcome.PASS,
ProcedureId = "proc-uuid",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
EndedAt = DateTimeOffset.UtcNow,
SerialNumber = "SN-001",
PartNumber = "PART-A",
RevisionNumber = "A",
BatchNumber = "BATCH-01",
ProcedureVersion = "1.0.0",
SubUnits = ["SN-SUB-001"],
Phases = [
new CreateRunPhase
{
Name = "Voltage Test",
Outcome = PhaseOutcome.PASS,
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
EndedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
Measurements = [
new CreateRunMeasurement
{
Name = "Output Voltage",
Outcome = MeasurementOutcome.PASS,
MeasuredValue = 5.02,
Units = "V",
LowerLimit = 4.8,
UpperLimit = 5.2
}
]
}
],
Logs = [
new CreateRunLog
{
Level = LogLevel.INFO,
Timestamp = DateTimeOffset.UtcNow.ToString("o"),
Message = "Test completed"
}
]
});CreateRunRequest fields:
| Field | Type | Required | Description |
|---|---|---|---|
Outcome |
RunOutcome |
Yes | RUNNING, PASS, FAIL, ERROR, TIMEOUT, ABORTED |
ProcedureId |
string |
Yes | Procedure UUID |
StartedAt |
DateTimeOffset |
Yes | Run start time |
EndedAt |
DateTimeOffset |
Yes | Run end time |
SerialNumber |
string |
Yes | Unit serial number |
ProcedureVersion |
string? |
No | Procedure version tag |
OperatedBy |
string? |
No | Operator ID |
PartNumber |
string? |
No | Part number |
RevisionNumber |
string? |
No | Revision number |
BatchNumber |
string? |
No | Batch number |
SubUnits |
IReadOnlyList<string>? |
No | Sub-unit serial numbers |
Docstring |
string? |
No | Documentation string |
Phases |
IReadOnlyList<CreateRunPhase>? |
No | Test phases |
Logs |
IReadOnlyList<CreateRunLog>? |
No | Log entries |
var run = await client.Runs.GetAsync("run-uuid");Currently used to attach files to a run.
await client.Runs.UpdateAsync(runId, new UpdateRunRequest
{
Attachments = [attachmentId]
});Bulk deletes runs by IDs.
var result = await client.Runs.DeleteAsync(["run-1", "run-2"]);
// result.Id contains the list of deleted IDsManage units under test, including parent-child hierarchies.
var units = await client.Units.ListAsync(new ListUnitsRequest
{
SerialNumbers = ["SN-001", "SN-002"],
Limit = 20
});var unit = await client.Units.CreateAsync(new CreateUnitRequest
{
SerialNumber = "SN-001",
PartNumber = "PART-A",
RevisionNumber = "A",
BatchNumber = "BATCH-01" // optional
});var unit = await client.Units.GetAsync("SN-001");var updated = await client.Units.UpdateAsync("SN-001", new UpdateUnitRequest
{
SerialNumber = "SN-001-NEW" // renames the serial number
});await client.Units.DeleteAsync("SN-001");
await client.Units.DeleteAsync(["SN-001", "SN-002"]);var parent = await client.Units.AddChildAsync("PARENT-SN", "CHILD-SN");var parent = await client.Units.RemoveChildAsync("PARENT-SN", "CHILD-SN");Manage test procedures (test definitions).
var procs = await client.Procedures.ListAsync(new ListProceduresRequest
{
SearchQuery = "battery"
});var proc = await client.Procedures.CreateAsync(new CreateProcedureRequest
{
Name = "Battery Test",
Description = "Full charge/discharge cycle"
});var proc = await client.Procedures.GetAsync("proc-uuid");
var updated = await client.Procedures.UpdateAsync("proc-uuid", new UpdateProcedureRequest
{
Name = "Battery Test v2",
Description = "Updated description"
});
await client.Procedures.DeleteAsync("proc-uuid");Accessed via client.Procedures.Versions.
var version = await client.Procedures.Versions.CreateAsync("proc-uuid",
new CreateProcedureVersionRequest { Tag = "1.0.0" });var version = await client.Procedures.Versions.GetAsync("proc-uuid", "1.0.0");await client.Procedures.Versions.DeleteAsync("proc-uuid", "1.0.0");Manage part definitions.
var parts = await client.Parts.ListAsync(new ListPartsRequest
{
SearchQuery = "capacitor",
Limit = 10
});var part = await client.Parts.CreateAsync(new CreatePartRequest
{
PartNumber = "CAP-100UF",
Name = "100µF Capacitor",
Description = "Electrolytic, 25V"
});var part = await client.Parts.GetAsync("CAP-100UF");
var updated = await client.Parts.UpdateAsync("CAP-100UF", new UpdatePartRequest
{
Name = "100µF Capacitor (updated)"
});
// Returns DeletePartResponse with Id and DeletedRevisionIds (cascade)
var result = await client.Parts.DeleteAsync("CAP-100UF");Accessed via client.Parts.Revisions.
var rev = await client.Parts.Revisions.CreateAsync("CAP-100UF",
new CreatePartRevisionRequest { RevisionNumber = "B" });var rev = await client.Parts.Revisions.GetAsync("CAP-100UF", "B");
var updated = await client.Parts.Revisions.UpdateAsync("CAP-100UF", "B",
new UpdatePartRevisionRequest { RevisionNumber = "C" });
await client.Parts.Revisions.DeleteAsync("CAP-100UF", "C");Manage production batches.
var batches = await client.Batches.ListAsync(new ListBatchesRequest
{
SearchQuery = "2024-Q1"
});var batch = await client.Batches.CreateAsync(new CreateBatchRequest
{
BatchNumber = "BATCH-2024-Q1",
PartNumber = "CAP-100UF" // optional
});var batch = await client.Batches.GetAsync("BATCH-2024-Q1");
var updated = await client.Batches.UpdateAsync("BATCH-2024-Q1", new UpdateBatchRequest
{
BatchNumber = "BATCH-2024-Q1-FINAL"
});
await client.Batches.DeleteAsync("BATCH-2024-Q1-FINAL");Manage test stations (physical or logical test locations).
var stations = await client.Stations.ListAsync(new ListStationsRequest
{
SearchQuery = "assembly"
});var station = await client.Stations.CreateAsync(new CreateStationRequest
{
Name = "Assembly Station 1",
Description = "Main assembly line"
});var station = await client.Stations.GetAsync("station-uuid");
// Get the station identified by the current API key
var current = await client.Stations.GetCurrentAsync();
var updated = await client.Stations.UpdateAsync("station-uuid", new UpdateStationRequest
{
Description = "Updated description"
});
await client.Stations.RemoveAsync("station-uuid");Upload and manage file attachments. The SDK provides a one-line upload that wraps the 3-step flow (initialize → S3 PUT → finalize).
// Upload from file path
var attachmentId = await client.Attachments.UploadAsync("report.pdf");
// Upload from stream
using var stream = File.OpenRead("data.csv");
var id = await client.Attachments.UploadAsync(stream, "data.csv");
// Link to a run
await client.Runs.UpdateAsync(runId, new UpdateRunRequest
{
Attachments = [attachmentId]
});// Download from a signed URL (e.g. from a run's attachment)
await AttachmentsResource.DownloadAsync(attachment.DownloadUrl, "local-report.pdf");If you need more control over the upload flow:
// 1. Initialize — get presigned URL
var init = await client.Attachments.InitializeAsync(
new InitializeUploadRequest { FileName = "report.pdf" });
// 2. Upload to S3 (your own HTTP PUT to init.UploadUrl)
// ...
// 3. Finalize
var finalized = await client.Attachments.FinalizeAsync(init.Id!);
// finalized.Url = signed download URLawait client.Attachments.DeleteAsync("attachment-uuid");
await client.Attachments.DeleteAsync(["id-1", "id-2"]);Note: Attachments linked to runs cannot be deleted — unlink them first.
List organization users.
// List all users
var users = await client.Users.ListAsync();
// Get only the current authenticated user
var me = await client.Users.ListAsync(new ListUsersRequest { Current = true });User fields: Id, Email, Name, Image, Banned
List methods return PaginatedResponse<T> with cursor-based pagination:
var allRuns = new List<Run>();
double? cursor = null;
do
{
var page = await client.Runs.ListAsync(new ListRunsRequest
{
Limit = 50,
Cursor = cursor
});
allRuns.AddRange(page.Data);
cursor = page.HasMore ? page.NextCursor : null;
} while (cursor is not null);PaginatedResponse properties:
| Property | Type | Description |
|---|---|---|
Data |
IReadOnlyList<T> |
Items on this page |
Meta |
PaginationMeta? |
Pagination metadata |
HasMore |
bool |
Whether more pages exist |
NextCursor |
double? |
Cursor for the next page |
Every model object has fluent extension methods so you can work directly with objects instead of passing IDs back to resource methods. Both styles coexist — use whichever fits:
// Resource-first (existing API — unchanged)
await client.Runs.DeleteAsync([run.Id]);
// Model-first (extension methods — new)
await run.DeleteAsync(client);// Upload a file and attach it to a run in one call
var attachmentId = await run.AttachAsync(client, "report.pdf");
// Stream overload
var id = await run.AttachAsync(client, stream, "data.csv");
// Re-fetch latest state
run = await run.RefreshAsync(client);
// Delete
await run.DeleteAsync(client);// Parent-child management
var parent = await unit.AddChildAsync(client, "CHILD-SN");
parent = await unit.RemoveChildAsync(client, "CHILD-SN");
// Update, refresh, delete
var updated = await unit.UpdateAsync(client, new UpdateUnitRequest { SerialNumber = "NEW-SN" });
unit = await unit.RefreshAsync(client);
await unit.DeleteAsync(client);var updated = await proc.UpdateAsync(client, new UpdateProcedureRequest { Name = "v2" });
var version = await proc.CreateVersionAsync(client, new CreateProcedureVersionRequest { Tag = "1.0.0" });
proc = await proc.RefreshAsync(client);
await proc.DeleteAsync(client);var updated = await part.UpdateAsync(client, new UpdatePartRequest { Name = "Updated" });
var rev = await part.CreateRevisionAsync(client, new CreatePartRevisionRequest { RevisionNumber = "B" });
part = await part.RefreshAsync(client);
await part.DeleteAsync(client); // also deletes revisionsvar updated = await batch.UpdateAsync(client, new UpdateBatchRequest { BatchNumber = "NEW-NUM" });
batch = await batch.RefreshAsync(client);
await batch.DeleteAsync(client);var updated = await station.UpdateAsync(client, new UpdateStationRequest { Description = "New desc" });
station = await station.RefreshAsync(client);
await station.RemoveAsync(client);All API errors throw typed exceptions inheriting from TofuPilotException:
try
{
var run = await client.Runs.GetAsync("invalid-id");
}
catch (NotFoundException ex)
{
Console.WriteLine($"Not found: {ex.Message}");
}
catch (UnauthorizedException)
{
Console.WriteLine("Invalid API key");
}
catch (RateLimitException ex)
{
Console.WriteLine($"Rate limited — retry after {ex.RetryAfter}");
}
catch (TofuPilotException ex)
{
Console.WriteLine($"API error {ex.StatusCode}: {ex.Message}");
Console.WriteLine($"Response: {ex.ResponseBody}");
}Exception types:
| Exception | HTTP Status | Description |
|---|---|---|
BadRequestException |
400 | Invalid request parameters |
UnauthorizedException |
401 | Invalid or missing API key |
ForbiddenException |
403 | Access denied |
NotFoundException |
404 | Resource not found |
ConflictException |
409 | Resource conflict (duplicate) |
UnprocessableEntityException |
422 | Validation error |
RateLimitException |
429 | Rate limit exceeded (has RetryAfter) |
InternalServerErrorException |
500 | Server error |
ServiceUnavailableException |
503 | Service unavailable |
NetworkException |
— | Network/connectivity error |
All exceptions expose: StatusCode, ErrorCode, ResponseBody.
| Property | Type | Default | Description |
|---|---|---|---|
ApiKey |
string? |
env TOFUPILOT_API_KEY |
API key |
BaseUrl |
string? |
"https://www.tofupilot.app/api/" |
API base URL (env TOFUPILOT_URL) |
TimeoutSeconds |
int |
30 |
HTTP timeout |
Retry |
RetryOptions |
see below | Retry configuration |
| Property | Type | Default | Description |
|---|---|---|---|
Enabled |
bool |
true |
Enable automatic retries |
MaxRetries |
int |
3 |
Maximum retry attempts |
InitialDelayMs |
int |
1000 |
Initial delay between retries (ms) |
MaxDelayMs |
int |
30000 |
Maximum delay between retries (ms) |
BackoffMultiplier |
double |
2.0 |
Exponential backoff multiplier |
RetryableStatusCodes |
int[] |
[429, 500, 502, 503, 504] |
Status codes that trigger retries |
RUNNING · PASS · FAIL · ERROR · TIMEOUT · ABORTED
PASS · FAIL · SKIP · ERROR
PASS · FAIL · UNSET
DEBUG · INFO · WARNING · ERROR · CRITICAL