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,
+}