Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **P0 for operators with diverging Frigate ↔ Blue Iris camera names.** Restored legacy `FrigateMQTTProcessingService.CameraShortName` semantics that the v1.0.0 → v1.0.1 migration tool dropped. Blue Iris's HTTP trigger API returns 200 OK on unknown camera names but **silently does nothing** — so URL templates that send Frigate's lowercase id (e.g. `driveway`) to a Blue Iris install expecting its own shortname (e.g. `DriveWayHD`) appeared to succeed but never actually triggered the recording. The bug went unnoticed during the v1.0.0 parity window because the legacy service was running concurrently and firing the trigger with the correct name; once an operator stopped legacy, BI triggers stopped. (#32, verified end-to-end on 2026-05-01.) The fix:
- New optional `CameraShortName` field on each subscription in `appsettings.json`. Defaults to null; when set, surfaces through the new `{camera_shortname}` URL-template token. The dedupe-cache and subscription matcher continue to key on `Camera` (Frigate's id) — only template substitution sees the override.
- New `{camera_shortname}` token added to `EventTokenTemplate.AllowedTokens` (alongside the existing `{camera}`, `{label}`, `{event_id}`, `{zone}`). Resolution order: `CameraShortName ?? Camera`, so operators whose names already match keep working without setting the override.
- `EventContext` gained an optional `CameraShortName` property that the host's `EventPump` populates per-dispatch via a `with`-clone (the source-agnostic invariant on `EventContext` is preserved — `IEventSource` implementations never set this field).
- `tools/FrigateRelay.MigrateConf` now preserves the legacy `CameraShortName` value into the new field on each subscription (skipping emit when it equals `CameraName` — the override is redundant in that case). The migrated `BlueIris.TriggerUrlTemplate` now uses `{camera_shortname}` so existing legacy operators get the corrected behaviour automatically.
- `README.md` "Key concepts" gains a bullet documenting `CameraShortName` and the silent-no-op trap it solves; the canonical `appsettings.Example.json` is intentionally left minimal (no `CameraShortName`) to preserve the Phase 8 success-criterion ≤60% size-parity gate.

**Operator-action item for anyone running v1.0.0 / v1.0.1 with diverging Frigate/BI camera names** (this is most operators with a pre-existing BI install): after upgrading to the version with this fix, set `CameraShortName` on each subscription and change your `BlueIris:TriggerUrlTemplate` from `&camera={camera}` to `&camera={camera_shortname}`. If your Frigate camera ids and BI shortnames already match — congratulations, no config change needed.

### Documentation

- **Validator engine status** section in `README.md` and `CodeProjectAiOptions` XML doc remarks rewritten to hard-confirm Blue Onyx as a supported backend (no separate plugin needed — point `Validators:<name>:BaseUrl` at the Blue Onyx host and the existing CPAI plugin handles it). Verified end-to-end by an operator on 2026-05-01. Also documented the concrete performance caveat: Blue Onyx GPU acceleration is available only via its Windows EXE/service distribution; the Docker image is CPU-only and slower than CPAI's CUDA-enabled Docker image on the same hardware. Closes #12 (the original "add Blue Onyx validator" ask reduces to docs work since the existing plugin already handles it).
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ cp docker/.env.example .env
Edit `.env` and fill in your secrets:

```
BLUEIRIS__TRIGGERURLTEMPLATE=http://your-blueiris-host:81/admin?camera={camera}&trigger=1
BLUEIRIS__TRIGGERURLTEMPLATE=http://your-blueiris-host:81/admin?camera={camera_shortname}&trigger=1
PUSHOVER__APPTOKEN=your-app-token
PUSHOVER__USERKEY=your-user-key
```
Expand Down Expand Up @@ -92,6 +92,7 @@ A full example config lives at `config/appsettings.Example.json`. A minimal exce

- **Profiles** are reusable action lists, referenced by name from subscriptions. Define once, use across many subscriptions.
- **Subscriptions** match events by camera name, object label, and optional zone. Each subscription uses a profile or declares its own inline action list.
- **`CameraShortName` (optional, per subscription):** an alternate camera identifier surfaced through the `{camera_shortname}` URL-template token. Use when the downstream system (typically Blue Iris) names the camera differently from the originating source — e.g. Frigate id `"driveway"` vs Blue Iris shortname `"DriveWayHD"`. Without it, BI's HTTP trigger API returns 200 OK on the wrong name but silently does nothing.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **SnapshotProvider override:** `"SnapshotProvider": "Frigate"` on an action overrides the subscription default. Resolution order: per-action → per-subscription → global `DefaultSnapshotProvider`.
- **Validators** attach to specific action entries and gate that action independently.

Expand Down
10 changes: 10 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,15 @@ coverage:
threshold: 1%
patch:
default:
# Patch coverage is INFORMATIONAL — the comment on each PR still
# reports the % so we can see it, but it does not fail the merge
# check. Rationale: this is a one-maintainer project and patch
# coverage hits noisy edge cases (cobertura path-casing mismatches
# across test runs, source-generator partial classes excluded from
# one assembly's instrumentation but tracked in another, whitespace
# re-alignment treated as "modified lines" — see PR #33 for a
# worked example). The project-status check above stays strict, so
# real regressions still surface.
target: auto
threshold: 1%
informational: true
6 changes: 3 additions & 3 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@
# add the FrigateRelay container's host to the trusted-IP list, after
# which trigger requests need no credentials in the URL:
#
# BLUEIRIS__TRIGGERURLTEMPLATE=http://blueiris.lan:81/admin?camera={camera}&trigger=1
# BLUEIRIS__TRIGGERURLTEMPLATE=http://blueiris.lan:81/admin?camera={camera_shortname}&trigger=1
#
# 2. URL-embedded credentials (when whitelist is not an option):
#
# BLUEIRIS__TRIGGERURLTEMPLATE=http://blueiris.lan:81/admin?camera={camera}&trigger=1&user=<USER>&pw=<PW>
# BLUEIRIS__TRIGGERURLTEMPLATE=http://blueiris.lan:81/admin?camera={camera_shortname}&trigger=1&user=<USER>&pw=<PW>
#
# Optional snapshot-source template (only set when using BlueIris as the
# snapshot provider; same {camera}/{label}/{event_id}/{zone} tokens):
# BLUEIRIS__SNAPSHOTURLTEMPLATE=http://blueiris.lan:81/image/{camera}?q=85
BLUEIRIS__TRIGGERURLTEMPLATE=http://blueiris.lan:81/admin?camera={camera}&trigger=1
BLUEIRIS__TRIGGERURLTEMPLATE=http://blueiris.lan:81/admin?camera={camera_shortname}&trigger=1

# Pushover credentials. Bound to Pushover:* options class. Note: the
# property is AppToken (not ApiToken) — the env var key must be APPTOKEN.
Expand Down
16 changes: 16 additions & 0 deletions src/FrigateRelay.Abstractions/EventContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,20 @@ public sealed record EventContext
/// Callers should not invoke this from a hot path; snapshot fetching typically involves an outbound HTTP call.
/// </summary>
public required Func<CancellationToken, ValueTask<byte[]?>> SnapshotFetcher { get; init; }

/// <summary>
/// Optional alternative camera identifier used when the downstream system (e.g. Blue Iris)
/// names the camera differently from the originating source. The host's <c>EventPump</c>
/// populates this per-dispatch from <c>SubscriptionOptions.CameraShortName</c> via a
/// <c>with</c>-clone before invoking action plugins; sources MUST NOT set this field
/// (the source-agnostic invariant on <see cref="EventContext"/> is preserved).
/// </summary>
/// <remarks>
/// Resolved by the <c>{camera_shortname}</c> token in <c>EventTokenTemplate</c> with a
/// fall-through to <see cref="Camera"/> when null. See issue #32 for the legacy-parity
/// motivation: Blue Iris returns 200 OK on unknown camera names but silently does nothing,
/// so URL templates that send Frigate's lowercase id to a BI server expecting its own
/// shortname (e.g. <c>DriveWayHD</c>) appear to succeed but never trigger the recording.
/// </remarks>
public string? CameraShortName { get; init; }
}
22 changes: 13 additions & 9 deletions src/FrigateRelay.Abstractions/EventTokenTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ namespace FrigateRelay.Abstractions;
/// fields into a string. Supports URL-encoding (default) or raw substitution.
/// </summary>
/// <remarks>
/// Allowed tokens: {camera}, {label}, {event_id}, {zone}. The {score} placeholder is
/// explicitly excluded (ID-7 hard rail) — EventContext carries no Score property.
/// Allowed tokens: {camera}, {camera_shortname}, {label}, {event_id}, {zone}. The {score}
/// placeholder is explicitly excluded (ID-7 hard rail) — EventContext carries no Score property.
/// Use <see cref="Parse"/> to obtain an instance; the constructor is private.
/// </remarks>
public sealed partial class EventTokenTemplate
Expand All @@ -19,7 +19,8 @@ public sealed partial class EventTokenTemplate

/// <summary>The set of token names accepted by <see cref="Parse"/>.</summary>
public static readonly FrozenSet<string> AllowedTokens =
new[] { "camera", "label", "event_id", "zone" }.ToFrozenSet(StringComparer.Ordinal);
new[] { "camera", "camera_shortname", "label", "event_id", "zone" }
.ToFrozenSet(StringComparer.Ordinal);

private readonly string _template;

Expand Down Expand Up @@ -48,7 +49,7 @@ public static EventTokenTemplate Parse(string template, string callerName)
if (!AllowedTokens.Contains(name))
throw new ArgumentException(
$"{callerName}: template contains unknown placeholder '{{{name}}}'. " +
$"Allowed placeholders: {{camera}}, {{label}}, {{event_id}}, {{zone}}.",
$"Allowed placeholders: {{camera}}, {{camera_shortname}}, {{label}}, {{event_id}}, {{zone}}.",
nameof(template));
}

Expand All @@ -71,11 +72,14 @@ public string Resolve(EventContext context, bool urlEncode = true)
{
var raw = m.Groups["name"].Value switch
{
"camera" => context.Camera,
"label" => context.Label,
"event_id" => context.EventId,
"zone" => context.Zones.Count > 0 ? context.Zones[0] : "",
_ => m.Value, // unreachable — Parse() guards
"camera" => context.Camera,
"camera_shortname" => string.IsNullOrWhiteSpace(context.CameraShortName)
? context.Camera
: context.CameraShortName,
"label" => context.Label,
"event_id" => context.EventId,
"zone" => context.Zones.Count > 0 ? context.Zones[0] : "",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
_ => m.Value, // unreachable — Parse() guards
};
return urlEncode ? Uri.EscapeDataString(raw) : raw;
});
Expand Down
14 changes: 14 additions & 0 deletions src/FrigateRelay.Host/Configuration/SubscriptionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ internal sealed record SubscriptionOptions
/// <summary>Gets the camera name to match (case-insensitive).</summary>
public required string Camera { get; init; }

/// <summary>
/// Optional alternative camera identifier surfaced through the <c>{camera_shortname}</c>
/// token in plugin URL/message templates. When <see langword="null"/>, <c>{camera_shortname}</c>
/// falls through to <see cref="Camera"/>.
/// </summary>
/// <remarks>
/// Restores legacy <c>FrigateMQTTProcessingService.CameraShortName</c> semantics (issue #32).
/// Use when the downstream system (e.g. Blue Iris) names this camera differently from the
/// originating source — e.g. Frigate id <c>"driveway"</c> vs Blue Iris shortname
/// <c>"DriveWayHD"</c>. The dedupe-cache and matcher continue to key on <see cref="Camera"/>;
/// only template substitution sees this value.
/// </remarks>
public string? CameraShortName { get; init; }

/// <summary>Gets the detection label to match (e.g. <c>"person"</c>, <c>"car"</c>) (case-insensitive).</summary>
public required string Label { get; init; }

Expand Down
13 changes: 11 additions & 2 deletions src/FrigateRelay.Host/EventPump.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ private async Task PumpAsync(IEventSource source, CancellationToken ct)
1,
new TagList { { "camera", context.Camera }, { "label", context.Label }, { "subscription", sub.Name } });

// Apply per-subscription EventContext augmentations before dispatch (#32).
// Today only CameraShortName; future host-managed per-subscription overrides
// would plug in here. The source-agnostic invariant on EventContext stays
// intact because the originating source never set this field — only the
// host's matcher does, after the source has handed off the event.
var dispatchContext = sub.CameraShortName is { } shortName
? context with { CameraShortName = shortName }
: context;

// dispatch.enqueue span — wraps per-subscription action enqueue loop (PLAN-2.1 Task 1).
using (var enqueueActivity = DispatcherDiagnostics.ActivitySource.StartActivity(
"dispatch.enqueue", ActivityKind.Producer))
Expand All @@ -142,9 +151,9 @@ private async Task PumpAsync(IEventSource source, CancellationToken ct)
: Array.Empty<IValidationPlugin>();

await _dispatcher.EnqueueAsync(
context, plugin, validators,
dispatchContext, plugin, validators,
sub.Name, entry.SnapshotProvider, sub.DefaultSnapshotProvider, ct).ConfigureAwait(false);
LogDispatchEnqueued(_logger, plugin.Name, sub.Name, context.EventId, null);
LogDispatchEnqueued(_logger, plugin.Name, sub.Name, dispatchContext.EventId, null);
}
}
}
Expand Down
106 changes: 105 additions & 1 deletion tests/FrigateRelay.Abstractions.Tests/EventTokenTemplateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ private static EventContext NewCtx(
string camera = "front",
string label = "person",
string eventId = "ev-1",
IReadOnlyList<string>? zones = null) => new()
IReadOnlyList<string>? zones = null,
string? cameraShortName = null) => new()
{
EventId = eventId,
Camera = camera,
Expand All @@ -20,6 +21,7 @@ private static EventContext NewCtx(
StartedAt = DateTimeOffset.UnixEpoch,
RawPayload = "{}",
SnapshotFetcher = static _ => ValueTask.FromResult<byte[]?>(null),
CameraShortName = cameraShortName,
};

[TestMethod]
Expand Down Expand Up @@ -97,4 +99,106 @@ public void Resolve_FirstZoneUsed()

result.Should().Be("driveway");
}

// ---------- {camera_shortname} (#32) ----------

[TestMethod]
public void Parse_CameraShortnameToken_AcceptedWithoutThrowing()
{
var act = () => EventTokenTemplate.Parse(
"http://bi/admin?camera={camera_shortname}", "Caller=BlueIris");
act.Should().NotThrow();
}

[TestMethod]
public void Resolve_CameraShortnameUnset_FallsThroughToCamera()
{
var tmpl = EventTokenTemplate.Parse("{camera_shortname}", "Caller=Test");
var ctx = NewCtx(camera: "driveway", cameraShortName: null);

var result = tmpl.Resolve(ctx, urlEncode: false);

result.Should().Be("driveway",
"operators whose Frigate id and BI shortname already match should keep " +
"working without setting CameraShortName per subscription");
}

[TestMethod]
[DataRow("")]
[DataRow(" ")]
[DataRow("\t")]
public void Resolve_CameraShortnameBlankOrWhitespace_FallsThroughToCamera(string blankOverride)
{
// IConfiguration.Bind happily produces an empty string for "CameraShortName": "" or
// for an env var like CAMERASHORTNAME= with no value. Plain `??` would let that empty
// value through and we'd be back to the silent-no-op trap #32 was supposed to close.
// Treat any blank/whitespace as unset.
var tmpl = EventTokenTemplate.Parse("{camera_shortname}", "Caller=Test");
var ctx = NewCtx(camera: "driveway", cameraShortName: blankOverride);

var result = tmpl.Resolve(ctx, urlEncode: false);

result.Should().Be("driveway",
"blank/whitespace must fall through to Camera, otherwise BI gets " +
"camera= and silently no-ops just like the unconfigured case");
}

[TestMethod]
public void Resolve_CameraShortnameSet_UsesOverride()
{
var tmpl = EventTokenTemplate.Parse("{camera_shortname}", "Caller=Test");
var ctx = NewCtx(camera: "driveway", cameraShortName: "DriveWayHD");

var result = tmpl.Resolve(ctx, urlEncode: false);

result.Should().Be("DriveWayHD",
"the override is the whole point of #32 — Blue Iris's HTTP API silently " +
"no-ops on unknown camera names, so the URL must carry the BI shortname");
}

[TestMethod]
public void Resolve_CameraTokenAndShortnameToken_AreIndependent()
{
// {camera} continues to render Frigate's id even when CameraShortName is set —
// important so operators who use {camera} in Pushover message templates don't
// suddenly get BI shortname text in their phone notifications.
var tmpl = EventTokenTemplate.Parse(
"frigate={camera} bi={camera_shortname}", "Caller=Test");
var ctx = NewCtx(camera: "driveway", cameraShortName: "DriveWayHD");

var result = tmpl.Resolve(ctx, urlEncode: false);

result.Should().Be("frigate=driveway bi=DriveWayHD");
}

[TestMethod]
public void AllowedTokens_IncludesCameraShortname()
{
// Pins the public surface so a future refactor that drops {camera_shortname}
// from AllowedTokens fails immediately, not silently.
EventTokenTemplate.AllowedTokens.Should().Contain("camera_shortname");
}

[TestMethod]
public void Parse_UnknownToken_ErrorMessageListsCameraShortname()
{
// Covers the modified allowed-placeholders text in the Parse error path.
// Operators triaging a "{nope} unknown" config error see {camera_shortname} in
// the suggestions list, which is itself the affordance that surfaces the new field.
var act = () => EventTokenTemplate.Parse("https://x/{nope}", "Caller=Test");
act.Should().Throw<ArgumentException>()
.Which.Message.Should().Contain("{camera_shortname}");
}

[TestMethod]
public void Resolve_CameraShortnameUrlEncoded()
{
var tmpl = EventTokenTemplate.Parse("{camera_shortname}", "Caller=Test");
var ctx = NewCtx(camera: "driveway", cameraShortName: "Drive Way HD");

var result = tmpl.Resolve(ctx);

result.Should().Be("Drive%20Way%20HD",
"the override flows through the same Uri.EscapeDataString path as {camera}");
}
}
Loading
Loading