Files
Extrudex/backend/API/Hubs/PrinterHub.cs
cubecraft-agents[bot] 230c3b295d initial commit
2026-04-25 18:51:05 +00:00

137 lines
6.1 KiB
C#

using Microsoft.AspNetCore.SignalR;
namespace Extrudex.API.Hubs;
/// <summary>
/// SignalR hub for real-time printer status updates.
///
/// Clients connect to this hub to receive push notifications when
/// a printer's status changes (e.g., Idle → Printing, Offline → Idle)
/// or when a heartbeat confirms the printer is still reachable.
///
/// <para>Usage flow:</para>
/// <list type="number">
/// <item>Client connects to /hubs/printer</item>
/// <item>Client calls <see cref="JoinPrinterGroup"/> with a printer ID</item>
/// <item>Server adds the connection to a SignalR group named after the printer ID</item>
/// <item>When the backend detects a status change, it calls
/// <see cref="PrinterHubExtensions.PushPrinterStatusAsync"/>
/// which broadcasts to all subscribers of that printer</item>
/// </list>
///
/// <para>Group naming: <c>printer:{printerId}</c> (lowercase GUID).</para>
///
/// <para>Typed client: <see cref="IPrinterClient"/> — all server-to-client
/// calls go through this interface for compile-time safety.</para>
/// </summary>
public class PrinterHub : Hub<IPrinterClient>
{
/// <summary>
/// Adds the calling connection to the SignalR group for a specific printer.
/// Once joined, the client will receive all status updates and heartbeats
/// for that printer until it disconnects or calls <see cref="LeavePrinterGroup"/>.
/// </summary>
/// <param name="printerId">
/// The unique identifier of the printer to subscribe to.
/// The GUID is normalized to lowercase for consistent group naming.
/// </param>
/// <exception cref="HubException">
/// Thrown if <paramref name="printerId"/> cannot be parsed as a valid GUID.
/// </exception>
public async Task JoinPrinterGroup(Guid printerId)
{
var groupName = PrinterGroupName(printerId);
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
}
/// <summary>
/// Removes the calling connection from the SignalR group for a specific printer.
/// After leaving, the client will no longer receive updates for that printer.
/// </summary>
/// <param name="printerId">
/// The unique identifier of the printer to unsubscribe from.
/// </param>
public async Task LeavePrinterGroup(Guid printerId)
{
var groupName = PrinterGroupName(printerId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
}
/// <summary>
/// Overrides <see cref="Hub.OnDisconnectedAsync"/> to perform cleanup.
/// SignalR automatically removes disconnected connections from all groups,
/// so no manual cleanup is required here.
/// </summary>
/// <param name="exception">Exception that caused the disconnection, if any.</param>
public override Task OnDisconnectedAsync(Exception? exception)
{
// SignalR automatically removes the connection from all groups on disconnect.
// No manual cleanup needed.
return base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Returns the SignalR group name for a given printer ID.
/// Format: <c>printer:{printerId}</c> (lowercase to avoid case-sensitivity issues).
/// </summary>
/// <param name="printerId">The unique identifier of the printer.</param>
/// <returns>A consistent, lowercase group name string.</returns>
internal static string PrinterGroupName(Guid printerId) =>
$"printer:{printerId.ToString().ToLowerInvariant()}";
}
/// <summary>
/// Extension methods for pushing real-time printer updates through
/// the <see cref="IHubContext{T}"/> of <see cref="PrinterHub"/>.
///
/// These methods are intended to be called from background services
/// (e.g., MQTT message handlers, Moonraker pollers) or other
/// server-side code that detects a printer state change.
/// </summary>
public static class PrinterHubExtensions
{
/// <summary>
/// Pushes a printer status change to all clients subscribed to
/// the given printer's SignalR group.
///
/// Call this from any background service or controller when a printer's
/// operational status changes (e.g., a Bambu Lab MQTT message reports
/// the printer started printing, or a Moonraker poller detects an error).
/// </summary>
/// <param name="hubContext">The hub context injected via DI.</param>
/// <param name="printerId">The unique identifier of the printer that changed.</param>
/// <param name="status">The new status string (e.g., "Idle", "Printing", "Offline").</param>
/// <param name="lastSeenAt">Timestamp (UTC) of when the status was observed, or null if unknown.</param>
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
public static async Task PushPrinterStatusAsync(
this IHubContext<PrinterHub, IPrinterClient> hubContext,
Guid printerId,
string status,
DateTime? lastSeenAt = null)
{
var groupName = PrinterHub.PrinterGroupName(printerId);
await hubContext.Clients.Group(groupName)
.PrinterStatusChanged(printerId, status, lastSeenAt);
}
/// <summary>
/// Pushes a heartbeat signal to all clients subscribed to the given
/// printer's SignalR group. Use this for lightweight "still alive"
/// notifications that don't require a full status payload.
/// </summary>
/// <param name="hubContext">The hub context injected via DI.</param>
/// <param name="printerId">The unique identifier of the printer.</param>
/// <param name="isActive">Whether the printer is currently active and accepting jobs.</param>
/// <param name="lastSeenAt">Timestamp (UTC) of the last telemetry from the printer, or null.</param>
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
public static async Task PushPrinterHeartbeatAsync(
this IHubContext<PrinterHub, IPrinterClient> hubContext,
Guid printerId,
bool isActive,
DateTime? lastSeenAt = null)
{
var groupName = PrinterHub.PrinterGroupName(printerId);
await hubContext.Clients.Group(groupName)
.PrinterHeartbeat(printerId, isActive, lastSeenAt);
}
}