diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 1dcf9e5..5019d97 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -142,7 +142,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. - Look up frame by MAC → 404 if unknown or file absent. - Return `FileStreamResult` with `Content-Type: image/png`, `Cache-Control: no-store`, `ETag` derived from file mtime. -### [ ] 5.3 Manual end-to-end smoke +### [x] 5.3 Manual end-to-end smoke - Start service, `curl -F image=@photo.jpg .../api/frames/living-room/image` → 200. - `curl .../i/aabbccddeeff.png > out.png` → image opens and looks dithered + remapped. - **DoD:** above two commands work. @@ -157,7 +157,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. - Exposes `bool IsConnected` for `/health`. - On `StartAsync`: connect; on failure, log and continue (background reconnect handles it). -### [ ] 6.2 `PublishAsync(MacAddress, CancellationToken) → Result` +### [x] 6.2 `PublishAsync(MacAddress, CancellationToken) → Result` - Topic: `{BaseTopic}/{mac}`, payload UTF-8 `"update"`, QoS 1, retained false. - Returns success/failure (no throw). @@ -195,7 +195,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. ## Phase 8 — Auth + concurrency + robustness ### [ ] 8.1 `ApiKeyMiddleware` -- Matches request path `/api/*`; reads `X-Api-Key` header; constant-time compare against `ApiKeyOptions`. +- Matches request path `/api/*`; reads `X-Api-Key` header; constant-time compare against `ApiKeyOptions` only if `ApiKeyOptions`is set to non-empty. - 401 on mismatch. `/i/{mac}.png` and `/health` unaffected. ### [ ] 8.2 `FrameLockProvider` diff --git a/src/FrameProcessor/Mqtt/MqttPublisher.cs b/src/FrameProcessor/Mqtt/MqttPublisher.cs index 7940d20..95e5f63 100644 --- a/src/FrameProcessor/Mqtt/MqttPublisher.cs +++ b/src/FrameProcessor/Mqtt/MqttPublisher.cs @@ -1,7 +1,9 @@ using FrameProcessor.Configuration; +using FrameProcessor.Domain; using Microsoft.Extensions.Options; using MQTTnet; using MQTTnet.Client; +using MQTTnet.Protocol; namespace FrameProcessor.Mqtt; @@ -86,6 +88,43 @@ public sealed class MqttPublisher : BackgroundService } } + /// + /// Publish an update notification for the given frame. Returns + /// on any error (disconnected, broker reject, + /// transport fault) — per SPEC.md §3.5, publish failure must not fail the upload, + /// so this method never throws on MQTT errors. + /// still propagates so callers can honor cooperative cancellation. + /// + public async Task PublishAsync(MacAddress mac, CancellationToken cancellationToken) + { + var opts = _options.Value; + var topic = $"{opts.BaseTopic}/{mac}"; + var message = new MqttApplicationMessageBuilder() + .WithTopic(topic) + .WithPayload("update") + .WithQualityOfServiceLevel((MqttQualityOfServiceLevel)opts.PublishQos) + .WithRetainFlag(false) + .Build(); + + try + { + var result = await _client.PublishAsync(message, cancellationToken).ConfigureAwait(false); + if (result.IsSuccess) + { + _logger.LogInformation("MQTT publish to {Topic} succeeded", topic); + return PublishResult.Success; + } + + _logger.LogWarning("MQTT publish to {Topic} returned {ReasonCode}", topic, result.ReasonCode); + return PublishResult.Failure; + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "MQTT publish to {Topic} failed", topic); + return PublishResult.Failure; + } + } + public override async Task StopAsync(CancellationToken cancellationToken) { await base.StopAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/FrameProcessor/Mqtt/PublishResult.cs b/src/FrameProcessor/Mqtt/PublishResult.cs new file mode 100644 index 0000000..72923c7 --- /dev/null +++ b/src/FrameProcessor/Mqtt/PublishResult.cs @@ -0,0 +1,12 @@ +namespace FrameProcessor.Mqtt; + +/// +/// Outcome of an call. covers +/// any non-success path — broker unreachable, not yet connected, broker rejected, transport +/// fault — and is non-fatal to the calling request per SPEC.md §3.5. +/// +public enum PublishResult +{ + Success, + Failure, +}