From 45683272c338844f3a9836a0ff9cdefa3092359f Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 15 Aug 2025 22:11:52 +0200 Subject: [PATCH 01/29] This is a WIP --- API/Realtime/RedisSubscriberService.cs | 1 - Common/Common.csproj | 1 + Common/Redis/PubSub/DeviceMessage.cs | 123 ++++++++++++++++++ .../Services/RedisPubSub/IRedisPubService.cs | 27 ++-- Common/Services/RedisPubSub/RedisChannels.cs | 11 +- .../Services/RedisPubSub/RedisPubService.cs | 92 +++++-------- .../LifetimeManager/HubLifetime.cs | 4 +- .../PubSub/RedisSubscriberService.cs | 37 +++--- 8 files changed, 186 insertions(+), 110 deletions(-) create mode 100644 Common/Redis/PubSub/DeviceMessage.cs diff --git a/API/Realtime/RedisSubscriberService.cs b/API/Realtime/RedisSubscriberService.cs index f150a68d..40918540 100644 --- a/API/Realtime/RedisSubscriberService.cs +++ b/API/Realtime/RedisSubscriberService.cs @@ -9,7 +9,6 @@ using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; using Redis.OM.Contracts; -using Redis.OM.Searching; using StackExchange.Redis; namespace OpenShock.API.Realtime; diff --git a/Common/Common.csproj b/Common/Common.csproj index fd84edfb..860edbd3 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -12,6 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Common/Redis/PubSub/DeviceMessage.cs b/Common/Redis/PubSub/DeviceMessage.cs new file mode 100644 index 00000000..3c1481ca --- /dev/null +++ b/Common/Redis/PubSub/DeviceMessage.cs @@ -0,0 +1,123 @@ +using MessagePack; +using MessagePack.Formatters; +using OpenShock.Common.Models; +using Semver; + +namespace OpenShock.Common.Redis.PubSub; + +[MessagePackObject] +public sealed class DeviceMessage +{ + [Key(0)] public Guid DeviceId { get; init; } + + [Key(1)] public DeviceEventType Type { get; init; } + + [Key(2)] public required IEventPayload Payload { get; init; } + + public static DeviceMessage Create(Guid deviceId, TriggerKind kind) => new() + { + DeviceId = deviceId, + Type = DeviceEventType.Trigger, + Payload = new TriggerPayload { Kind = kind } + }; + + public static DeviceMessage Create(Guid deviceId, ToggleTarget target, bool state) => new() + { + DeviceId = deviceId, + Type = DeviceEventType.Toggle, + Payload = new TogglePayload { Target = target, State = state } + }; + + public static DeviceMessage Create(Guid deviceId, ControlPayload payload) => new() + { + DeviceId = deviceId, + Type = DeviceEventType.Control, + Payload = payload + }; + + public static DeviceMessage Create(Guid deviceId, DeviceOtaInstallPayload payload) => new() + { + DeviceId = deviceId, + Type = DeviceEventType.OtaInstall, + Payload = payload + }; +} + +public enum DeviceEventType : byte +{ + Trigger = 0, + Toggle = 1, + Control = 2, + OtaInstall = 3 +} + +[Union(0, typeof(TriggerPayload))] +[Union(1, typeof(TogglePayload))] +[Union(2, typeof(ControlPayload))] +[Union(3, typeof(DeviceOtaInstallPayload))] +public interface IEventPayload; + +[MessagePackObject] +public sealed class ControlPayload : IEventPayload +{ + [Key(0)] public Guid Sender { get; init; } + + [Key(1)] public required ShockerControlInfo[] Controls { get; init; } + + [MessagePackObject] + public sealed class ShockerControlInfo + { + [Key(0)] public ushort RfId { get; init; } + [Key(1)] public byte Intensity { get; init; } + [Key(2)] public ushort Duration { get; init; } + [Key(3)] public ControlType Type { get; init; } + [Key(4)] public ShockerModelType Model { get; init; } + [Key(5)] public bool Exclusive { get; init; } + } +} + +[MessagePackObject] +public sealed class DeviceOtaInstallPayload : IEventPayload +{ + [Key(0)] + [MessagePackFormatter(typeof(SemVersionMessagePackFormatter))] + public required SemVersion Version { get; init; } +} + +public enum ToggleTarget : byte +{ + DeviceOnline = 0, + CaptivePortal = 1, +} + +[MessagePackObject] +public sealed class TogglePayload : IEventPayload +{ + [Key(0)] public ToggleTarget Target { get; init; } + [Key(1)] public bool State { get; init; } +} + +public enum TriggerKind : byte +{ + DeviceInfoUpdated = 0, + DeviceEmergencyStop = 1, + DeviceReboot = 2 +} + +[MessagePackObject] +public sealed class TriggerPayload : IEventPayload +{ + [Key(0)] public TriggerKind Kind { get; init; } +} + +public sealed class SemVersionMessagePackFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, SemVersion? value, MessagePackSerializerOptions options) + => writer.Write(value?.ToString()); + + public SemVersion? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var str = reader.ReadString(); + return str is null ? null : SemVersion.Parse(str); + } +} \ No newline at end of file diff --git a/Common/Services/RedisPubSub/IRedisPubService.cs b/Common/Services/RedisPubSub/IRedisPubService.cs index 15b098f1..6cb046e8 100644 --- a/Common/Services/RedisPubSub/IRedisPubService.cs +++ b/Common/Services/RedisPubSub/IRedisPubService.cs @@ -5,22 +5,30 @@ namespace OpenShock.Common.Services.RedisPubSub; public interface IRedisPubService { + /// + /// Something about the device or its shockers updated + /// + /// + /// + Task SendDeviceUpdate(Guid deviceId); + /// /// Used when a device comes online or changes its connection details like, gateway, firmware version, etc. /// /// + /// /// - public Task SendDeviceOnlineStatus(Guid deviceId); + public Task SendDeviceOnlineStatus(Guid deviceId, bool isOnline); /// /// General shocker control /// - /// - /// + /// + /// + /// /// - Task SendDeviceControl(Guid sender, - IDictionary> controlMessages); - + Task SendDeviceControl(Guid deviceId, Guid senderId, ControlPayload.ShockerControlInfo[] controls); + /// /// Toggle captive portal /// @@ -28,13 +36,6 @@ Task SendDeviceControl(Guid sender, /// /// Task SendDeviceCaptivePortal(Guid deviceId, bool enabled); - - /// - /// Something about the device or its shockers updated - /// - /// - /// - Task SendDeviceUpdate(Guid deviceId); /// /// Trigger the emergency stop on the device if it's supported diff --git a/Common/Services/RedisPubSub/RedisChannels.cs b/Common/Services/RedisPubSub/RedisChannels.cs index f30470ba..258f5b0d 100644 --- a/Common/Services/RedisPubSub/RedisChannels.cs +++ b/Common/Services/RedisPubSub/RedisChannels.cs @@ -6,14 +6,5 @@ public static class RedisChannels { public static readonly RedisChannel KeyEventExpired = new("__keyevent@0__:expired", RedisChannel.PatternMode.Literal); - public static readonly RedisChannel DeviceControl = new("msg-device-control", RedisChannel.PatternMode.Literal); - public static readonly RedisChannel DeviceCaptive = new("msg-device-control-captive", RedisChannel.PatternMode.Literal); - public static readonly RedisChannel DeviceUpdate = new("msg-device-update", RedisChannel.PatternMode.Literal); - public static readonly RedisChannel DeviceOnlineStatus = new("msg-device-online-status", RedisChannel.PatternMode.Literal); - - // OTA - public static readonly RedisChannel DeviceOtaInstall = new("msg-device-ota-install", RedisChannel.PatternMode.Literal); - - public static readonly RedisChannel DeviceEmergencyStop = new("msg-device-emergency-stop", RedisChannel.PatternMode.Literal); - public static readonly RedisChannel DeviceReboot = new("msg-device-reboot", RedisChannel.PatternMode.Literal); + public static readonly RedisChannel DeviceMessage = new("msg-device", RedisChannel.PatternMode.Literal); } \ No newline at end of file diff --git a/Common/Services/RedisPubSub/RedisPubService.cs b/Common/Services/RedisPubSub/RedisPubService.cs index a487d3f0..e8dc8c2e 100644 --- a/Common/Services/RedisPubSub/RedisPubService.cs +++ b/Common/Services/RedisPubSub/RedisPubService.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using MessagePack; using OpenShock.Common.Redis.PubSub; using Semver; using StackExchange.Redis; @@ -8,92 +8,58 @@ namespace OpenShock.Common.Services.RedisPubSub; public sealed class RedisPubService : IRedisPubService { private readonly ISubscriber _subscriber; - - /// - /// DI Constructor - /// - /// + public RedisPubService(IConnectionMultiplexer connectionMultiplexer) { _subscriber = connectionMultiplexer.GetSubscriber(); } - - public Task SendDeviceOnlineStatus(Guid deviceId) - { - var redisMessage = new DeviceUpdatedMessage - { - Id = deviceId - }; - return _subscriber.PublishAsync(RedisChannels.DeviceOnlineStatus, JsonSerializer.Serialize(redisMessage)); - } - - /// - public Task SendDeviceControl(Guid sender, IDictionary> controlMessages) + public Task SendDeviceUpdate(Guid deviceId) { - var redisMessage = new ControlMessage - { - Sender = sender, - ControlMessages = controlMessages - }; - - return _subscriber.PublishAsync(RedisChannels.DeviceControl, JsonSerializer.Serialize(redisMessage)); + var msg = DeviceMessage.Create(deviceId, TriggerKind.DeviceInfoUpdated); + var bytes = MessagePackSerializer.Serialize(msg); + return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } - /// - public Task SendDeviceCaptivePortal(Guid deviceId, bool enabled) + public Task SendDeviceOnlineStatus(Guid deviceId, bool isOnline) { - var redisMessage = new CaptiveMessage - { - DeviceId = deviceId, - Enabled = enabled - }; - - return _subscriber.PublishAsync(RedisChannels.DeviceCaptive, JsonSerializer.Serialize(redisMessage)); + var msg = DeviceMessage.Create(deviceId, ToggleTarget.DeviceOnline, isOnline); + var bytes = MessagePackSerializer.Serialize(msg); + return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } - /// - public Task SendDeviceUpdate(Guid deviceId) + public Task SendDeviceControl(Guid deviceId, Guid senderId, ControlPayload.ShockerControlInfo[] controls) { - var redisMessage = new DeviceUpdatedMessage - { - Id = deviceId - }; + var msg = DeviceMessage.Create(deviceId, new ControlPayload { Sender = senderId, Controls = controls }); + var bytes = MessagePackSerializer.Serialize(msg); + return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); + } - return _subscriber.PublishAsync(RedisChannels.DeviceUpdate, JsonSerializer.Serialize(redisMessage)); + public Task SendDeviceCaptivePortal(Guid deviceId, bool enabled) + { + var msg = DeviceMessage.Create(deviceId, ToggleTarget.CaptivePortal, enabled); + var bytes = MessagePackSerializer.Serialize(msg); + return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } - /// public Task SendDeviceEmergencyStop(Guid deviceId) { - var redisMessage = new DeviceEmergencyStopMessage - { - Id = deviceId - }; - - return _subscriber.PublishAsync(RedisChannels.DeviceEmergencyStop, JsonSerializer.Serialize(redisMessage)); + var msg = DeviceMessage.Create(deviceId, TriggerKind.DeviceEmergencyStop); + var bytes = MessagePackSerializer.Serialize(msg); + return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } - /// public Task SendDeviceOtaInstall(Guid deviceId, SemVersion version) { - var redisMessage = new DeviceOtaInstallMessage - { - Id = deviceId, - Version = version - }; - - return _subscriber.PublishAsync(RedisChannels.DeviceOtaInstall, JsonSerializer.Serialize(redisMessage)); + var msg = DeviceMessage.Create(deviceId, new DeviceOtaInstallPayload { Version = version }); + var bytes = MessagePackSerializer.Serialize(msg); + return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } - /// public Task SendDeviceReboot(Guid deviceId) { - var redisMessage = new DeviceRebootMessage - { - Id = deviceId - }; - - return _subscriber.PublishAsync(RedisChannels.DeviceReboot, JsonSerializer.Serialize(redisMessage)); + var msg = DeviceMessage.Create(deviceId, TriggerKind.DeviceReboot); + var bytes = MessagePackSerializer.Serialize(msg); + return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } } \ No newline at end of file diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 377a333f..27e1eae7 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -395,7 +395,7 @@ await deviceOnline.InsertAsync(new DeviceOnline }, Duration.DeviceKeepAliveTimeout); - await _redisPubService.SendDeviceOnlineStatus(device); + await _redisPubService.SendDeviceOnlineStatus(device, true); return new Success(); } @@ -424,7 +424,7 @@ await deviceOnline.InsertAsync(new DeviceOnline if (sendOnlineStatusUpdate) { - await _redisPubService.SendDeviceOnlineStatus(device); + await _redisPubService.SendDeviceOnlineStatus(device, true); return new OnlineStateUpdated(); } diff --git a/LiveControlGateway/PubSub/RedisSubscriberService.cs b/LiveControlGateway/PubSub/RedisSubscriberService.cs index 1fb25ced..b926a073 100644 --- a/LiveControlGateway/PubSub/RedisSubscriberService.cs +++ b/LiveControlGateway/PubSub/RedisSubscriberService.cs @@ -30,21 +30,16 @@ public RedisSubscriberService( /// public async Task StartAsync(CancellationToken cancellationToken) { - await _subscriber.SubscribeAsync(RedisChannels.DeviceControl, - (_, message) => OsTask.Run(() => DeviceControl(message))); - await _subscriber.SubscribeAsync(RedisChannels.DeviceCaptive, - (_, message) => OsTask.Run(() => DeviceControlCaptive(message))); - await _subscriber.SubscribeAsync(RedisChannels.DeviceUpdate, - (_, message) => OsTask.Run(() => DeviceUpdate(message))); - - // OTA - await _subscriber.SubscribeAsync(RedisChannels.DeviceOtaInstall, - (_, message) => OsTask.Run(() => DeviceOtaInstall(message))); - - await _subscriber.SubscribeAsync(RedisChannels.DeviceEmergencyStop, - (_, message) => OsTask.Run(() => DeviceEmergencyStop(message))); - await _subscriber.SubscribeAsync(RedisChannels.DeviceReboot, - (_, message) => OsTask.Run(() => DeviceReboot(message))); + await _subscriber.SubscribeAsync(RedisChannels.DeviceMessage, (_, val) => OsTask.Run(() => DeviceMessage(val))); + } + + private async Task DeviceMessage(RedisValue value) + { + if (!value.HasValue) return; + var data = JsonSerializer.Deserialize(value.ToString()); + if (data is null) return; + + await _hubLifetimeManager.ControlCaptive(data.DeviceId, data.Enabled); } private async Task DeviceControl(RedisValue value) @@ -75,10 +70,10 @@ private async Task DeviceUpdate(RedisValue value) if (!value.HasValue) return; var data = JsonSerializer.Deserialize(value.ToString()); if (data is null) return; - + await _hubLifetimeManager.UpdateDevice(data.Id); } - + /// /// Trigger the device's emergency stop the device if found and it supports it /// @@ -89,7 +84,7 @@ private async Task DeviceEmergencyStop(RedisValue value) if (!value.HasValue) return; var data = JsonSerializer.Deserialize(value.ToString()); if (data is null) return; - + await _hubLifetimeManager.EmergencyStop(data.Id); } @@ -103,10 +98,10 @@ private async Task DeviceOtaInstall(RedisValue value) if (!value.HasValue) return; var data = JsonSerializer.Deserialize(value.ToString()); if (data is null) return; - + await _hubLifetimeManager.OtaInstall(data.Id, data.Version); } - + /// /// Reboot the device if found and it supports it /// @@ -117,7 +112,7 @@ private async Task DeviceReboot(RedisValue value) if (!value.HasValue) return; var data = JsonSerializer.Deserialize(value.ToString()); if (data is null) return; - + await _hubLifetimeManager.Reboot(data.Id); } From 071833c4e16f0095f894dc885ffcce8e5f840de8 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 15 Aug 2025 22:42:49 +0200 Subject: [PATCH 02/29] This needs more thinking --- Common/Redis/PubSub/DeviceMessage.cs | 24 ++-- .../Services/RedisPubSub/IRedisPubService.cs | 2 +- .../Services/RedisPubSub/RedisPubService.cs | 10 +- .../PubSub/RedisSubscriberService.cs | 128 ++++++++---------- 4 files changed, 75 insertions(+), 89 deletions(-) diff --git a/Common/Redis/PubSub/DeviceMessage.cs b/Common/Redis/PubSub/DeviceMessage.cs index 3c1481ca..cfaee40f 100644 --- a/Common/Redis/PubSub/DeviceMessage.cs +++ b/Common/Redis/PubSub/DeviceMessage.cs @@ -14,21 +14,21 @@ public sealed class DeviceMessage [Key(2)] public required IEventPayload Payload { get; init; } - public static DeviceMessage Create(Guid deviceId, TriggerKind kind) => new() + public static DeviceMessage Create(Guid deviceId, DeviceTriggerType type) => new() { DeviceId = deviceId, Type = DeviceEventType.Trigger, - Payload = new TriggerPayload { Kind = kind } + Payload = new DeviceTriggerPayload { Type = type } }; public static DeviceMessage Create(Guid deviceId, ToggleTarget target, bool state) => new() { DeviceId = deviceId, Type = DeviceEventType.Toggle, - Payload = new TogglePayload { Target = target, State = state } + Payload = new DeviceTogglePayload { Target = target, State = state } }; - public static DeviceMessage Create(Guid deviceId, ControlPayload payload) => new() + public static DeviceMessage Create(Guid deviceId, DeviceControlPayload payload) => new() { DeviceId = deviceId, Type = DeviceEventType.Control, @@ -51,14 +51,14 @@ public enum DeviceEventType : byte OtaInstall = 3 } -[Union(0, typeof(TriggerPayload))] -[Union(1, typeof(TogglePayload))] -[Union(2, typeof(ControlPayload))] +[Union(0, typeof(DeviceTriggerPayload))] +[Union(1, typeof(DeviceTogglePayload))] +[Union(2, typeof(DeviceControlPayload))] [Union(3, typeof(DeviceOtaInstallPayload))] public interface IEventPayload; [MessagePackObject] -public sealed class ControlPayload : IEventPayload +public sealed class DeviceControlPayload : IEventPayload { [Key(0)] public Guid Sender { get; init; } @@ -91,13 +91,13 @@ public enum ToggleTarget : byte } [MessagePackObject] -public sealed class TogglePayload : IEventPayload +public sealed class DeviceTogglePayload : IEventPayload { [Key(0)] public ToggleTarget Target { get; init; } [Key(1)] public bool State { get; init; } } -public enum TriggerKind : byte +public enum DeviceTriggerType : byte { DeviceInfoUpdated = 0, DeviceEmergencyStop = 1, @@ -105,9 +105,9 @@ public enum TriggerKind : byte } [MessagePackObject] -public sealed class TriggerPayload : IEventPayload +public sealed class DeviceTriggerPayload : IEventPayload { - [Key(0)] public TriggerKind Kind { get; init; } + [Key(0)] public DeviceTriggerType Type { get; init; } } public sealed class SemVersionMessagePackFormatter : IMessagePackFormatter diff --git a/Common/Services/RedisPubSub/IRedisPubService.cs b/Common/Services/RedisPubSub/IRedisPubService.cs index 6cb046e8..d7c2db86 100644 --- a/Common/Services/RedisPubSub/IRedisPubService.cs +++ b/Common/Services/RedisPubSub/IRedisPubService.cs @@ -27,7 +27,7 @@ public interface IRedisPubService /// /// /// - Task SendDeviceControl(Guid deviceId, Guid senderId, ControlPayload.ShockerControlInfo[] controls); + Task SendDeviceControl(Guid deviceId, Guid senderId, DeviceControlPayload.ShockerControlInfo[] controls); /// /// Toggle captive portal diff --git a/Common/Services/RedisPubSub/RedisPubService.cs b/Common/Services/RedisPubSub/RedisPubService.cs index e8dc8c2e..774e87fb 100644 --- a/Common/Services/RedisPubSub/RedisPubService.cs +++ b/Common/Services/RedisPubSub/RedisPubService.cs @@ -16,7 +16,7 @@ public RedisPubService(IConnectionMultiplexer connectionMultiplexer) public Task SendDeviceUpdate(Guid deviceId) { - var msg = DeviceMessage.Create(deviceId, TriggerKind.DeviceInfoUpdated); + var msg = DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceInfoUpdated); var bytes = MessagePackSerializer.Serialize(msg); return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } @@ -28,9 +28,9 @@ public Task SendDeviceOnlineStatus(Guid deviceId, bool isOnline) return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } - public Task SendDeviceControl(Guid deviceId, Guid senderId, ControlPayload.ShockerControlInfo[] controls) + public Task SendDeviceControl(Guid deviceId, Guid senderId, DeviceControlPayload.ShockerControlInfo[] controls) { - var msg = DeviceMessage.Create(deviceId, new ControlPayload { Sender = senderId, Controls = controls }); + var msg = DeviceMessage.Create(deviceId, new DeviceControlPayload { Sender = senderId, Controls = controls }); var bytes = MessagePackSerializer.Serialize(msg); return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } @@ -44,7 +44,7 @@ public Task SendDeviceCaptivePortal(Guid deviceId, bool enabled) public Task SendDeviceEmergencyStop(Guid deviceId) { - var msg = DeviceMessage.Create(deviceId, TriggerKind.DeviceEmergencyStop); + var msg = DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceEmergencyStop); var bytes = MessagePackSerializer.Serialize(msg); return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } @@ -58,7 +58,7 @@ public Task SendDeviceOtaInstall(Guid deviceId, SemVersion version) public Task SendDeviceReboot(Guid deviceId) { - var msg = DeviceMessage.Create(deviceId, TriggerKind.DeviceReboot); + var msg = DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceReboot); var bytes = MessagePackSerializer.Serialize(msg); return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } diff --git a/LiveControlGateway/PubSub/RedisSubscriberService.cs b/LiveControlGateway/PubSub/RedisSubscriberService.cs index b926a073..9118f3dd 100644 --- a/LiveControlGateway/PubSub/RedisSubscriberService.cs +++ b/LiveControlGateway/PubSub/RedisSubscriberService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using MessagePack; using OpenShock.Common.Redis.PubSub; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; @@ -33,94 +34,79 @@ public async Task StartAsync(CancellationToken cancellationToken) await _subscriber.SubscribeAsync(RedisChannels.DeviceMessage, (_, val) => OsTask.Run(() => DeviceMessage(val))); } - private async Task DeviceMessage(RedisValue value) - { - if (!value.HasValue) return; - var data = JsonSerializer.Deserialize(value.ToString()); - if (data is null) return; - - await _hubLifetimeManager.ControlCaptive(data.DeviceId, data.Enabled); - } - - private async Task DeviceControl(RedisValue value) - { - if (!value.HasValue) return; - var data = JsonSerializer.Deserialize(value.ToString()); - if (data is null) return; - - await Task.WhenAll(data.ControlMessages.Select(x => _hubLifetimeManager.Control(x.Key, x.Value))); - } - - private async Task DeviceControlCaptive(RedisValue value) + /// + public Task StopAsync(CancellationToken cancellationToken) { - if (!value.HasValue) return; - var data = JsonSerializer.Deserialize(value.ToString()); - if (data is null) return; - - await _hubLifetimeManager.ControlCaptive(data.DeviceId, data.Enabled); + return Task.CompletedTask; } - /// - /// Update the device connection if found - /// - /// - /// - private async Task DeviceUpdate(RedisValue value) + private async Task DeviceMessage(RedisValue value) { if (!value.HasValue) return; - var data = JsonSerializer.Deserialize(value.ToString()); - if (data is null) return; - - await _hubLifetimeManager.UpdateDevice(data.Id); + var message = MessagePackSerializer.Deserialize(Convert.FromBase64String(value.ToString())); + switch (message.Type) + { + case DeviceEventType.Trigger: + await DeviceMessageTrigger(message.DeviceId, message.Payload as DeviceTriggerPayload); + break; + case DeviceEventType.Toggle: + await DeviceMessageToggle(message.DeviceId, message.Payload as DeviceTogglePayload); + break; + case DeviceEventType.Control: + await DeviceMessageControl(message.DeviceId, message.Payload as DeviceControlPayload); + break; + case DeviceEventType.OtaInstall: + await DeviceMessageOtaInstall(message.DeviceId, message.Payload as DeviceOtaInstallPayload); + break; + default: + throw new ArgumentOutOfRangeException(); + } } - /// - /// Trigger the device's emergency stop the device if found and it supports it - /// - /// - /// - private async Task DeviceEmergencyStop(RedisValue value) + private async Task DeviceMessageTrigger(Guid deviceId, DeviceTriggerPayload? payload) { - if (!value.HasValue) return; - var data = JsonSerializer.Deserialize(value.ToString()); - if (data is null) return; - - await _hubLifetimeManager.EmergencyStop(data.Id); + if (payload is null) return; + switch (payload.Type) + { + case DeviceTriggerType.DeviceInfoUpdated: + await _hubLifetimeManager.UpdateDevice(deviceId); + break; + case DeviceTriggerType.DeviceEmergencyStop: + await _hubLifetimeManager.EmergencyStop(deviceId); + break; + case DeviceTriggerType.DeviceReboot: + await _hubLifetimeManager.Reboot(deviceId); + break; + default: + throw new ArgumentOutOfRangeException(); + } } - /// - /// Update the device connection if found - /// - /// - /// - private async Task DeviceOtaInstall(RedisValue value) + private async Task DeviceMessageToggle(Guid deviceId, DeviceTogglePayload? payload) { - if (!value.HasValue) return; - var data = JsonSerializer.Deserialize(value.ToString()); - if (data is null) return; - - await _hubLifetimeManager.OtaInstall(data.Id, data.Version); + if (payload is null) return; + switch (payload.Target) + { + case ToggleTarget.DeviceOnline: // Do nothing + break; + case ToggleTarget.CaptivePortal: + await _hubLifetimeManager.ControlCaptive(deviceId, payload.State); + break; + default: + throw new ArgumentOutOfRangeException(); + } } - /// - /// Reboot the device if found and it supports it - /// - /// - /// - private async Task DeviceReboot(RedisValue value) + private async Task DeviceMessageControl(Guid deviceId, DeviceControlPayload? payload) { - if (!value.HasValue) return; - var data = JsonSerializer.Deserialize(value.ToString()); - if (data is null) return; - - await _hubLifetimeManager.Reboot(data.Id); + if (payload is null) return; + await Task.WhenAll(payload.Controls.Select(x => _hubLifetimeManager.Control(deviceId, x))); } - - /// - public Task StopAsync(CancellationToken cancellationToken) + private async Task DeviceMessageOtaInstall(Guid deviceId, DeviceOtaInstallPayload? payload) { - return Task.CompletedTask; + if (payload is null) return; + await _hubLifetimeManager.OtaInstall(deviceId, payload.Version); } /// From 4c78f84f5f7996e3eae8fa6624367f00311d6588 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 15 Aug 2025 23:06:49 +0200 Subject: [PATCH 03/29] Like this? --- Common/Redis/PubSub/CaptiveMessage.cs | 9 -- Common/Redis/PubSub/ControlMessage.cs | 26 ----- .../PubSub/DeviceEmergencyStopMessage.cs | 7 -- Common/Redis/PubSub/DeviceMessage.cs | 94 ++++++++----------- .../Redis/PubSub/DeviceOtaInstallMessage.cs | 12 --- Common/Redis/PubSub/DeviceRebootMessage.cs | 7 -- Common/Redis/PubSub/DeviceStatus.cs | 22 +++++ Common/Redis/PubSub/DeviceUpdatedMessage.cs | 6 -- .../PubSub/SemVersionMessagePackFormatter.cs | 19 ++++ .../Services/RedisPubSub/RedisPubService.cs | 4 +- .../LifetimeManager/HubLifetime.cs | 2 +- .../LifetimeManager/HubLifetimeManager.cs | 2 +- .../PubSub/RedisSubscriberService.cs | 20 ++-- 13 files changed, 94 insertions(+), 136 deletions(-) delete mode 100644 Common/Redis/PubSub/CaptiveMessage.cs delete mode 100644 Common/Redis/PubSub/ControlMessage.cs delete mode 100644 Common/Redis/PubSub/DeviceEmergencyStopMessage.cs delete mode 100644 Common/Redis/PubSub/DeviceOtaInstallMessage.cs delete mode 100644 Common/Redis/PubSub/DeviceRebootMessage.cs create mode 100644 Common/Redis/PubSub/DeviceStatus.cs delete mode 100644 Common/Redis/PubSub/DeviceUpdatedMessage.cs create mode 100644 Common/Redis/PubSub/SemVersionMessagePackFormatter.cs diff --git a/Common/Redis/PubSub/CaptiveMessage.cs b/Common/Redis/PubSub/CaptiveMessage.cs deleted file mode 100644 index 83a4d0a1..00000000 --- a/Common/Redis/PubSub/CaptiveMessage.cs +++ /dev/null @@ -1,9 +0,0 @@ -// ReSharper disable UnusedAutoPropertyAccessor.Global - -namespace OpenShock.Common.Redis.PubSub; - -public sealed class CaptiveMessage -{ - public required Guid DeviceId { get; set; } - public required bool Enabled { get; set; } -} \ No newline at end of file diff --git a/Common/Redis/PubSub/ControlMessage.cs b/Common/Redis/PubSub/ControlMessage.cs deleted file mode 100644 index 5a7ce145..00000000 --- a/Common/Redis/PubSub/ControlMessage.cs +++ /dev/null @@ -1,26 +0,0 @@ -using OpenShock.Common.Models; - -// ReSharper disable UnusedAutoPropertyAccessor.Global - -namespace OpenShock.Common.Redis.PubSub; - -public sealed class ControlMessage -{ - public required Guid Sender { get; set; } - - /// - /// Guid is the device id - /// - public required IDictionary> ControlMessages { get; set; } - - public sealed class ShockerControlInfo - { - public required Guid Id { get; set; } - public required ushort RfId { get; set; } - public required byte Intensity { get; set; } - public required ushort Duration { get; set; } - public required ControlType Type { get; set; } - public required ShockerModelType Model { get; set; } - public bool Exclusive { get; set; } = false; - } -} \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceEmergencyStopMessage.cs b/Common/Redis/PubSub/DeviceEmergencyStopMessage.cs deleted file mode 100644 index 91eb0f6b..00000000 --- a/Common/Redis/PubSub/DeviceEmergencyStopMessage.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Text.Json.Serialization; -namespace OpenShock.Common.Redis.PubSub; - -public sealed class DeviceEmergencyStopMessage -{ - public required Guid Id { get; set; } -} \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceMessage.cs b/Common/Redis/PubSub/DeviceMessage.cs index cfaee40f..0b7225a2 100644 --- a/Common/Redis/PubSub/DeviceMessage.cs +++ b/Common/Redis/PubSub/DeviceMessage.cs @@ -1,5 +1,4 @@ using MessagePack; -using MessagePack.Formatters; using OpenShock.Common.Models; using Semver; @@ -10,40 +9,40 @@ public sealed class DeviceMessage { [Key(0)] public Guid DeviceId { get; init; } - [Key(1)] public DeviceEventType Type { get; init; } + [Key(1)] public DeviceMessageType Type { get; init; } - [Key(2)] public required IEventPayload Payload { get; init; } + [Key(2)] public required IDeviceMessagePayload MessagePayload { get; init; } public static DeviceMessage Create(Guid deviceId, DeviceTriggerType type) => new() { DeviceId = deviceId, - Type = DeviceEventType.Trigger, - Payload = new DeviceTriggerPayload { Type = type } + Type = DeviceMessageType.Trigger, + MessagePayload = new DeviceTriggerPayload { Type = type } }; - public static DeviceMessage Create(Guid deviceId, ToggleTarget target, bool state) => new() + public static DeviceMessage Create(Guid deviceId, DeviceToggleTarget target, bool state) => new() { DeviceId = deviceId, - Type = DeviceEventType.Toggle, - Payload = new DeviceTogglePayload { Target = target, State = state } + Type = DeviceMessageType.Toggle, + MessagePayload = new DeviceTogglePayload { Target = target, State = state } }; public static DeviceMessage Create(Guid deviceId, DeviceControlPayload payload) => new() { DeviceId = deviceId, - Type = DeviceEventType.Control, - Payload = payload + Type = DeviceMessageType.Control, + MessagePayload = payload }; public static DeviceMessage Create(Guid deviceId, DeviceOtaInstallPayload payload) => new() { DeviceId = deviceId, - Type = DeviceEventType.OtaInstall, - Payload = payload + Type = DeviceMessageType.OtaInstall, + MessagePayload = payload }; } -public enum DeviceEventType : byte +public enum DeviceMessageType : byte { Trigger = 0, Toggle = 1, @@ -55,10 +54,35 @@ public enum DeviceEventType : byte [Union(1, typeof(DeviceTogglePayload))] [Union(2, typeof(DeviceControlPayload))] [Union(3, typeof(DeviceOtaInstallPayload))] -public interface IEventPayload; +public interface IDeviceMessagePayload; + +public enum DeviceTriggerType : byte +{ + DeviceInfoUpdated = 0, + DeviceEmergencyStop = 1, + DeviceReboot = 2 +} [MessagePackObject] -public sealed class DeviceControlPayload : IEventPayload +public sealed class DeviceTriggerPayload : IDeviceMessagePayload +{ + [Key(0)] public DeviceTriggerType Type { get; init; } +} + +public enum DeviceToggleTarget : byte +{ + CaptivePortal = 0, +} + +[MessagePackObject] +public sealed class DeviceTogglePayload : IDeviceMessagePayload +{ + [Key(0)] public DeviceToggleTarget Target { get; init; } + [Key(1)] public bool State { get; init; } +} + +[MessagePackObject] +public sealed class DeviceControlPayload : IDeviceMessagePayload { [Key(0)] public Guid Sender { get; init; } @@ -77,47 +101,9 @@ public sealed class ShockerControlInfo } [MessagePackObject] -public sealed class DeviceOtaInstallPayload : IEventPayload +public sealed class DeviceOtaInstallPayload : IDeviceMessagePayload { [Key(0)] [MessagePackFormatter(typeof(SemVersionMessagePackFormatter))] public required SemVersion Version { get; init; } -} - -public enum ToggleTarget : byte -{ - DeviceOnline = 0, - CaptivePortal = 1, -} - -[MessagePackObject] -public sealed class DeviceTogglePayload : IEventPayload -{ - [Key(0)] public ToggleTarget Target { get; init; } - [Key(1)] public bool State { get; init; } -} - -public enum DeviceTriggerType : byte -{ - DeviceInfoUpdated = 0, - DeviceEmergencyStop = 1, - DeviceReboot = 2 -} - -[MessagePackObject] -public sealed class DeviceTriggerPayload : IEventPayload -{ - [Key(0)] public DeviceTriggerType Type { get; init; } -} - -public sealed class SemVersionMessagePackFormatter : IMessagePackFormatter -{ - public void Serialize(ref MessagePackWriter writer, SemVersion? value, MessagePackSerializerOptions options) - => writer.Write(value?.ToString()); - - public SemVersion? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) - { - var str = reader.ReadString(); - return str is null ? null : SemVersion.Parse(str); - } } \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceOtaInstallMessage.cs b/Common/Redis/PubSub/DeviceOtaInstallMessage.cs deleted file mode 100644 index e1889f85..00000000 --- a/Common/Redis/PubSub/DeviceOtaInstallMessage.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; -using OpenShock.Common.JsonSerialization; -using Semver; - -namespace OpenShock.Common.Redis.PubSub; - -public sealed class DeviceOtaInstallMessage -{ - public required Guid Id { get; set; } - [JsonConverter(typeof(SemVersionJsonConverter))] - public required SemVersion Version { get; set; } -} \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceRebootMessage.cs b/Common/Redis/PubSub/DeviceRebootMessage.cs deleted file mode 100644 index ebfbab84..00000000 --- a/Common/Redis/PubSub/DeviceRebootMessage.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Text.Json.Serialization; -namespace OpenShock.Common.Redis.PubSub; - -public sealed class DeviceRebootMessage -{ - public required Guid Id { get; set; } -} \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceStatus.cs b/Common/Redis/PubSub/DeviceStatus.cs new file mode 100644 index 00000000..e9c281ef --- /dev/null +++ b/Common/Redis/PubSub/DeviceStatus.cs @@ -0,0 +1,22 @@ +using MessagePack; + +namespace OpenShock.Common.Redis.PubSub; + +[MessagePackObject] +public sealed class DeviceStatus +{ + [Key(0)] public Guid DeviceId { get; init; } + + [Key(1)] public DeviceStatusType Type { get; init; } + + public static DeviceStatus Create(Guid deviceId, DeviceStatusType type) => new() + { + DeviceId = deviceId, + Type = DeviceStatusType.Online, + }; +} + +public enum DeviceStatusType : byte +{ + Online = 0, +} \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceUpdatedMessage.cs b/Common/Redis/PubSub/DeviceUpdatedMessage.cs deleted file mode 100644 index c55f23ca..00000000 --- a/Common/Redis/PubSub/DeviceUpdatedMessage.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace OpenShock.Common.Redis.PubSub; - -public sealed class DeviceUpdatedMessage -{ - public required Guid Id { get; set; } -} \ No newline at end of file diff --git a/Common/Redis/PubSub/SemVersionMessagePackFormatter.cs b/Common/Redis/PubSub/SemVersionMessagePackFormatter.cs new file mode 100644 index 00000000..255fd219 --- /dev/null +++ b/Common/Redis/PubSub/SemVersionMessagePackFormatter.cs @@ -0,0 +1,19 @@ +using MessagePack; +using MessagePack.Formatters; +using Semver; + +namespace OpenShock.Common.Redis.PubSub; + +public sealed class SemVersionMessagePackFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, SemVersion? value, MessagePackSerializerOptions options) + { + writer.Write(value?.ToString()); + } + + public SemVersion? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var str = reader.ReadString(); + return str is null ? null : SemVersion.Parse(str); + } +} \ No newline at end of file diff --git a/Common/Services/RedisPubSub/RedisPubService.cs b/Common/Services/RedisPubSub/RedisPubService.cs index 774e87fb..4e7f7694 100644 --- a/Common/Services/RedisPubSub/RedisPubService.cs +++ b/Common/Services/RedisPubSub/RedisPubService.cs @@ -23,7 +23,7 @@ public Task SendDeviceUpdate(Guid deviceId) public Task SendDeviceOnlineStatus(Guid deviceId, bool isOnline) { - var msg = DeviceMessage.Create(deviceId, ToggleTarget.DeviceOnline, isOnline); + var msg = DeviceStatus.Create(deviceId, DeviceStatusType.Online); var bytes = MessagePackSerializer.Serialize(msg); return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } @@ -37,7 +37,7 @@ public Task SendDeviceControl(Guid deviceId, Guid senderId, DeviceControlPayload public Task SendDeviceCaptivePortal(Guid deviceId, bool enabled) { - var msg = DeviceMessage.Create(deviceId, ToggleTarget.CaptivePortal, enabled); + var msg = DeviceMessage.Create(deviceId, DeviceToggleTarget.CaptivePortal, enabled); var bytes = MessagePackSerializer.Serialize(msg); return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); } diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 27e1eae7..e116848f 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -318,7 +318,7 @@ private static DateTimeOffset CalculateActiveUntil(byte tps) => /// /// /// - public ValueTask Control(IReadOnlyList shocks) + public ValueTask Control(IReadOnlyList shocks) { var shocksTransformed = new List(shocks.Count); foreach (var shock in shocks) diff --git a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs index e039c513..9b254e84 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs @@ -212,7 +212,7 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// public async Task> Control(Guid device, - IReadOnlyList shocks) + IReadOnlyList shocks) { if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); await deviceLifetime.Control(shocks); diff --git a/LiveControlGateway/PubSub/RedisSubscriberService.cs b/LiveControlGateway/PubSub/RedisSubscriberService.cs index 9118f3dd..74755b85 100644 --- a/LiveControlGateway/PubSub/RedisSubscriberService.cs +++ b/LiveControlGateway/PubSub/RedisSubscriberService.cs @@ -46,17 +46,17 @@ private async Task DeviceMessage(RedisValue value) var message = MessagePackSerializer.Deserialize(Convert.FromBase64String(value.ToString())); switch (message.Type) { - case DeviceEventType.Trigger: - await DeviceMessageTrigger(message.DeviceId, message.Payload as DeviceTriggerPayload); + case DeviceMessageType.Trigger: + await DeviceMessageTrigger(message.DeviceId, message.MessagePayload as DeviceTriggerPayload); break; - case DeviceEventType.Toggle: - await DeviceMessageToggle(message.DeviceId, message.Payload as DeviceTogglePayload); + case DeviceMessageType.Toggle: + await DeviceMessageToggle(message.DeviceId, message.MessagePayload as DeviceTogglePayload); break; - case DeviceEventType.Control: - await DeviceMessageControl(message.DeviceId, message.Payload as DeviceControlPayload); + case DeviceMessageType.Control: + await DeviceMessageControl(message.DeviceId, message.MessagePayload as DeviceControlPayload); break; - case DeviceEventType.OtaInstall: - await DeviceMessageOtaInstall(message.DeviceId, message.Payload as DeviceOtaInstallPayload); + case DeviceMessageType.OtaInstall: + await DeviceMessageOtaInstall(message.DeviceId, message.MessagePayload as DeviceOtaInstallPayload); break; default: throw new ArgumentOutOfRangeException(); @@ -87,9 +87,7 @@ private async Task DeviceMessageToggle(Guid deviceId, DeviceTogglePayload? paylo if (payload is null) return; switch (payload.Target) { - case ToggleTarget.DeviceOnline: // Do nothing - break; - case ToggleTarget.CaptivePortal: + case DeviceToggleTarget.CaptivePortal: await _hubLifetimeManager.ControlCaptive(deviceId, payload.State); break; default: From 0bc4673fc92c2c41c35b1cbcfd9c5044dbc0c708 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Sat, 16 Aug 2025 00:22:01 +0200 Subject: [PATCH 04/29] Base64 --- .../Services/RedisPubSub/RedisPubService.cs | 31 ++++++------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/Common/Services/RedisPubSub/RedisPubService.cs b/Common/Services/RedisPubSub/RedisPubService.cs index 4e7f7694..54a175e2 100644 --- a/Common/Services/RedisPubSub/RedisPubService.cs +++ b/Common/Services/RedisPubSub/RedisPubService.cs @@ -14,52 +14,41 @@ public RedisPubService(IConnectionMultiplexer connectionMultiplexer) _subscriber = connectionMultiplexer.GetSubscriber(); } + private Task Publish(T msg) => _subscriber.PublishAsync(RedisChannels.DeviceMessage, + Convert.ToBase64String(MessagePackSerializer.Serialize(msg))); + public Task SendDeviceUpdate(Guid deviceId) { - var msg = DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceInfoUpdated); - var bytes = MessagePackSerializer.Serialize(msg); - return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); + return Publish(DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceInfoUpdated)); } public Task SendDeviceOnlineStatus(Guid deviceId, bool isOnline) { - var msg = DeviceStatus.Create(deviceId, DeviceStatusType.Online); - var bytes = MessagePackSerializer.Serialize(msg); - return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); + return Publish(DeviceStatus.Create(deviceId, DeviceStatusType.Online)); } public Task SendDeviceControl(Guid deviceId, Guid senderId, DeviceControlPayload.ShockerControlInfo[] controls) { - var msg = DeviceMessage.Create(deviceId, new DeviceControlPayload { Sender = senderId, Controls = controls }); - var bytes = MessagePackSerializer.Serialize(msg); - return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); + return Publish(DeviceMessage.Create(deviceId, new DeviceControlPayload { Sender = senderId, Controls = controls })); } public Task SendDeviceCaptivePortal(Guid deviceId, bool enabled) { - var msg = DeviceMessage.Create(deviceId, DeviceToggleTarget.CaptivePortal, enabled); - var bytes = MessagePackSerializer.Serialize(msg); - return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); + return Publish(DeviceMessage.Create(deviceId, DeviceToggleTarget.CaptivePortal, enabled)); } public Task SendDeviceEmergencyStop(Guid deviceId) { - var msg = DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceEmergencyStop); - var bytes = MessagePackSerializer.Serialize(msg); - return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); + return Publish(DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceEmergencyStop)); } public Task SendDeviceOtaInstall(Guid deviceId, SemVersion version) { - var msg = DeviceMessage.Create(deviceId, new DeviceOtaInstallPayload { Version = version }); - var bytes = MessagePackSerializer.Serialize(msg); - return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); + return Publish(DeviceMessage.Create(deviceId, new DeviceOtaInstallPayload { Version = version })); } public Task SendDeviceReboot(Guid deviceId) { - var msg = DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceReboot); - var bytes = MessagePackSerializer.Serialize(msg); - return _subscriber.PublishAsync(RedisChannels.DeviceMessage, bytes); + return Publish(DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceReboot)); } } \ No newline at end of file From 497ec406ff2e02be3e8443334d5c99a243e31a34 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Sun, 17 Aug 2025 19:56:16 +0200 Subject: [PATCH 05/29] more progress, doesn't compile --- API/Controller/Shockers/GetShockerLogs.cs | 4 +- API/Controller/Shockers/SendControl.cs | 2 +- API/Realtime/RedisSubscriberService.cs | 4 +- Common/DeviceControl/ControlLogic.cs | 80 ++++++------------- Common/DeviceControl/ControlShockerObj.cs | 14 ++-- Common/Hubs/PublicShareHub.cs | 4 +- Common/Hubs/UserHub.cs | 8 +- Common/Models/ControlLogSender.cs | 2 +- Common/Models/SharePermsAndLimits.cs | 16 ++-- Common/Redis/PubSub/DeviceMessage.cs | 30 +++---- Common/Redis/PubSub/DeviceStatus.cs | 34 ++++++-- .../Services/RedisPubSub/IRedisPubService.cs | 3 +- Common/Services/RedisPubSub/RedisChannels.cs | 4 +- .../Services/RedisPubSub/RedisPubService.cs | 18 ++--- Common/Utils/PermissionUtils.cs | 20 +++++ .../Controllers/HubControllerBase.cs | 2 +- .../Controllers/HubV1Controller.cs | 2 +- .../Controllers/HubV2Controller.cs | 2 +- .../Controllers/IHubController.cs | 2 +- .../Controllers/LiveControlController.cs | 24 ++---- .../LifetimeManager/HubLifetime.cs | 22 +---- .../PubSub/RedisSubscriberService.cs | 8 +- 22 files changed, 133 insertions(+), 172 deletions(-) create mode 100644 Common/Utils/PermissionUtils.cs diff --git a/API/Controller/Shockers/GetShockerLogs.cs b/API/Controller/Shockers/GetShockerLogs.cs index cd656b14..a35e9203 100644 --- a/API/Controller/Shockers/GetShockerLogs.cs +++ b/API/Controller/Shockers/GetShockerLogs.cs @@ -49,14 +49,14 @@ [FromQuery] [Range(1, 500)] uint limit = 100) ControlledBy = x.ControlledByUser == null ? new ControlLogSenderLight { - Id = Guid.Empty, + UserId = Guid.Empty, Name = "Guest", Image = GravatarUtils.GuestImageUrl, CustomName = x.CustomName } : new ControlLogSenderLight { - Id = x.ControlledByUser.Id, + UserId = x.ControlledByUser.Id, Name = x.ControlledByUser.Name, Image = x.ControlledByUser.GetImageUrl(), CustomName = x.CustomName diff --git a/API/Controller/Shockers/SendControl.cs b/API/Controller/Shockers/SendControl.cs index 35620453..f6e3a0cd 100644 --- a/API/Controller/Shockers/SendControl.cs +++ b/API/Controller/Shockers/SendControl.cs @@ -35,7 +35,7 @@ public async Task SendControl( { var sender = new ControlLogSender { - Id = CurrentUser.Id, + UserId = CurrentUser.Id, Name = CurrentUser.Name, Image = CurrentUser.GetImageUrl(), ConnectionId = HttpContext.Connection.Id, diff --git a/API/Realtime/RedisSubscriberService.cs b/API/Realtime/RedisSubscriberService.cs index 40918540..de24d227 100644 --- a/API/Realtime/RedisSubscriberService.cs +++ b/API/Realtime/RedisSubscriberService.cs @@ -46,10 +46,10 @@ public RedisSubscriberService( public async Task StartAsync(CancellationToken cancellationToken) { await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired, (_, message) => { OsTask.Run(() => HandleKeyExpired(message)); }); - await _subscriber.SubscribeAsync(RedisChannels.DeviceOnlineStatus, (_, message) => { OsTask.Run(() => HandleDeviceOnlineStatus(message)); }); + await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus, (_, message) => { OsTask.Run(() => HandleDeviceStatus(message)); }); } - private async Task HandleDeviceOnlineStatus(RedisValue message) + private async Task HandleDeviceStatus(RedisValue message) { if (!message.HasValue) return; var data = JsonSerializer.Deserialize(message.ToString()); diff --git a/Common/DeviceControl/ControlLogic.cs b/Common/DeviceControl/ControlLogic.cs index d2edf724..75b26a1c 100644 --- a/Common/DeviceControl/ControlLogic.cs +++ b/Common/DeviceControl/ControlLogic.cs @@ -10,6 +10,7 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis.PubSub; using OpenShock.Common.Services.RedisPubSub; +using OpenShock.Common.Utils; namespace OpenShock.Common.DeviceControl; @@ -18,28 +19,28 @@ public static class ControlLogic public static async Task> ControlByUser(IReadOnlyList shocks, OpenShockContext db, ControlLogSender sender, IHubClients hubClients, IRedisPubService redisPubService) { - var ownShockers = await db.Shockers.Where(x => x.Device.OwnerId == sender.Id).Select(x => - new ControlShockerObj + var queryOwn = db.Shockers + .AsNoTracking() + .Where(x => x.Device.OwnerId == sender.UserId || x.UserShares.Any(u => u.SharedWithUserId == sender.UserId)) + .Select(x => new ControlShockerObj { Id = x.Id, - Name = x.Name, RfId = x.RfId, Device = x.DeviceId, Model = x.Model, - Owner = x.Device.OwnerId, Paused = x.IsPaused, PermsAndLimits = null - }).ToListAsync(); - - var sharedShockers = await db.UserShares.Where(x => x.SharedWithUserId == sender.Id).Select(x => - new ControlShockerObj + }); + + var queryShared = db.UserShares + .AsNoTracking() + .Where(x => x.SharedWithUserId == sender.UserId) + .Select(x => new ControlShockerObj { Id = x.Shocker.Id, - Name = x.Shocker.Name, RfId = x.Shocker.RfId, Device = x.Shocker.DeviceId, Model = x.Shocker.Model, - Owner = x.Shocker.Device.OwnerId, Paused = x.Shocker.IsPaused || x.IsPaused, PermsAndLimits = new SharePermsAndLimits { @@ -47,13 +48,14 @@ public static async Task> ControlPublicShare(IReadOnlyList shocks, OpenShockContext db, @@ -64,11 +66,9 @@ public static async Task new ControlShockerObj { Id = x.Shocker.Id, - Name = x.Shocker.Name, RfId = x.Shocker.RfId, Device = x.Shocker.DeviceId, Model = x.Shocker.Model, - Owner = x.Shocker.Device.OwnerId, Paused = x.Shocker.IsPaused || x.IsPaused, PermsAndLimits = new SharePermsAndLimits { @@ -76,7 +76,8 @@ public static async Task> ControlInternal(IReadOnlyList shocks, OpenShockContext db, ControlLogSender sender, - IHubClients hubClients, IReadOnlyCollection allowedShockers, IRedisPubService redisPubService) + IHubClients hubClients, ControlShockerObj[] allowedShockers, IRedisPubService redisPubService) { - var finalMessages = new Dictionary>(); + var finalMessages = new Dictionary>(); var curTime = DateTime.UtcNow; var distinctShocks = shocks.DistinctBy(x => x.Id); - var logs = new Dictionary>(); foreach (var shock in distinctShocks) { @@ -99,7 +99,7 @@ private static async Task kvp.Key, IReadOnlyList (kvp) => kvp.Value)); + var redisTask = redisPubService.SendDeviceControl(sender.UserId, finalMessages); - var logSends = logs.Select(x => hubClients.User(x.Key.ToString()).Log(sender, x.Value)); + var logSends = logs.Select(x => hubClients.User(x.Key.ToString()).Log(sender, x.Value)); await Task.WhenAll([ redisTask, @@ -160,19 +143,6 @@ await Task.WhenAll([ return new Success(); } - - private static bool IsAllowed(ControlType type, SharePermsAndLimits? perms) // TODO: Duplicate logic (LiveControlGateway.csproj -> LiveControlController.cs -> IsAllowed) - { - if (perms is null) return true; - return type switch - { - ControlType.Shock => perms.Shock, - ControlType.Vibrate => perms.Vibrate, - ControlType.Sound => perms.Sound, - ControlType.Stop => perms.Shock || perms.Vibrate || perms.Sound, - _ => false - }; - } } public readonly record struct ShockerNotFoundOrNoAccess(Guid Value); diff --git a/Common/DeviceControl/ControlShockerObj.cs b/Common/DeviceControl/ControlShockerObj.cs index 92df41ab..1f5ea9f9 100644 --- a/Common/DeviceControl/ControlShockerObj.cs +++ b/Common/DeviceControl/ControlShockerObj.cs @@ -4,12 +4,10 @@ namespace OpenShock.Common.DeviceControl; public sealed class ControlShockerObj { - public required Guid Id { get; set; } - public required string Name { get; set; } - public required ushort RfId { get; set; } - public required Guid Device { get; set; } - public required Guid Owner { get; set; } - public required ShockerModelType Model { get; set; } - public required bool Paused { get; set; } - public required SharePermsAndLimits? PermsAndLimits { get; set; } + public required Guid Id { get; init; } + public required ushort RfId { get; init; } + public required Guid Device { get; init; } + public required ShockerModelType Model { get; init; } + public required bool Paused { get; init; } + public required SharePermsAndLimits? PermsAndLimits { get; init; } } \ No newline at end of file diff --git a/Common/Hubs/PublicShareHub.cs b/Common/Hubs/PublicShareHub.cs index 960b5cea..a48fab5e 100644 --- a/Common/Hubs/PublicShareHub.cs +++ b/Common/Hubs/PublicShareHub.cs @@ -97,7 +97,7 @@ public override async Task OnConnectedAsync() CachedControlLogSender = user is null ? new ControlLogSender { - Id = Guid.Empty, + UserId = Guid.Empty, Name = "Guest", Image = GravatarUtils.GuestImageUrl, ConnectionId = Context.ConnectionId, @@ -106,7 +106,7 @@ public override async Task OnConnectedAsync() } : new ControlLogSender { - Id = user.Id, + UserId = user.Id, Image = user.Image, Name = user.Name, ConnectionId = Context.ConnectionId, diff --git a/Common/Hubs/UserHub.cs b/Common/Hubs/UserHub.cs index 69a28bd2..be16aebc 100644 --- a/Common/Hubs/UserHub.cs +++ b/Common/Hubs/UserHub.cs @@ -83,7 +83,7 @@ public async Task ControlV2(IReadOnlyList shocks, var sender = await _db.Users.Where(x => x.Id == UserId).Select(x => new ControlLogSender { - Id = x.Id, + UserId = x.Id, Name = x.Name, Image = x.GetImageUrl(), ConnectionId = Context.ConnectionId, @@ -141,11 +141,7 @@ public async Task Reboot(Guid deviceId) await _redisPubService.SendDeviceReboot(deviceId); } - - - private Task GetUser() => GetUser(UserId, _db); - + private Guid UserId => _userId ??= Guid.Parse(Context.UserIdentifier!); private Guid? _userId; - private static Task GetUser(Guid userId, OpenShockContext db) => db.Users.SingleAsync(x => x.Id == userId); } \ No newline at end of file diff --git a/Common/Models/ControlLogSender.cs b/Common/Models/ControlLogSender.cs index a3dad69b..a06f8c46 100644 --- a/Common/Models/ControlLogSender.cs +++ b/Common/Models/ControlLogSender.cs @@ -2,7 +2,7 @@ public class ControlLogSenderLight { - public required Guid Id { get; set; } + public required Guid UserId { get; set; } public required string Name { get; set; } public required Uri Image { get; set; } public required string? CustomName { get; set; } diff --git a/Common/Models/SharePermsAndLimits.cs b/Common/Models/SharePermsAndLimits.cs index 8657cdd4..948696f2 100644 --- a/Common/Models/SharePermsAndLimits.cs +++ b/Common/Models/SharePermsAndLimits.cs @@ -2,14 +2,10 @@ public class SharePermsAndLimits { - public required bool Sound { get; set; } - public required bool Vibrate { get; set; } - public required bool Shock { get; set; } - public required ushort? Duration { get; set; } - public required byte? Intensity { get; set; } -} - -public sealed class SharePermsAndLimitsLive : SharePermsAndLimits -{ - public required bool Live { get; set; } + public required bool Sound { get; init; } + public required bool Vibrate { get; init; } + public required bool Shock { get; init; } + public required ushort? Duration { get; init; } + public required byte? Intensity { get; init; } + public required bool Live { get; init; } } \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceMessage.cs b/Common/Redis/PubSub/DeviceMessage.cs index 0b7225a2..110b55fd 100644 --- a/Common/Redis/PubSub/DeviceMessage.cs +++ b/Common/Redis/PubSub/DeviceMessage.cs @@ -7,38 +7,32 @@ namespace OpenShock.Common.Redis.PubSub; [MessagePackObject] public sealed class DeviceMessage { - [Key(0)] public Guid DeviceId { get; init; } + [Key(0)] public DeviceMessageType Type { get; init; } - [Key(1)] public DeviceMessageType Type { get; init; } + [Key(1)] public required IDeviceMessagePayload Payload { get; init; } - [Key(2)] public required IDeviceMessagePayload MessagePayload { get; init; } - - public static DeviceMessage Create(Guid deviceId, DeviceTriggerType type) => new() + public static DeviceMessage Create(DeviceTriggerType type) => new() { - DeviceId = deviceId, Type = DeviceMessageType.Trigger, - MessagePayload = new DeviceTriggerPayload { Type = type } + Payload = new DeviceTriggerPayload { Type = type } }; - public static DeviceMessage Create(Guid deviceId, DeviceToggleTarget target, bool state) => new() + public static DeviceMessage Create(DeviceToggleTarget target, bool state) => new() { - DeviceId = deviceId, Type = DeviceMessageType.Toggle, - MessagePayload = new DeviceTogglePayload { Target = target, State = state } + Payload = new DeviceTogglePayload { Target = target, State = state } }; - public static DeviceMessage Create(Guid deviceId, DeviceControlPayload payload) => new() + public static DeviceMessage Create(DeviceControlPayload payload) => new() { - DeviceId = deviceId, Type = DeviceMessageType.Control, - MessagePayload = payload + Payload = payload }; - public static DeviceMessage Create(Guid deviceId, DeviceOtaInstallPayload payload) => new() + public static DeviceMessage Create(DeviceOtaInstallPayload payload) => new() { - DeviceId = deviceId, Type = DeviceMessageType.OtaInstall, - MessagePayload = payload + Payload = payload }; } @@ -84,9 +78,7 @@ public sealed class DeviceTogglePayload : IDeviceMessagePayload [MessagePackObject] public sealed class DeviceControlPayload : IDeviceMessagePayload { - [Key(0)] public Guid Sender { get; init; } - - [Key(1)] public required ShockerControlInfo[] Controls { get; init; } + [Key(0)] public required ShockerControlInfo[] Controls { get; init; } [MessagePackObject] public sealed class ShockerControlInfo diff --git a/Common/Redis/PubSub/DeviceStatus.cs b/Common/Redis/PubSub/DeviceStatus.cs index e9c281ef..c34dc46a 100644 --- a/Common/Redis/PubSub/DeviceStatus.cs +++ b/Common/Redis/PubSub/DeviceStatus.cs @@ -5,18 +5,40 @@ namespace OpenShock.Common.Redis.PubSub; [MessagePackObject] public sealed class DeviceStatus { - [Key(0)] public Guid DeviceId { get; init; } + [Key(0)] public DeviceStatusType Type { get; init; } + [Key(1)] public required IDeviceStatusPayload Payload { get; init; } - [Key(1)] public DeviceStatusType Type { get; init; } - - public static DeviceStatus Create(Guid deviceId, DeviceStatusType type) => new() + public static DeviceStatus Create(DeviceBoolStateType stateType, bool state) => new() { - DeviceId = deviceId, - Type = DeviceStatusType.Online, + Type = DeviceStatusType.BoolStateChanged, + Payload = new DeviceBoolStatePayload + { + Type = stateType, + State = state + } }; } public enum DeviceStatusType : byte +{ + BoolStateChanged = 0, +} + +[Union(0, typeof(DeviceTriggerPayload))] +[Union(1, typeof(DeviceTogglePayload))] +[Union(2, typeof(DeviceControlPayload))] +[Union(3, typeof(DeviceOtaInstallPayload))] +public interface IDeviceStatusPayload; + +public enum DeviceBoolStateType : byte { Online = 0, + EStopped = 1 +} + +[MessagePackObject] +public sealed class DeviceBoolStatePayload : IDeviceStatusPayload +{ + [Key(0)] public DeviceBoolStateType Type { get; init; } + [Key(1)] public bool State { get; init; } } \ No newline at end of file diff --git a/Common/Services/RedisPubSub/IRedisPubService.cs b/Common/Services/RedisPubSub/IRedisPubService.cs index d7c2db86..c861dec5 100644 --- a/Common/Services/RedisPubSub/IRedisPubService.cs +++ b/Common/Services/RedisPubSub/IRedisPubService.cs @@ -24,10 +24,9 @@ public interface IRedisPubService /// General shocker control /// /// - /// /// /// - Task SendDeviceControl(Guid deviceId, Guid senderId, DeviceControlPayload.ShockerControlInfo[] controls); + Task SendDeviceControl(Guid deviceId, DeviceControlPayload.ShockerControlInfo[] controls); /// /// Toggle captive portal diff --git a/Common/Services/RedisPubSub/RedisChannels.cs b/Common/Services/RedisPubSub/RedisChannels.cs index 258f5b0d..789a4fa9 100644 --- a/Common/Services/RedisPubSub/RedisChannels.cs +++ b/Common/Services/RedisPubSub/RedisChannels.cs @@ -6,5 +6,7 @@ public static class RedisChannels { public static readonly RedisChannel KeyEventExpired = new("__keyevent@0__:expired", RedisChannel.PatternMode.Literal); - public static readonly RedisChannel DeviceMessage = new("msg-device", RedisChannel.PatternMode.Literal); + public static RedisChannel DeviceMessage(Guid deviceId) => new($"device-msg:{deviceId}", RedisChannel.PatternMode.Pattern); + + public static readonly RedisChannel DeviceStatus = new("device-status", RedisChannel.PatternMode.Literal); } \ No newline at end of file diff --git a/Common/Services/RedisPubSub/RedisPubService.cs b/Common/Services/RedisPubSub/RedisPubService.cs index 54a175e2..904421ec 100644 --- a/Common/Services/RedisPubSub/RedisPubService.cs +++ b/Common/Services/RedisPubSub/RedisPubService.cs @@ -14,41 +14,41 @@ public RedisPubService(IConnectionMultiplexer connectionMultiplexer) _subscriber = connectionMultiplexer.GetSubscriber(); } - private Task Publish(T msg) => _subscriber.PublishAsync(RedisChannels.DeviceMessage, + private Task Publish(Guid deviceId, T msg) => _subscriber.PublishAsync(RedisChannels.DeviceMessage(deviceId), Convert.ToBase64String(MessagePackSerializer.Serialize(msg))); public Task SendDeviceUpdate(Guid deviceId) { - return Publish(DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceInfoUpdated)); + return Publish(deviceId, DeviceMessage.Create(DeviceTriggerType.DeviceInfoUpdated)); } public Task SendDeviceOnlineStatus(Guid deviceId, bool isOnline) { - return Publish(DeviceStatus.Create(deviceId, DeviceStatusType.Online)); + return Publish(deviceId, DeviceStatus.Create(DeviceBoolStateType.Online, isOnline)); } - public Task SendDeviceControl(Guid deviceId, Guid senderId, DeviceControlPayload.ShockerControlInfo[] controls) + public Task SendDeviceControl(Guid deviceId, DeviceControlPayload.ShockerControlInfo[] controls) { - return Publish(DeviceMessage.Create(deviceId, new DeviceControlPayload { Sender = senderId, Controls = controls })); + return Publish(deviceId, DeviceMessage.Create(new DeviceControlPayload { Controls = controls })); } public Task SendDeviceCaptivePortal(Guid deviceId, bool enabled) { - return Publish(DeviceMessage.Create(deviceId, DeviceToggleTarget.CaptivePortal, enabled)); + return Publish(deviceId, DeviceMessage.Create(DeviceToggleTarget.CaptivePortal, enabled)); } public Task SendDeviceEmergencyStop(Guid deviceId) { - return Publish(DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceEmergencyStop)); + return Publish(deviceId, DeviceMessage.Create(DeviceTriggerType.DeviceEmergencyStop)); } public Task SendDeviceOtaInstall(Guid deviceId, SemVersion version) { - return Publish(DeviceMessage.Create(deviceId, new DeviceOtaInstallPayload { Version = version })); + return Publish(deviceId, DeviceMessage.Create(new DeviceOtaInstallPayload { Version = version })); } public Task SendDeviceReboot(Guid deviceId) { - return Publish(DeviceMessage.Create(deviceId, DeviceTriggerType.DeviceReboot)); + return Publish(deviceId, DeviceMessage.Create(DeviceTriggerType.DeviceReboot)); } } \ No newline at end of file diff --git a/Common/Utils/PermissionUtils.cs b/Common/Utils/PermissionUtils.cs new file mode 100644 index 00000000..99307465 --- /dev/null +++ b/Common/Utils/PermissionUtils.cs @@ -0,0 +1,20 @@ +using OpenShock.Common.Models; + +namespace OpenShock.Common.Utils; + +public static class PermissionUtils +{ + public static bool IsAllowed(ControlType type, bool isLive, SharePermsAndLimits? perms) + { + if (perms is null) return true; + if (isLive && !perms.Live) return false; + return type switch + { + ControlType.Shock => perms.Shock, + ControlType.Vibrate => perms.Vibrate, + ControlType.Sound => perms.Sound, + ControlType.Stop => perms.Shock || perms.Vibrate || perms.Sound, + _ => false + }; + } +} \ No newline at end of file diff --git a/LiveControlGateway/Controllers/HubControllerBase.cs b/LiveControlGateway/Controllers/HubControllerBase.cs index a652778b..a0e1d23c 100644 --- a/LiveControlGateway/Controllers/HubControllerBase.cs +++ b/LiveControlGateway/Controllers/HubControllerBase.cs @@ -167,7 +167,7 @@ protected override async Task UnregisterConnection() } /// - public abstract ValueTask Control(List controlCommands); + public abstract ValueTask Control(IList controlCommands); /// public abstract ValueTask CaptivePortal(bool enable); diff --git a/LiveControlGateway/Controllers/HubV1Controller.cs b/LiveControlGateway/Controllers/HubV1Controller.cs index c7c7fe87..3680be69 100644 --- a/LiveControlGateway/Controllers/HubV1Controller.cs +++ b/LiveControlGateway/Controllers/HubV1Controller.cs @@ -165,7 +165,7 @@ await otaService.Error(CurrentHub.Id, payload.BootStatus.OtaUpdateId, false, } /// - public override ValueTask Control(List controlCommands) + public override ValueTask Control(IList controlCommands) => QueueMessage(new GatewayToHubMessage { Payload = new GatewayToHubMessagePayload(new ShockerCommandList diff --git a/LiveControlGateway/Controllers/HubV2Controller.cs b/LiveControlGateway/Controllers/HubV2Controller.cs index bfe60618..a17d4fdd 100644 --- a/LiveControlGateway/Controllers/HubV2Controller.cs +++ b/LiveControlGateway/Controllers/HubV2Controller.cs @@ -198,7 +198,7 @@ await otaService.Error(CurrentHub.Id, payload.BootStatus.OtaUpdateId, false, } /// - public override ValueTask Control(List controlCommands) + public override ValueTask Control(IList controlCommands) => QueueMessage(new GatewayToHubMessage { Payload = new GatewayToHubMessagePayload(new ShockerCommandList diff --git a/LiveControlGateway/Controllers/IHubController.cs b/LiveControlGateway/Controllers/IHubController.cs index 343c5c36..5462c4f1 100644 --- a/LiveControlGateway/Controllers/IHubController.cs +++ b/LiveControlGateway/Controllers/IHubController.cs @@ -18,7 +18,7 @@ public interface IHubController : IAsyncDisposable /// /// /// - public ValueTask Control(List controlCommands); + public ValueTask Control(IList controlCommands); /// /// Turn the captive portal on or off diff --git a/LiveControlGateway/Controllers/LiveControlController.cs b/LiveControlGateway/Controllers/LiveControlController.cs index b22b0f18..6687e183 100644 --- a/LiveControlGateway/Controllers/LiveControlController.cs +++ b/LiveControlGateway/Controllers/LiveControlController.cs @@ -42,7 +42,7 @@ public sealed class LiveControlController : WebsocketBaseController, NotFound, LiveNotEnabled, NoPermission, ShockerPaused> + private OneOf, NotFound, LiveNotEnabled, NoPermission, ShockerPaused> CheckFramePermissions( Guid shocker, ControlType controlType) { if (!_sharedShockers.TryGetValue(shocker, out var shockerShare)) return new NotFound(); if (shockerShare.Paused) return new ShockerPaused(); - if (!IsAllowed(controlType, shockerShare.PermsAndLimits)) return new NoPermission(); + if (!PermissionUtils.IsAllowed(controlType, true, shockerShare.PermsAndLimits)) return new NoPermission(); - return new Success(shockerShare.PermsAndLimits); - } - - private static bool IsAllowed(ControlType type, SharePermsAndLimitsLive? perms) // TODO: Duplicate logic (Common.csproj -> ControlLogic.cs -> IsAllowed) - { - if (perms is null) return true; - if (!perms.Live) return false; - return type switch - { - ControlType.Shock => perms.Shock, - ControlType.Vibrate => perms.Vibrate, - ControlType.Sound => perms.Sound, - ControlType.Stop => perms.Shock || perms.Vibrate || perms.Sound, - _ => false - }; + return new Success(shockerShare.PermsAndLimits); } diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index e116848f..3fa66b3a 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -320,27 +320,7 @@ private static DateTimeOffset CalculateActiveUntil(byte tps) => /// public ValueTask Control(IReadOnlyList shocks) { - var shocksTransformed = new List(shocks.Count); - foreach (var shock in shocks) - { - if (!_shockerStates.TryGetValue(shock.Id, out var state)) continue; - - _logger.LogTrace( - "Control exclusive: {Exclusive}, type: {Type}, duration: {Duration}, intensity: {Intensity}", - shock.Exclusive, shock.Type, shock.Duration, shock.Intensity); - state.ExclusiveUntil = shock.Exclusive && shock.Type != ControlType.Stop - ? DateTimeOffset.UtcNow.AddMilliseconds(shock.Duration) - : DateTimeOffset.MinValue; - - shocksTransformed.Add(new ShockerCommand - { - Id = shock.RfId, Duration = shock.Duration, Intensity = shock.Intensity, - Type = (ShockerCommandType)shock.Type, - Model = (ShockerModelType)shock.Model - }); - } - - return HubController.Control(shocksTransformed); + return HubController.Control(shocks); } /// diff --git a/LiveControlGateway/PubSub/RedisSubscriberService.cs b/LiveControlGateway/PubSub/RedisSubscriberService.cs index 74755b85..e785cff1 100644 --- a/LiveControlGateway/PubSub/RedisSubscriberService.cs +++ b/LiveControlGateway/PubSub/RedisSubscriberService.cs @@ -47,16 +47,16 @@ private async Task DeviceMessage(RedisValue value) switch (message.Type) { case DeviceMessageType.Trigger: - await DeviceMessageTrigger(message.DeviceId, message.MessagePayload as DeviceTriggerPayload); + await DeviceMessageTrigger(message.DeviceId, message.Payload as DeviceTriggerPayload); break; case DeviceMessageType.Toggle: - await DeviceMessageToggle(message.DeviceId, message.MessagePayload as DeviceTogglePayload); + await DeviceMessageToggle(message.DeviceId, message.Payload as DeviceTogglePayload); break; case DeviceMessageType.Control: - await DeviceMessageControl(message.DeviceId, message.MessagePayload as DeviceControlPayload); + await DeviceMessageControl(message.DeviceId, message.Payload as DeviceControlPayload); break; case DeviceMessageType.OtaInstall: - await DeviceMessageOtaInstall(message.DeviceId, message.MessagePayload as DeviceOtaInstallPayload); + await DeviceMessageOtaInstall(message.DeviceId, message.Payload as DeviceOtaInstallPayload); break; default: throw new ArgumentOutOfRangeException(); From d62d2525747a9a295f74428f3a5c3ec8a6a78993 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 20 Aug 2025 16:59:55 +0200 Subject: [PATCH 06/29] More WIP --- Common/DeviceControl/ControlLogic.cs | 66 ++++++++++++------- Common/DeviceControl/ControlShockerObj.cs | 2 + Common/Extensions/DictionaryExtensions.cs | 15 ++--- Common/Redis/PubSub/DeviceMessage.cs | 2 +- .../Services/RedisPubSub/IRedisPubService.cs | 2 +- .../Services/RedisPubSub/RedisPubService.cs | 2 +- 6 files changed, 53 insertions(+), 36 deletions(-) diff --git a/Common/DeviceControl/ControlLogic.cs b/Common/DeviceControl/ControlLogic.cs index 75b26a1c..ba04702c 100644 --- a/Common/DeviceControl/ControlLogic.cs +++ b/Common/DeviceControl/ControlLogic.cs @@ -25,9 +25,11 @@ public static async Task new ControlShockerObj { Id = x.Id, + Name = x.Name, RfId = x.RfId, Device = x.DeviceId, Model = x.Model, + OwnerId = x.Device.OwnerId, Paused = x.IsPaused, PermsAndLimits = null }); @@ -38,9 +40,11 @@ public static async Task new ControlShockerObj { Id = x.Shocker.Id, + Name = x.Shocker.Name, RfId = x.Shocker.RfId, Device = x.Shocker.DeviceId, Model = x.Shocker.Model, + OwnerId = x.Shocker.Device.OwnerId, Paused = x.Shocker.IsPaused || x.IsPaused, PermsAndLimits = new SharePermsAndLimits { @@ -64,22 +68,24 @@ public static async Task x.PublicShareId == publicShareId && (x.PublicShare.ExpiresAt > DateTime.UtcNow || x.PublicShare.ExpiresAt == null)) .Select(x => new ControlShockerObj - { - Id = x.Shocker.Id, - RfId = x.Shocker.RfId, - Device = x.Shocker.DeviceId, - Model = x.Shocker.Model, - Paused = x.Shocker.IsPaused || x.IsPaused, - PermsAndLimits = new SharePermsAndLimits { - Sound = x.AllowSound, - Vibrate = x.AllowVibrate, - Shock = x.AllowShock, - Duration = x.MaxDuration, - Intensity = x.MaxIntensity, - Live = x.AllowLiveControl - } - }).ToArrayAsync(); + Id = x.Shocker.Id, + Name = x.Shocker.Name, + RfId = x.Shocker.RfId, + Device = x.Shocker.DeviceId, + Model = x.Shocker.Model, + OwnerId = x.Shocker.Device.OwnerId, + Paused = x.Shocker.IsPaused || x.IsPaused, + PermsAndLimits = new SharePermsAndLimits + { + Sound = x.AllowSound, + Vibrate = x.AllowVibrate, + Shock = x.AllowShock, + Duration = x.MaxDuration, + Intensity = x.MaxIntensity, + Live = x.AllowLiveControl + } + }).ToArrayAsync(); return await ControlInternal(shocks, db, sender, hubClients, publicShareShockers, redisPubService); } @@ -87,7 +93,8 @@ public static async Task> ControlInternal(IReadOnlyList shocks, OpenShockContext db, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers, IRedisPubService redisPubService) { - var finalMessages = new Dictionary>(); + var messages = new Dictionary>(); + var logs = new Dictionary>(); var curTime = DateTime.UtcNow; var distinctShocks = shocks.DistinctBy(x => x.Id); @@ -103,12 +110,10 @@ private static async Task hubClients.User(x.Key.ToString()).Log(sender, x.Value)); + // Save all db cahnges before continuing + await db.SaveChangesAsync(); + // Then send all network events await Task.WhenAll([ - redisTask, - db.SaveChangesAsync(), - ..logSends + ..messages.Select(kvp => redisPubService.SendDeviceControl(kvp.Key, kvp.Value)), + ..logs.Select(x => hubClients.User(x.Key.ToString()).Log(sender, x.Value)) ]); return new Success(); diff --git a/Common/DeviceControl/ControlShockerObj.cs b/Common/DeviceControl/ControlShockerObj.cs index 1f5ea9f9..d13cdbe4 100644 --- a/Common/DeviceControl/ControlShockerObj.cs +++ b/Common/DeviceControl/ControlShockerObj.cs @@ -5,9 +5,11 @@ namespace OpenShock.Common.DeviceControl; public sealed class ControlShockerObj { public required Guid Id { get; init; } + public required string Name { get; init; } public required ushort RfId { get; init; } public required Guid Device { get; init; } public required ShockerModelType Model { get; init; } + public required Guid OwnerId { get; init; } public required bool Paused { get; init; } public required SharePermsAndLimits? PermsAndLimits { get; init; } } \ No newline at end of file diff --git a/Common/Extensions/DictionaryExtensions.cs b/Common/Extensions/DictionaryExtensions.cs index bb90c300..1fdea473 100644 --- a/Common/Extensions/DictionaryExtensions.cs +++ b/Common/Extensions/DictionaryExtensions.cs @@ -4,16 +4,15 @@ namespace OpenShock.Common.Extensions; public static class DictionaryExtensions { - public static TValue GetValueOrAddDefault(this Dictionary dictionary, TKey key, - TValue defaultValue) where TKey : notnull + public static void AppendValue(this Dictionary> dictionary, TKey key, TValue value) where TKey : notnull { - ref var value = ref CollectionsMarshal.GetValueRefOrAddDefault(dictionary, key, out var exists); - if (exists) + ref var list = ref CollectionsMarshal.GetValueRefOrAddDefault(dictionary, key, out _); + if (list is null) { - return value!; + list = [value]; + return; } - - value = defaultValue; - return value; + + list.Add(value); } } \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceMessage.cs b/Common/Redis/PubSub/DeviceMessage.cs index 110b55fd..2a979cff 100644 --- a/Common/Redis/PubSub/DeviceMessage.cs +++ b/Common/Redis/PubSub/DeviceMessage.cs @@ -78,7 +78,7 @@ public sealed class DeviceTogglePayload : IDeviceMessagePayload [MessagePackObject] public sealed class DeviceControlPayload : IDeviceMessagePayload { - [Key(0)] public required ShockerControlInfo[] Controls { get; init; } + [Key(0)] public required List Controls { get; init; } [MessagePackObject] public sealed class ShockerControlInfo diff --git a/Common/Services/RedisPubSub/IRedisPubService.cs b/Common/Services/RedisPubSub/IRedisPubService.cs index c861dec5..c8fe9110 100644 --- a/Common/Services/RedisPubSub/IRedisPubService.cs +++ b/Common/Services/RedisPubSub/IRedisPubService.cs @@ -26,7 +26,7 @@ public interface IRedisPubService /// /// /// - Task SendDeviceControl(Guid deviceId, DeviceControlPayload.ShockerControlInfo[] controls); + Task SendDeviceControl(Guid deviceId, List controls); /// /// Toggle captive portal diff --git a/Common/Services/RedisPubSub/RedisPubService.cs b/Common/Services/RedisPubSub/RedisPubService.cs index 904421ec..31325b59 100644 --- a/Common/Services/RedisPubSub/RedisPubService.cs +++ b/Common/Services/RedisPubSub/RedisPubService.cs @@ -27,7 +27,7 @@ public Task SendDeviceOnlineStatus(Guid deviceId, bool isOnline) return Publish(deviceId, DeviceStatus.Create(DeviceBoolStateType.Online, isOnline)); } - public Task SendDeviceControl(Guid deviceId, DeviceControlPayload.ShockerControlInfo[] controls) + public Task SendDeviceControl(Guid deviceId, List controls) { return Publish(deviceId, DeviceMessage.Create(new DeviceControlPayload { Controls = controls })); } From 8ab7104160fb47f4aa1867104b1f82185ea21903 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 15:49:42 +0200 Subject: [PATCH 07/29] More logic stuffz --- API/Controller/Shockers/SendControl.cs | 13 +- API/Realtime/RedisSubscriberService.cs | 5 +- Common/Hubs/PublicShareHub.cs | 20 +-- Common/Hubs/UserHub.cs | 9 +- Common/Models/ControlLogSender.cs | 2 +- Common/Redis/PubSub/DeviceMessage.cs | 16 +- Common/Redis/PubSub/DeviceStatus.cs | 5 +- .../ControlSender.cs} | 49 +++--- Common/Services/IControlSender.cs | 21 +++ .../Services/RedisPubSub/RedisPubService.cs | 3 +- .../Controllers/HubControllerBase.cs | 4 +- .../Controllers/HubV1Controller.cs | 4 +- .../Controllers/HubV2Controller.cs | 4 +- .../LifetimeManager/HubLifetime.cs | 146 +++++++++++++++--- .../LifetimeManager/HubLifetimeManager.cs | 17 +- .../Mappers/FlatbuffersMappers.cs | 30 ++++ .../Models/LiveShockerPermission.cs | 2 +- LiveControlGateway/Program.cs | 2 - .../PubSub/RedisSubscriberService.cs | 124 --------------- 19 files changed, 251 insertions(+), 225 deletions(-) rename Common/{DeviceControl/ControlLogic.cs => Services/ControlSender.cs} (74%) create mode 100644 Common/Services/IControlSender.cs create mode 100644 LiveControlGateway/Mappers/FlatbuffersMappers.cs delete mode 100644 LiveControlGateway/PubSub/RedisSubscriberService.cs diff --git a/API/Controller/Shockers/SendControl.cs b/API/Controller/Shockers/SendControl.cs index f6e3a0cd..a8ce8d28 100644 --- a/API/Controller/Shockers/SendControl.cs +++ b/API/Controller/Shockers/SendControl.cs @@ -9,14 +9,11 @@ using OpenShock.Common.Hubs; using OpenShock.Common.Models; using OpenShock.Common.Problems; -using OpenShock.Common.Services.RedisPubSub; namespace OpenShock.API.Controller.Shockers; public sealed partial class ShockerController { - private static readonly IDictionary EmptyDic = new Dictionary(); - /// /// Send a control message to shockers /// @@ -31,7 +28,7 @@ public sealed partial class ShockerController public async Task SendControl( [FromBody] ControlRequest body, [FromServices] IHubContext userHub, - [FromServices] IRedisPubService redisPubService) + [FromServices] IControlSender controlSender) { var sender = new ControlLogSender { @@ -39,11 +36,11 @@ public async Task SendControl( Name = CurrentUser.Name, Image = CurrentUser.GetImageUrl(), ConnectionId = HttpContext.Connection.Id, - AdditionalItems = EmptyDic, + AdditionalItems = [], CustomName = body.CustomName }; - var controlAction = await ControlLogic.ControlByUser(body.Shocks, _db, sender, userHub.Clients, redisPubService); + var controlAction = await controlSender.ControlByUser(body.Shocks, sender, userHub.Clients); return controlAction.Match( success => LegacyEmptyOk("Successfully sent control messages"), notFound => Problem(ShockerControlError.ShockerControlNotFound(notFound.Value)), @@ -65,12 +62,12 @@ public async Task SendControl( public Task SendControl_DEPRECATED( [FromBody] IReadOnlyList body, [FromServices] IHubContext userHub, - [FromServices] IRedisPubService redisPubService) + [FromServices] IControlSender controlSender) { return SendControl(new ControlRequest { Shocks = body, CustomName = null - }, userHub, redisPubService); + }, userHub, controlSender); } } \ No newline at end of file diff --git a/API/Realtime/RedisSubscriberService.cs b/API/Realtime/RedisSubscriberService.cs index de24d227..7be9fd6f 100644 --- a/API/Realtime/RedisSubscriberService.cs +++ b/API/Realtime/RedisSubscriberService.cs @@ -5,7 +5,6 @@ using OpenShock.Common.Models.WebSocket; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; -using OpenShock.Common.Redis.PubSub; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; using Redis.OM.Contracts; @@ -45,8 +44,8 @@ public RedisSubscriberService( /// public async Task StartAsync(CancellationToken cancellationToken) { - await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired, (_, message) => { OsTask.Run(() => HandleKeyExpired(message)); }); - await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus, (_, message) => { OsTask.Run(() => HandleDeviceStatus(message)); }); + await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired, (_, message) => OsTask.Run(() => HandleKeyExpired(message))); + await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus, (_, message) => OsTask.Run(() => HandleDeviceStatus(message))); } private async Task HandleDeviceStatus(RedisValue message) diff --git a/Common/Hubs/PublicShareHub.cs b/Common/Hubs/PublicShareHub.cs index a48fab5e..598b7c7e 100644 --- a/Common/Hubs/PublicShareHub.cs +++ b/Common/Hubs/PublicShareHub.cs @@ -6,6 +6,7 @@ using OpenShock.Common.Extensions; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -14,23 +15,22 @@ namespace OpenShock.Common.Hubs; public sealed class PublicShareHub : Hub { - private readonly ISessionService _sessionService; private readonly OpenShockContext _db; private readonly IHubContext _userHub; - private readonly ILogger _logger; - private readonly IRedisPubService _redisPubService; + private readonly ISessionService _sessionService; + private readonly IControlSender _controlSender; private readonly IUserReferenceService _userReferenceService; + private readonly ILogger _logger; private IReadOnlyList? _tokenPermissions = null; - public PublicShareHub(OpenShockContext db, IHubContext userHub, ILogger logger, - ISessionService sessionService, IRedisPubService redisPubService, IUserReferenceService userReferenceService) + public PublicShareHub(OpenShockContext db, IHubContext userHub, ISessionService sessionService, IControlSender controlSender, IUserReferenceService userReferenceService, ILogger logger) { _db = db; _userHub = userHub; - _logger = logger; - _redisPubService = redisPubService; - _userReferenceService = userReferenceService; _sessionService = sessionService; + _controlSender = controlSender; + _userReferenceService = userReferenceService; + _logger = logger; } public override async Task OnConnectedAsync() @@ -122,8 +122,8 @@ public Task Control(IReadOnlyList shocks) { if (!_tokenPermissions.IsAllowedAllowOnNull(PermissionType.Shockers_Use)) return Task.CompletedTask; - return ControlLogic.ControlPublicShare(shocks, _db, CustomData.CachedControlLogSender, _userHub.Clients, - CustomData.PublicShareId, _redisPubService); + return _controlSender.ControlPublicShare(shocks, CustomData.CachedControlLogSender, _userHub.Clients, + CustomData.PublicShareId); } private CustomDataHolder CustomData => (CustomDataHolder)Context.Items[PublicShareCustomData]!; diff --git a/Common/Hubs/UserHub.cs b/Common/Hubs/UserHub.cs index be16aebc..2505cec1 100644 --- a/Common/Hubs/UserHub.cs +++ b/Common/Hubs/UserHub.cs @@ -9,6 +9,7 @@ using OpenShock.Common.Models.WebSocket; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; +using OpenShock.Common.Services; using OpenShock.Common.Services.RedisPubSub; using Redis.OM; using Redis.OM.Contracts; @@ -23,16 +24,18 @@ public sealed class UserHub : Hub private readonly OpenShockContext _db; private readonly IRedisConnectionProvider _provider; private readonly IRedisPubService _redisPubService; + private readonly IControlSender _controlSender; private readonly IUserReferenceService _userReferenceService; private IReadOnlyList? _tokenPermissions = null; public UserHub(ILogger logger, OpenShockContext db, IRedisConnectionProvider provider, - IRedisPubService redisPubService, IUserReferenceService userReferenceService) + IRedisPubService redisPubService, IControlSender controlSender, IUserReferenceService userReferenceService) { _logger = logger; _db = db; _provider = provider; _redisPubService = redisPubService; + _controlSender = controlSender; _userReferenceService = userReferenceService; } @@ -89,9 +92,9 @@ public async Task ControlV2(IReadOnlyList shocks, ConnectionId = Context.ConnectionId, AdditionalItems = additionalItems, CustomName = customName - }).SingleAsync(); + }).FirstAsync(); - await ControlLogic.ControlByUser(shocks, _db, sender, Clients, _redisPubService); + await _controlSender.ControlByUser(shocks, sender, Clients); } public async Task CaptivePortal(Guid deviceId, bool enabled) diff --git a/Common/Models/ControlLogSender.cs b/Common/Models/ControlLogSender.cs index a06f8c46..8eae6725 100644 --- a/Common/Models/ControlLogSender.cs +++ b/Common/Models/ControlLogSender.cs @@ -11,5 +11,5 @@ public class ControlLogSenderLight public class ControlLogSender : ControlLogSenderLight { public required string ConnectionId { get; set; } - public required IDictionary AdditionalItems { get; set; } + public required Dictionary AdditionalItems { get; set; } } \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceMessage.cs b/Common/Redis/PubSub/DeviceMessage.cs index 2a979cff..65a094ab 100644 --- a/Common/Redis/PubSub/DeviceMessage.cs +++ b/Common/Redis/PubSub/DeviceMessage.cs @@ -7,43 +7,29 @@ namespace OpenShock.Common.Redis.PubSub; [MessagePackObject] public sealed class DeviceMessage { - [Key(0)] public DeviceMessageType Type { get; init; } - - [Key(1)] public required IDeviceMessagePayload Payload { get; init; } + [Key(0)] public required IDeviceMessagePayload Payload { get; init; } public static DeviceMessage Create(DeviceTriggerType type) => new() { - Type = DeviceMessageType.Trigger, Payload = new DeviceTriggerPayload { Type = type } }; public static DeviceMessage Create(DeviceToggleTarget target, bool state) => new() { - Type = DeviceMessageType.Toggle, Payload = new DeviceTogglePayload { Target = target, State = state } }; public static DeviceMessage Create(DeviceControlPayload payload) => new() { - Type = DeviceMessageType.Control, Payload = payload }; public static DeviceMessage Create(DeviceOtaInstallPayload payload) => new() { - Type = DeviceMessageType.OtaInstall, Payload = payload }; } -public enum DeviceMessageType : byte -{ - Trigger = 0, - Toggle = 1, - Control = 2, - OtaInstall = 3 -} - [Union(0, typeof(DeviceTriggerPayload))] [Union(1, typeof(DeviceTogglePayload))] [Union(2, typeof(DeviceControlPayload))] diff --git a/Common/Redis/PubSub/DeviceStatus.cs b/Common/Redis/PubSub/DeviceStatus.cs index c34dc46a..e76f0da9 100644 --- a/Common/Redis/PubSub/DeviceStatus.cs +++ b/Common/Redis/PubSub/DeviceStatus.cs @@ -24,10 +24,7 @@ public enum DeviceStatusType : byte BoolStateChanged = 0, } -[Union(0, typeof(DeviceTriggerPayload))] -[Union(1, typeof(DeviceTogglePayload))] -[Union(2, typeof(DeviceControlPayload))] -[Union(3, typeof(DeviceOtaInstallPayload))] +[Union(0, typeof(DeviceBoolStatePayload))] public interface IDeviceStatusPayload; public enum DeviceBoolStateType : byte diff --git a/Common/DeviceControl/ControlLogic.cs b/Common/Services/ControlSender.cs similarity index 74% rename from Common/DeviceControl/ControlLogic.cs rename to Common/Services/ControlSender.cs index ba04702c..a0935a53 100644 --- a/Common/DeviceControl/ControlLogic.cs +++ b/Common/Services/ControlSender.cs @@ -3,6 +3,7 @@ using OneOf; using OneOf.Types; using OpenShock.Common.Constants; +using OpenShock.Common.DeviceControl; using OpenShock.Common.Extensions; using OpenShock.Common.Hubs; using OpenShock.Common.Models; @@ -12,14 +13,22 @@ using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; -namespace OpenShock.Common.DeviceControl; +namespace OpenShock.Common.Services; -public static class ControlLogic +public sealed class ControlSender : IControlSender { - public static async Task> ControlByUser(IReadOnlyList shocks, OpenShockContext db, ControlLogSender sender, - IHubClients hubClients, IRedisPubService redisPubService) + private readonly OpenShockContext _db; + private readonly IRedisPubService _publisher; + + public ControlSender(OpenShockContext db, IRedisPubService publisher) + { + _db = db; + _publisher = publisher; + } + + public async Task> ControlByUser(IReadOnlyList shocks,ControlLogSender sender, IHubClients hubClients) { - var queryOwn = db.Shockers + var queryOwn = _db.Shockers .AsNoTracking() .Where(x => x.Device.OwnerId == sender.UserId || x.UserShares.Any(u => u.SharedWithUserId == sender.UserId)) .Select(x => new ControlShockerObj @@ -34,7 +43,7 @@ public static async Task x.SharedWithUserId == sender.UserId) .Select(x => new ControlShockerObj @@ -59,14 +68,13 @@ public static async Task> ControlPublicShare(IReadOnlyList shocks, OpenShockContext db, - ControlLogSender sender, - IHubClients hubClients, Guid publicShareId, IRedisPubService redisPubService) + public async Task> ControlPublicShare(IReadOnlyList shocks, ControlLogSender sender, IHubClients hubClients, Guid publicShareId) { - var publicShareShockers = await db.PublicShareShockerMappings.Where(x => x.PublicShareId == publicShareId && (x.PublicShare.ExpiresAt > DateTime.UtcNow || x.PublicShare.ExpiresAt == null)) + var publicShareShockers = await _db.PublicShareShockerMappings + .Where(x => x.PublicShareId == publicShareId && (x.PublicShare.ExpiresAt > DateTime.UtcNow || x.PublicShare.ExpiresAt == null)) .Select(x => new ControlShockerObj { Id = x.Shocker.Id, @@ -87,11 +95,10 @@ public static async Task> ControlInternal(IReadOnlyList shocks, OpenShockContext db, ControlLogSender sender, - IHubClients hubClients, ControlShockerObj[] allowedShockers, IRedisPubService redisPubService) + private async Task> ControlInternal(IReadOnlyList shocks, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers) { var messages = new Dictionary>(); var logs = new Dictionary>(); @@ -135,7 +142,7 @@ private static async Task redisPubService.SendDeviceControl(kvp.Key, kvp.Value)), + ..messages.Select(kvp => _publisher.SendDeviceControl(kvp.Key, kvp.Value)), ..logs.Select(x => hubClients.User(x.Key.ToString()).Log(sender, x.Value)) ]); return new Success(); } -} - -public readonly record struct ShockerNotFoundOrNoAccess(Guid Value); - -public readonly record struct ShockerPaused(Guid Value); - -public readonly record struct ShockerNoPermission(Guid Value); \ No newline at end of file +} \ No newline at end of file diff --git a/Common/Services/IControlSender.cs b/Common/Services/IControlSender.cs new file mode 100644 index 00000000..5a284a59 --- /dev/null +++ b/Common/Services/IControlSender.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.SignalR; +using OneOf; +using OneOf.Types; +using OpenShock.Common.Hubs; +using OpenShock.Common.Models; +using OpenShock.Common.Models.WebSocket.User; + +namespace OpenShock.Common.DeviceControl; + +public interface IControlSender +{ + public Task> ControlByUser(IReadOnlyList shocks, ControlLogSender sender, IHubClients hubClients); + + public Task> ControlPublicShare(IReadOnlyList shocks, ControlLogSender sender, IHubClients hubClients, Guid publicShareId); +} + +public readonly record struct ShockerNotFoundOrNoAccess(Guid Value); + +public readonly record struct ShockerPaused(Guid Value); + +public readonly record struct ShockerNoPermission(Guid Value); \ No newline at end of file diff --git a/Common/Services/RedisPubSub/RedisPubService.cs b/Common/Services/RedisPubSub/RedisPubService.cs index 31325b59..5fe004d7 100644 --- a/Common/Services/RedisPubSub/RedisPubService.cs +++ b/Common/Services/RedisPubSub/RedisPubService.cs @@ -14,8 +14,7 @@ public RedisPubService(IConnectionMultiplexer connectionMultiplexer) _subscriber = connectionMultiplexer.GetSubscriber(); } - private Task Publish(Guid deviceId, T msg) => _subscriber.PublishAsync(RedisChannels.DeviceMessage(deviceId), - Convert.ToBase64String(MessagePackSerializer.Serialize(msg))); + private Task Publish(Guid deviceId, T msg) => _subscriber.PublishAsync(RedisChannels.DeviceMessage(deviceId), (RedisValue)new ReadOnlyMemory(MessagePackSerializer.Serialize(msg))); public Task SendDeviceUpdate(Guid deviceId) { diff --git a/LiveControlGateway/Controllers/HubControllerBase.cs b/LiveControlGateway/Controllers/HubControllerBase.cs index a0e1d23c..ac1ff3e5 100644 --- a/LiveControlGateway/Controllers/HubControllerBase.cs +++ b/LiveControlGateway/Controllers/HubControllerBase.cs @@ -1,5 +1,4 @@ -using System.Net.WebSockets; -using FlatSharp; +using FlatSharp; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; @@ -16,6 +15,7 @@ using OpenShock.LiveControlGateway.Websocket; using OpenShock.Serialization.Gateway; using Semver; +using System.Net.WebSockets; using Timer = System.Timers.Timer; namespace OpenShock.LiveControlGateway.Controllers; diff --git a/LiveControlGateway/Controllers/HubV1Controller.cs b/LiveControlGateway/Controllers/HubV1Controller.cs index 3680be69..970be688 100644 --- a/LiveControlGateway/Controllers/HubV1Controller.cs +++ b/LiveControlGateway/Controllers/HubV1Controller.cs @@ -170,14 +170,14 @@ public override ValueTask Control(IList new ShockerCommand() + Commands = [.. controlCommands.Select(x => new ShockerCommand() { Duration = x.Duration, Type = x.Type, Id = x.Model == Serialization.Types.ShockerModelType.Petrainer998DR ? (ushort)(x.Id >> 1) : x.Id, // Fix for old hubs, their ids was serialized wrongly in the RFTransmitter, the V1 endpoint is being phased out, so this wont stay here forever Intensity = x.Intensity, Model = x.Model - }).ToList() + })] }) }); diff --git a/LiveControlGateway/Controllers/HubV2Controller.cs b/LiveControlGateway/Controllers/HubV2Controller.cs index a17d4fdd..8440c747 100644 --- a/LiveControlGateway/Controllers/HubV2Controller.cs +++ b/LiveControlGateway/Controllers/HubV2Controller.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; @@ -16,6 +15,7 @@ using OpenShock.Serialization.Types; using Semver; using Serilog; +using System.Diagnostics; namespace OpenShock.LiveControlGateway.Controllers; //TODO: Implement new keep alive ping pong mechanism diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 3fa66b3a..4c7cb72c 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -1,23 +1,26 @@ -using System.Collections.Immutable; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics; +using MessagePack; +using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OneOf; using OneOf.Types; using OpenShock.Common.Constants; using OpenShock.Common.Extensions; using OpenShock.Common.Models; +using OpenShock.Common.Models.WebSocket.User; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; using OpenShock.Common.Redis.PubSub; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; using OpenShock.LiveControlGateway.Controllers; +using OpenShock.LiveControlGateway.Mappers; using OpenShock.Serialization.Gateway; -using OpenShock.Serialization.Types; using Redis.OM.Contracts; using Semver; -using ShockerModelType = OpenShock.Serialization.Types.ShockerModelType; +using StackExchange.Redis; +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; namespace OpenShock.LiveControlGateway.LifetimeManager; @@ -41,13 +44,16 @@ public sealed class HubLifetime : IAsyncDisposable private readonly TimeSpan _waitBetweenTicks; private readonly ushort _commandDuration; - private Dictionary _shockerStates = new(); + private Dictionary _shockerStates = []; private readonly CancellationTokenSource _cancellationSource; private readonly IDbContextFactory _dbContextFactory; private readonly IRedisConnectionProvider _redisConnectionProvider; private readonly IRedisPubService _redisPubService; + private readonly RedisChannel _deviceMsgChannel; + private readonly ISubscriber _subscriber; + private readonly ILogger _logger; private ImmutableArray _liveControlClients = ImmutableArray.Empty; @@ -59,11 +65,13 @@ public sealed class HubLifetime : IAsyncDisposable /// /// /// + /// /// /// /// public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, IDbContextFactory dbContextFactory, + IConnectionMultiplexer connectionMultiplexer, IRedisConnectionProvider redisConnectionProvider, IRedisPubService redisPubService, ILogger logger) @@ -77,6 +85,9 @@ public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, _waitBetweenTicks = TimeSpan.FromMilliseconds(Math.Floor((float)1000 / tps)); _commandDuration = (ushort)(_waitBetweenTicks.TotalMilliseconds * 2.5); + + _subscriber = connectionMultiplexer.GetSubscriber(); + _deviceMsgChannel = RedisChannels.DeviceMessage(hubController.Id); } /// @@ -157,11 +168,106 @@ public async Task InitAsync(CancellationToken cancellationToken) OsTask.Run(UpdateLoop); #pragma warning restore CS4014 + await _subscriber.SubscribeAsync(_deviceMsgChannel, HandleRedisMessage); + _state = HubLifetimeState.Idle; // We are fully setup, we can go back to idle state return true; } + private void HandleRedisMessage(RedisChannel _, RedisValue value) + { + if (!value.HasValue) return; + + DeviceMessage message; + try + { + message = MessagePackSerializer.Deserialize((ReadOnlyMemory)value); + if (message is null) return; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to deserialize redis message"); + return; + } + + OsTask.Run(() => DeviceMessage(message)); + } + + private async Task DeviceMessage(DeviceMessage message) + { + switch (message.Payload) + { + case DeviceTriggerPayload trigger: + await DeviceMessageTrigger(trigger); + break; + case DeviceTogglePayload toggle: + await DeviceMessageToggle(toggle); + break; + case DeviceControlPayload control: + await DeviceMessageControl(control); + break; + case DeviceOtaInstallPayload { Version: var version }: + await OtaInstall(version); + break; + default: + _logger.LogError("Got DeviceMessage with unknown payload type: {PayloadType}", message.Payload?.GetType().Name); + break; + } + } + + private async Task DeviceMessageTrigger(DeviceTriggerPayload trigger) + { + switch (trigger.Type) + { + case DeviceTriggerType.DeviceInfoUpdated: + await UpdateDevice(); + break; + case DeviceTriggerType.DeviceEmergencyStop: + await EmergencyStop(); // Ignored bool return + break; + case DeviceTriggerType.DeviceReboot: + await Reboot(); // Ignored bool return + break; + default: + _logger.LogError("Unknown DeviceTriggerType: {TriggerType}", trigger.Type); + break; + } + } + + private async ValueTask DeviceMessageToggle(DeviceTogglePayload toggle) + { + switch (toggle.Target) + { + case DeviceToggleTarget.CaptivePortal: + await ControlCaptive(toggle.State); + break; + default: + _logger.LogError("Unknown DeviceToggleTarget: {Target}", toggle.Target); + break; + } + } + + private async ValueTask DeviceMessageControl(DeviceControlPayload control) + { + if (control.Controls.Count == 0) + { + _logger.LogDebug("DeviceControlPayload had no commands, skipping."); + return; + } + + await Control([ + ..control.Controls.Select(cmd => new ShockerCommand + { + Model = FlatbuffersMappers.ToFbsModelType(cmd.Model), + Id = cmd.RfId, + Type = FlatbuffersMappers.ToFbsCommandType(cmd.Type), + Intensity = cmd.Intensity, + Duration = cmd.Duration + }) + ]); + } + /// /// Swap to a new underlying controller /// @@ -238,23 +344,20 @@ private async Task UpdateLoop() private async Task Update() { - var commandList = new List(_shockerStates.Count); - foreach (var (_, state) in _shockerStates) - { - var cur = DateTimeOffset.UtcNow; - if (state.ActiveUntil < cur || state.ExclusiveUntil >= cur) continue; - - commandList.Add(new ShockerCommand + var now = DateTimeOffset.UtcNow; + var commandList = _shockerStates + .Where(kvp => kvp.Value.ActiveUntil < now || kvp.Value.ExclusiveUntil >= now) + .Select(kvp => new ShockerCommand { - Id = state.RfId, - Model = (ShockerModelType)state.Model, - Type = (ShockerCommandType)state.LastType, + Model = FlatbuffersMappers.ToFbsModelType(kvp.Value.Model), + Id = kvp.Value.RfId, + Type = FlatbuffersMappers.ToFbsCommandType(kvp.Value.LastType), + Intensity = kvp.Value.LastIntensity, Duration = _commandDuration, - Intensity = state.LastIntensity - }); - } + }) + .ToArray(); - if (commandList.Count == 0) return; + if (commandList.Length == 0) return; await HubController.Control(commandList); } @@ -318,7 +421,7 @@ private static DateTimeOffset CalculateActiveUntil(byte tps) => /// /// /// - public ValueTask Control(IReadOnlyList shocks) + public ValueTask Control(IList shocks) { return HubController.Control(shocks); } @@ -419,6 +522,7 @@ public async ValueTask DisposeAsync() if (_disposed) return; _disposed = true; + await _subscriber.UnsubscribeAllAsync(); await _cancellationSource.CancelAsync(); await DisposeLiveControlClients(); } diff --git a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs index 9b254e84..513db4d5 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs @@ -5,8 +5,11 @@ using OpenShock.Common.Redis.PubSub; using OpenShock.Common.Services.RedisPubSub; using OpenShock.LiveControlGateway.Controllers; +using OpenShock.LiveControlGateway.Mappers; +using OpenShock.Serialization.Gateway; using Redis.OM.Contracts; using Semver; +using StackExchange.Redis; namespace OpenShock.LiveControlGateway.LifetimeManager; @@ -16,6 +19,7 @@ namespace OpenShock.LiveControlGateway.LifetimeManager; public sealed class HubLifetimeManager { private readonly IDbContextFactory _dbContextFactory; + private readonly IConnectionMultiplexer _connectionMultiplexer; private readonly IRedisConnectionProvider _redisConnectionProvider; private readonly IRedisPubService _redisPubService; private readonly ILoggerFactory _loggerFactory; @@ -28,17 +32,20 @@ public sealed class HubLifetimeManager /// DI constructor /// /// + /// /// /// /// public HubLifetimeManager( IDbContextFactory dbContextFactory, + IConnectionMultiplexer connectionMultiplexer, IRedisConnectionProvider redisConnectionProvider, IRedisPubService redisPubService, ILoggerFactory loggerFactory ) { _dbContextFactory = dbContextFactory; + _connectionMultiplexer = connectionMultiplexer; _redisConnectionProvider = redisConnectionProvider; _redisPubService = redisPubService; _loggerFactory = loggerFactory; @@ -114,6 +121,7 @@ private HubLifetime CreateNewLifetime(byte tps, IHubController hubController) tps, hubController, _dbContextFactory, + _connectionMultiplexer, _redisConnectionProvider, _redisPubService, _loggerFactory.CreateLogger()); @@ -215,7 +223,14 @@ public async Task RemoveDeviceConnection(IHubController hubController) IReadOnlyList shocks) { if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); - await deviceLifetime.Control(shocks); + await deviceLifetime.Control([.. shocks.Select(shock => new ShockerCommand + { + Model = FlatbuffersMappers.ToFbsModelType(shock.Model), + Id = shock.RfId, + Type = FlatbuffersMappers.ToFbsCommandType(shock.Type), + Intensity = shock.Intensity, + Duration = shock.Duration + })]); return new Success(); } diff --git a/LiveControlGateway/Mappers/FlatbuffersMappers.cs b/LiveControlGateway/Mappers/FlatbuffersMappers.cs new file mode 100644 index 00000000..87cc1f70 --- /dev/null +++ b/LiveControlGateway/Mappers/FlatbuffersMappers.cs @@ -0,0 +1,30 @@ +using OpenShock.Common.Models; +using OpenShock.Serialization.Types; + +namespace OpenShock.LiveControlGateway.Mappers; + +public static class FlatbuffersMappers +{ + public static Serialization.Types.ShockerModelType ToFbsModelType(Common.Models.ShockerModelType type) + { + return type switch + { + Common.Models.ShockerModelType.CaiXianlin => Serialization.Types.ShockerModelType.CaiXianlin, + Common.Models.ShockerModelType.PetTrainer => Serialization.Types.ShockerModelType.Petrainer, + Common.Models.ShockerModelType.Petrainer998DR => Serialization.Types.ShockerModelType.Petrainer998DR, + _ => throw new NotImplementedException(), + }; + } + + public static ShockerCommandType ToFbsCommandType(ControlType type) + { + return type switch + { + ControlType.Stop => ShockerCommandType.Stop, + ControlType.Shock => ShockerCommandType.Shock, + ControlType.Vibrate => ShockerCommandType.Vibrate, + ControlType.Sound => ShockerCommandType.Sound, + _ => throw new NotImplementedException(), + }; + } +} diff --git a/LiveControlGateway/Models/LiveShockerPermission.cs b/LiveControlGateway/Models/LiveShockerPermission.cs index 3bceed82..37a2f194 100644 --- a/LiveControlGateway/Models/LiveShockerPermission.cs +++ b/LiveControlGateway/Models/LiveShockerPermission.cs @@ -15,5 +15,5 @@ public sealed class LiveShockerPermission /// /// Perms and limits for the live shocker /// - public required SharePermsAndLimitsLive PermsAndLimits { get; set; } + public required SharePermsAndLimits PermsAndLimits { get; set; } } \ No newline at end of file diff --git a/LiveControlGateway/Program.cs b/LiveControlGateway/Program.cs index 7d40b082..f9fc1640 100644 --- a/LiveControlGateway/Program.cs +++ b/LiveControlGateway/Program.cs @@ -8,7 +8,6 @@ using OpenShock.LiveControlGateway; using OpenShock.LiveControlGateway.LifetimeManager; using OpenShock.LiveControlGateway.Options; -using OpenShock.LiveControlGateway.PubSub; var builder = OpenShockApplication.CreateDefaultBuilder(args); @@ -39,7 +38,6 @@ //services.AddHealthChecks().AddCheck("database"); -builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); diff --git a/LiveControlGateway/PubSub/RedisSubscriberService.cs b/LiveControlGateway/PubSub/RedisSubscriberService.cs deleted file mode 100644 index e785cff1..00000000 --- a/LiveControlGateway/PubSub/RedisSubscriberService.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Text.Json; -using MessagePack; -using OpenShock.Common.Redis.PubSub; -using OpenShock.Common.Services.RedisPubSub; -using OpenShock.Common.Utils; -using OpenShock.LiveControlGateway.LifetimeManager; -using StackExchange.Redis; - -namespace OpenShock.LiveControlGateway.PubSub; - -/// -/// Redis subscription service, which handles listening to pub sub on redis -/// -public sealed class RedisSubscriberService : IHostedService, IAsyncDisposable -{ - private readonly HubLifetimeManager _hubLifetimeManager; - private readonly ISubscriber _subscriber; - - /// - /// DI Constructor - /// - /// - /// - public RedisSubscriberService( - IConnectionMultiplexer connectionMultiplexer, HubLifetimeManager hubLifetimeManager) - { - _hubLifetimeManager = hubLifetimeManager; - _subscriber = connectionMultiplexer.GetSubscriber(); - } - - /// - public async Task StartAsync(CancellationToken cancellationToken) - { - await _subscriber.SubscribeAsync(RedisChannels.DeviceMessage, (_, val) => OsTask.Run(() => DeviceMessage(val))); - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - private async Task DeviceMessage(RedisValue value) - { - if (!value.HasValue) return; - var message = MessagePackSerializer.Deserialize(Convert.FromBase64String(value.ToString())); - switch (message.Type) - { - case DeviceMessageType.Trigger: - await DeviceMessageTrigger(message.DeviceId, message.Payload as DeviceTriggerPayload); - break; - case DeviceMessageType.Toggle: - await DeviceMessageToggle(message.DeviceId, message.Payload as DeviceTogglePayload); - break; - case DeviceMessageType.Control: - await DeviceMessageControl(message.DeviceId, message.Payload as DeviceControlPayload); - break; - case DeviceMessageType.OtaInstall: - await DeviceMessageOtaInstall(message.DeviceId, message.Payload as DeviceOtaInstallPayload); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - private async Task DeviceMessageTrigger(Guid deviceId, DeviceTriggerPayload? payload) - { - if (payload is null) return; - switch (payload.Type) - { - case DeviceTriggerType.DeviceInfoUpdated: - await _hubLifetimeManager.UpdateDevice(deviceId); - break; - case DeviceTriggerType.DeviceEmergencyStop: - await _hubLifetimeManager.EmergencyStop(deviceId); - break; - case DeviceTriggerType.DeviceReboot: - await _hubLifetimeManager.Reboot(deviceId); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - private async Task DeviceMessageToggle(Guid deviceId, DeviceTogglePayload? payload) - { - if (payload is null) return; - switch (payload.Target) - { - case DeviceToggleTarget.CaptivePortal: - await _hubLifetimeManager.ControlCaptive(deviceId, payload.State); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - private async Task DeviceMessageControl(Guid deviceId, DeviceControlPayload? payload) - { - if (payload is null) return; - await Task.WhenAll(payload.Controls.Select(x => _hubLifetimeManager.Control(deviceId, x))); - } - - private async Task DeviceMessageOtaInstall(Guid deviceId, DeviceOtaInstallPayload? payload) - { - if (payload is null) return; - await _hubLifetimeManager.OtaInstall(deviceId, payload.Version); - } - - /// - public async ValueTask DisposeAsync() - { - await _subscriber.UnsubscribeAllAsync(); - GC.SuppressFinalize(this); - } - - /// - /// Destructor, just in case - /// - ~RedisSubscriberService() - { - DisposeAsync().AsTask().Wait(); - } -} \ No newline at end of file From 449fc11a3224df2eb20f26b488b450dfa2eb1dfc Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 16:09:17 +0200 Subject: [PATCH 08/29] Finalize implementation --- API/Realtime/RedisSubscriberService.cs | 67 ++++++++++++++++--- Common/Redis/PubSub/DeviceStatus.cs | 11 +-- .../Services/RedisPubSub/RedisPubService.cs | 22 +++--- 3 files changed, 75 insertions(+), 25 deletions(-) diff --git a/API/Realtime/RedisSubscriberService.cs b/API/Realtime/RedisSubscriberService.cs index 7be9fd6f..5971ba02 100644 --- a/API/Realtime/RedisSubscriberService.cs +++ b/API/Realtime/RedisSubscriberService.cs @@ -1,14 +1,18 @@ -using System.Text.Json; +using Google.Protobuf.WellKnownTypes; +using MessagePack; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OpenShock.Common.Hubs; using OpenShock.Common.Models.WebSocket; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; +using OpenShock.Common.Redis.PubSub; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; using Redis.OM.Contracts; using StackExchange.Redis; +using System.Text.Json; +using static System.Runtime.InteropServices.JavaScript.JSType; namespace OpenShock.API.Realtime; @@ -21,6 +25,7 @@ public sealed class RedisSubscriberService : IHostedService, IAsyncDisposable private readonly IDbContextFactory _dbContextFactory; private readonly IRedisConnectionProvider _redisConnectionProvider; private readonly ISubscriber _subscriber; + private readonly ILogger _logger; /// /// DI Constructor @@ -29,34 +34,78 @@ public sealed class RedisSubscriberService : IHostedService, IAsyncDisposable /// /// /// + /// public RedisSubscriberService( IConnectionMultiplexer connectionMultiplexer, IHubContext hubContext, IDbContextFactory dbContextFactory, - IRedisConnectionProvider redisConnectionProvider) + IRedisConnectionProvider redisConnectionProvider, + ILogger logger + ) { _hubContext = hubContext; _dbContextFactory = dbContextFactory; _redisConnectionProvider = redisConnectionProvider; _subscriber = connectionMultiplexer.GetSubscriber(); + _logger = logger; } /// public async Task StartAsync(CancellationToken cancellationToken) { await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired, (_, message) => OsTask.Run(() => HandleKeyExpired(message))); - await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus, (_, message) => OsTask.Run(() => HandleDeviceStatus(message))); + await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus, ProcessDeviceStatusEvent); } - private async Task HandleDeviceStatus(RedisValue message) + private void ProcessDeviceStatusEvent(RedisChannel _, RedisValue value) { - if (!message.HasValue) return; - var data = JsonSerializer.Deserialize(message.ToString()); - if (data is null) return; + if (!value.HasValue) return; + + DeviceStatus message; + try + { + message = MessagePackSerializer.Deserialize((ReadOnlyMemory)value); + if (message is null) return; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to deserialize redis message"); + return; + } - await LogicDeviceOnlineStatus(data.Id); + OsTask.Run(() => HandleDeviceStatusMessage(message)); } - + + private async Task HandleDeviceStatusMessage(DeviceStatus message) + { + switch (message.Payload) + { + case DeviceBoolStatePayload boolState: + await HandleDeviceBoolState(message.DeviceId, boolState); + break; + default: + _logger.LogError("Got DeviceStatus with unknown payload type: {PayloadType}", message.Payload?.GetType().Name); + break; + } + + } + + private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload state) + { + switch (state.Type) + { + case DeviceBoolStateType.Online: + await LogicDeviceOnlineStatus(deviceId); // TODO: Handle device offline messages too + break; + case DeviceBoolStateType.EStopped: + _logger.LogWarning("This is not yet implemented"); + break; + default: + _logger.LogError("Unknown DeviceBoolStateType: {StateType}", state.Type); + break; + } + } + private async Task HandleKeyExpired(RedisValue message) { if (!message.HasValue) return; diff --git a/Common/Redis/PubSub/DeviceStatus.cs b/Common/Redis/PubSub/DeviceStatus.cs index e76f0da9..1a830a8b 100644 --- a/Common/Redis/PubSub/DeviceStatus.cs +++ b/Common/Redis/PubSub/DeviceStatus.cs @@ -5,12 +5,12 @@ namespace OpenShock.Common.Redis.PubSub; [MessagePackObject] public sealed class DeviceStatus { - [Key(0)] public DeviceStatusType Type { get; init; } + [Key(0)] public required Guid DeviceId { get; init; } [Key(1)] public required IDeviceStatusPayload Payload { get; init; } - public static DeviceStatus Create(DeviceBoolStateType stateType, bool state) => new() + public static DeviceStatus Create(Guid deviceId, DeviceBoolStateType stateType, bool state) => new() { - Type = DeviceStatusType.BoolStateChanged, + DeviceId = deviceId, Payload = new DeviceBoolStatePayload { Type = stateType, @@ -19,11 +19,6 @@ public sealed class DeviceStatus }; } -public enum DeviceStatusType : byte -{ - BoolStateChanged = 0, -} - [Union(0, typeof(DeviceBoolStatePayload))] public interface IDeviceStatusPayload; diff --git a/Common/Services/RedisPubSub/RedisPubService.cs b/Common/Services/RedisPubSub/RedisPubService.cs index 5fe004d7..33350dfc 100644 --- a/Common/Services/RedisPubSub/RedisPubService.cs +++ b/Common/Services/RedisPubSub/RedisPubService.cs @@ -14,40 +14,46 @@ public RedisPubService(IConnectionMultiplexer connectionMultiplexer) _subscriber = connectionMultiplexer.GetSubscriber(); } - private Task Publish(Guid deviceId, T msg) => _subscriber.PublishAsync(RedisChannels.DeviceMessage(deviceId), (RedisValue)new ReadOnlyMemory(MessagePackSerializer.Serialize(msg))); + private Task Publish(RedisChannel channel, T msg) => _subscriber.PublishAsync(channel, (RedisValue)new ReadOnlyMemory(MessagePackSerializer.Serialize(msg))); + private Task PublishMessage(Guid deviceId, DeviceMessage msg) => Publish(RedisChannels.DeviceMessage(deviceId), msg); public Task SendDeviceUpdate(Guid deviceId) { - return Publish(deviceId, DeviceMessage.Create(DeviceTriggerType.DeviceInfoUpdated)); + return PublishMessage(deviceId, DeviceMessage.Create(DeviceTriggerType.DeviceInfoUpdated)); } public Task SendDeviceOnlineStatus(Guid deviceId, bool isOnline) { - return Publish(deviceId, DeviceStatus.Create(DeviceBoolStateType.Online, isOnline)); + return Publish(RedisChannels.DeviceStatus, DeviceStatus.Create(deviceId, DeviceBoolStateType.Online, isOnline)); + } + + public Task SendDeviceEstoppedStatus(Guid deviceId, bool isEstopped) + { + return Publish(RedisChannels.DeviceStatus, DeviceStatus.Create(deviceId, DeviceBoolStateType.EStopped, isEstopped)); } public Task SendDeviceControl(Guid deviceId, List controls) { - return Publish(deviceId, DeviceMessage.Create(new DeviceControlPayload { Controls = controls })); + return PublishMessage(deviceId, DeviceMessage.Create(new DeviceControlPayload { Controls = controls })); } public Task SendDeviceCaptivePortal(Guid deviceId, bool enabled) { - return Publish(deviceId, DeviceMessage.Create(DeviceToggleTarget.CaptivePortal, enabled)); + return PublishMessage(deviceId, DeviceMessage.Create(DeviceToggleTarget.CaptivePortal, enabled)); } public Task SendDeviceEmergencyStop(Guid deviceId) { - return Publish(deviceId, DeviceMessage.Create(DeviceTriggerType.DeviceEmergencyStop)); + return PublishMessage(deviceId, DeviceMessage.Create(DeviceTriggerType.DeviceEmergencyStop)); } public Task SendDeviceOtaInstall(Guid deviceId, SemVersion version) { - return Publish(deviceId, DeviceMessage.Create(new DeviceOtaInstallPayload { Version = version })); + return PublishMessage(deviceId, DeviceMessage.Create(new DeviceOtaInstallPayload { Version = version })); } public Task SendDeviceReboot(Guid deviceId) { - return Publish(deviceId, DeviceMessage.Create(DeviceTriggerType.DeviceReboot)); + return PublishMessage(deviceId, DeviceMessage.Create(DeviceTriggerType.DeviceReboot)); } } \ No newline at end of file From 12a1e13da7d3d8b9db49cff9ec8c15dfd78ba3ff Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 16:17:16 +0200 Subject: [PATCH 09/29] Simplify mapping --- .../LifetimeManager/HubLifetime.cs | 15 +++------------ .../LifetimeManager/HubLifetimeManager.cs | 9 +-------- .../{FlatbuffersMappers.cs => FbsMapper.cs} | 16 +++++++++++++++- 3 files changed, 19 insertions(+), 21 deletions(-) rename LiveControlGateway/Mappers/{FlatbuffersMappers.cs => FbsMapper.cs} (69%) diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 4c7cb72c..30850f2e 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -256,16 +256,7 @@ private async ValueTask DeviceMessageControl(DeviceControlPayload control) return; } - await Control([ - ..control.Controls.Select(cmd => new ShockerCommand - { - Model = FlatbuffersMappers.ToFbsModelType(cmd.Model), - Id = cmd.RfId, - Type = FlatbuffersMappers.ToFbsCommandType(cmd.Type), - Intensity = cmd.Intensity, - Duration = cmd.Duration - }) - ]); + await Control(control.Controls.Select(FbsMapper.ToFbsShockerCommand).ToArray()); } /// @@ -349,9 +340,9 @@ private async Task Update() .Where(kvp => kvp.Value.ActiveUntil < now || kvp.Value.ExclusiveUntil >= now) .Select(kvp => new ShockerCommand { - Model = FlatbuffersMappers.ToFbsModelType(kvp.Value.Model), + Model = FbsMapper.ToFbsModelType(kvp.Value.Model), Id = kvp.Value.RfId, - Type = FlatbuffersMappers.ToFbsCommandType(kvp.Value.LastType), + Type = FbsMapper.ToFbsCommandType(kvp.Value.LastType), Intensity = kvp.Value.LastIntensity, Duration = _commandDuration, }) diff --git a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs index 513db4d5..a11ec351 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs @@ -223,14 +223,7 @@ public async Task RemoveDeviceConnection(IHubController hubController) IReadOnlyList shocks) { if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); - await deviceLifetime.Control([.. shocks.Select(shock => new ShockerCommand - { - Model = FlatbuffersMappers.ToFbsModelType(shock.Model), - Id = shock.RfId, - Type = FlatbuffersMappers.ToFbsCommandType(shock.Type), - Intensity = shock.Intensity, - Duration = shock.Duration - })]); + await deviceLifetime.Control(shocks.Select(FbsMapper.ToFbsShockerCommand).ToArray()); return new Success(); } diff --git a/LiveControlGateway/Mappers/FlatbuffersMappers.cs b/LiveControlGateway/Mappers/FbsMapper.cs similarity index 69% rename from LiveControlGateway/Mappers/FlatbuffersMappers.cs rename to LiveControlGateway/Mappers/FbsMapper.cs index 87cc1f70..6dee9860 100644 --- a/LiveControlGateway/Mappers/FlatbuffersMappers.cs +++ b/LiveControlGateway/Mappers/FbsMapper.cs @@ -1,9 +1,11 @@ using OpenShock.Common.Models; +using OpenShock.Common.Redis.PubSub; +using OpenShock.Serialization.Gateway; using OpenShock.Serialization.Types; namespace OpenShock.LiveControlGateway.Mappers; -public static class FlatbuffersMappers +public static class FbsMapper { public static Serialization.Types.ShockerModelType ToFbsModelType(Common.Models.ShockerModelType type) { @@ -27,4 +29,16 @@ public static ShockerCommandType ToFbsCommandType(ControlType type) _ => throw new NotImplementedException(), }; } + + public static ShockerCommand ToFbsShockerCommand(DeviceControlPayload.ShockerControlInfo control) + { + return new ShockerCommand + { + Model = ToFbsModelType(control.Model), + Id = control.RfId, + Type = ToFbsCommandType(control.Type), + Intensity = control.Intensity, + Duration = control.Duration + }; + } } From c574181d4d755357208a44cc09d7214a881033c2 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 16:22:34 +0200 Subject: [PATCH 10/29] Update HubLifetime.cs --- LiveControlGateway/LifetimeManager/HubLifetime.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 30850f2e..fcb245fa 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -256,7 +256,7 @@ private async ValueTask DeviceMessageControl(DeviceControlPayload control) return; } - await Control(control.Controls.Select(FbsMapper.ToFbsShockerCommand).ToArray()); + await Control([.. control.Controls.Select(FbsMapper.ToFbsShockerCommand)]); } /// From dd9d9674d0a134d3ec6bb601de0663049f4ad2a6 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 16:34:57 +0200 Subject: [PATCH 11/29] More cleanup --- API/Program.cs | 3 +++ API/Realtime/RedisSubscriberService.cs | 7 ++---- Common/Hubs/PublicShareHub.cs | 2 -- Common/Hubs/UserHub.cs | 3 +-- Common/Redis/PubSub/DeviceMessage.cs | 22 +++++++++---------- Common/Services/ControlSender.cs | 4 ++-- .../Services/RedisPubSub/IRedisPubService.cs | 2 +- .../Services/RedisPubSub/RedisPubService.cs | 2 +- .../LifetimeManager/HubLifetime.cs | 2 -- .../LifetimeManager/HubLifetimeManager.cs | 2 +- LiveControlGateway/Mappers/FbsMapper.cs | 2 +- LiveControlGateway/Program.cs | 3 +++ 12 files changed, 26 insertions(+), 28 deletions(-) diff --git a/API/Program.cs b/API/Program.cs index b219c674..3684799f 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -6,10 +6,12 @@ using OpenShock.API.Services.Email; using OpenShock.API.Services.UserService; using OpenShock.Common; +using OpenShock.Common.DeviceControl; using OpenShock.Common.Extensions; using OpenShock.Common.Hubs; using OpenShock.Common.JsonSerialization; using OpenShock.Common.Options; +using OpenShock.Common.Services; using OpenShock.Common.Services.Device; using OpenShock.Common.Services.LCGNodeProvisioner; using OpenShock.Common.Services.Ota; @@ -44,6 +46,7 @@ }); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/API/Realtime/RedisSubscriberService.cs b/API/Realtime/RedisSubscriberService.cs index 5971ba02..34de722b 100644 --- a/API/Realtime/RedisSubscriberService.cs +++ b/API/Realtime/RedisSubscriberService.cs @@ -1,5 +1,4 @@ -using Google.Protobuf.WellKnownTypes; -using MessagePack; +using MessagePack; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OpenShock.Common.Hubs; @@ -11,8 +10,6 @@ using OpenShock.Common.Utils; using Redis.OM.Contracts; using StackExchange.Redis; -using System.Text.Json; -using static System.Runtime.InteropServices.JavaScript.JSType; namespace OpenShock.API.Realtime; @@ -98,7 +95,7 @@ private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload s await LogicDeviceOnlineStatus(deviceId); // TODO: Handle device offline messages too break; case DeviceBoolStateType.EStopped: - _logger.LogWarning("This is not yet implemented"); + _logger.LogWarning("This is not yet implemented"); // TODO: Implement me! break; default: _logger.LogError("Unknown DeviceBoolStateType: {StateType}", state.Type); diff --git a/Common/Hubs/PublicShareHub.cs b/Common/Hubs/PublicShareHub.cs index 598b7c7e..39c158fe 100644 --- a/Common/Hubs/PublicShareHub.cs +++ b/Common/Hubs/PublicShareHub.cs @@ -6,8 +6,6 @@ using OpenShock.Common.Extensions; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Services; -using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; diff --git a/Common/Hubs/UserHub.cs b/Common/Hubs/UserHub.cs index 2505cec1..e8451d77 100644 --- a/Common/Hubs/UserHub.cs +++ b/Common/Hubs/UserHub.cs @@ -9,7 +9,6 @@ using OpenShock.Common.Models.WebSocket; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; -using OpenShock.Common.Services; using OpenShock.Common.Services.RedisPubSub; using Redis.OM; using Redis.OM.Contracts; @@ -144,7 +143,7 @@ public async Task Reboot(Guid deviceId) await _redisPubService.SendDeviceReboot(deviceId); } - + private Guid UserId => _userId ??= Guid.Parse(Context.UserIdentifier!); private Guid? _userId; } \ No newline at end of file diff --git a/Common/Redis/PubSub/DeviceMessage.cs b/Common/Redis/PubSub/DeviceMessage.cs index 65a094ab..08ce5724 100644 --- a/Common/Redis/PubSub/DeviceMessage.cs +++ b/Common/Redis/PubSub/DeviceMessage.cs @@ -64,18 +64,18 @@ public sealed class DeviceTogglePayload : IDeviceMessagePayload [MessagePackObject] public sealed class DeviceControlPayload : IDeviceMessagePayload { - [Key(0)] public required List Controls { get; init; } + [Key(0)] public required List Controls { get; init; } +} - [MessagePackObject] - public sealed class ShockerControlInfo - { - [Key(0)] public ushort RfId { get; init; } - [Key(1)] public byte Intensity { get; init; } - [Key(2)] public ushort Duration { get; init; } - [Key(3)] public ControlType Type { get; init; } - [Key(4)] public ShockerModelType Model { get; init; } - [Key(5)] public bool Exclusive { get; init; } - } +[MessagePackObject] +public sealed class ShockerControlCommand +{ + [Key(0)] public ushort RfId { get; init; } + [Key(1)] public byte Intensity { get; init; } + [Key(2)] public ushort Duration { get; init; } + [Key(3)] public ControlType Type { get; init; } + [Key(4)] public ShockerModelType Model { get; init; } + [Key(5)] public bool Exclusive { get; init; } } [MessagePackObject] diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index a0935a53..dbc939d7 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -100,7 +100,7 @@ public async Task> ControlInternal(IReadOnlyList shocks, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers) { - var messages = new Dictionary>(); + var messages = new Dictionary>(); var logs = new Dictionary>(); var curTime = DateTime.UtcNow; var distinctShocks = shocks.DistinctBy(x => x.Id); @@ -120,7 +120,7 @@ private async Task /// /// - Task SendDeviceControl(Guid deviceId, List controls); + Task SendDeviceControl(Guid deviceId, List controls); /// /// Toggle captive portal diff --git a/Common/Services/RedisPubSub/RedisPubService.cs b/Common/Services/RedisPubSub/RedisPubService.cs index 33350dfc..85cd9973 100644 --- a/Common/Services/RedisPubSub/RedisPubService.cs +++ b/Common/Services/RedisPubSub/RedisPubService.cs @@ -32,7 +32,7 @@ public Task SendDeviceEstoppedStatus(Guid deviceId, bool isEstopped) return Publish(RedisChannels.DeviceStatus, DeviceStatus.Create(deviceId, DeviceBoolStateType.EStopped, isEstopped)); } - public Task SendDeviceControl(Guid deviceId, List controls) + public Task SendDeviceControl(Guid deviceId, List controls) { return PublishMessage(deviceId, DeviceMessage.Create(new DeviceControlPayload { Controls = controls })); } diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index fcb245fa..f4d9b86a 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -1,12 +1,10 @@ using MessagePack; -using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OneOf; using OneOf.Types; using OpenShock.Common.Constants; using OpenShock.Common.Extensions; using OpenShock.Common.Models; -using OpenShock.Common.Models.WebSocket.User; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; using OpenShock.Common.Redis.PubSub; diff --git a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs index a11ec351..d6982173 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs @@ -220,7 +220,7 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// public async Task> Control(Guid device, - IReadOnlyList shocks) + IReadOnlyList shocks) { if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); await deviceLifetime.Control(shocks.Select(FbsMapper.ToFbsShockerCommand).ToArray()); diff --git a/LiveControlGateway/Mappers/FbsMapper.cs b/LiveControlGateway/Mappers/FbsMapper.cs index 6dee9860..3497a772 100644 --- a/LiveControlGateway/Mappers/FbsMapper.cs +++ b/LiveControlGateway/Mappers/FbsMapper.cs @@ -30,7 +30,7 @@ public static ShockerCommandType ToFbsCommandType(ControlType type) }; } - public static ShockerCommand ToFbsShockerCommand(DeviceControlPayload.ShockerControlInfo control) + public static ShockerCommand ToFbsShockerCommand(ShockerControlCommand control) { return new ShockerCommand { diff --git a/LiveControlGateway/Program.cs b/LiveControlGateway/Program.cs index f9fc1640..bfff26b7 100644 --- a/LiveControlGateway/Program.cs +++ b/LiveControlGateway/Program.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.Options; using OpenShock.Common; +using OpenShock.Common.DeviceControl; using OpenShock.Common.Extensions; using OpenShock.Common.JsonSerialization; +using OpenShock.Common.Services; using OpenShock.Common.Services.Device; using OpenShock.Common.Services.Ota; using OpenShock.Common.Swagger; @@ -32,6 +34,7 @@ }); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.AddSwaggerExt(); From e841068dd7501fe6f063b34a761532d860b13b92 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 16:49:08 +0200 Subject: [PATCH 12/29] Fix time checks --- LiveControlGateway/LifetimeManager/HubLifetime.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index f4d9b86a..04882972 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -335,7 +335,7 @@ private async Task Update() { var now = DateTimeOffset.UtcNow; var commandList = _shockerStates - .Where(kvp => kvp.Value.ActiveUntil < now || kvp.Value.ExclusiveUntil >= now) + .Where(kvp => kvp.Value.ActiveUntil > now && kvp.Value.ExclusiveUntil < now) .Select(kvp => new ShockerCommand { Model = FbsMapper.ToFbsModelType(kvp.Value.Model), From 1361f846fae9c7863e3bf03105a1dca0a51b05a7 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 16:53:06 +0200 Subject: [PATCH 13/29] Fix query --- Common/Services/ControlSender.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index dbc939d7..daaaea35 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -30,7 +30,7 @@ public async Task x.Device.OwnerId == sender.UserId || x.UserShares.Any(u => u.SharedWithUserId == sender.UserId)) + .Where(x => x.Device.OwnerId == sender.UserId) .Select(x => new ControlShockerObj { Id = x.Id, From 74b46dc1ab517925ce85faa8959363d2911418ae Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 17:07:17 +0200 Subject: [PATCH 14/29] More cleanup --- API/Realtime/RedisSubscriberService.cs | 34 ++++++++++++-------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/API/Realtime/RedisSubscriberService.cs b/API/Realtime/RedisSubscriberService.cs index 34de722b..8612f91c 100644 --- a/API/Realtime/RedisSubscriberService.cs +++ b/API/Realtime/RedisSubscriberService.cs @@ -50,11 +50,24 @@ ILogger logger /// public async Task StartAsync(CancellationToken cancellationToken) { - await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired, (_, message) => OsTask.Run(() => HandleKeyExpired(message))); - await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus, ProcessDeviceStatusEvent); + await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired, HandleKeyExpired); + await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus, HandleDeviceStatus); } - private void ProcessDeviceStatusEvent(RedisChannel _, RedisValue value) + private void HandleKeyExpired(RedisChannel _, RedisValue message) + { + if (!message.HasValue) return; + if (message.ToString().Split(':', 2) is not [string guid, string name]) return; + + if (!Guid.TryParse(guid, out var id)) return; + + if (typeof(DeviceOnline).FullName == name) + { + OsTask.Run(() => LogicDeviceOnlineStatus(id)); + } + } + + private void HandleDeviceStatus(RedisChannel _, RedisValue value) { if (!value.HasValue) return; @@ -103,21 +116,6 @@ private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload s } } - private async Task HandleKeyExpired(RedisValue message) - { - if (!message.HasValue) return; - var msg = message.ToString().Split(':'); - if (msg.Length < 2) return; - - - if (!Guid.TryParse(msg[1], out var id)) return; - - if (typeof(DeviceOnline).FullName == msg[0]) - { - await LogicDeviceOnlineStatus(id); - } - } - private async Task LogicDeviceOnlineStatus(Guid deviceId) { await using var db = await _dbContextFactory.CreateDbContextAsync(); From ceb0a1d6c0b38388ae40ae3d4b578904a0325e27 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 17:17:35 +0200 Subject: [PATCH 15/29] Update RedisSubscriberService.cs --- API/Realtime/RedisSubscriberService.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/API/Realtime/RedisSubscriberService.cs b/API/Realtime/RedisSubscriberService.cs index 8612f91c..d5c52de4 100644 --- a/API/Realtime/RedisSubscriberService.cs +++ b/API/Realtime/RedisSubscriberService.cs @@ -108,7 +108,7 @@ private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload s await LogicDeviceOnlineStatus(deviceId); // TODO: Handle device offline messages too break; case DeviceBoolStateType.EStopped: - _logger.LogWarning("This is not yet implemented"); // TODO: Implement me! + _logger.LogInformation("EStopped state not implemented yet for DeviceId {DeviceId}", deviceId); break; default: _logger.LogError("Unknown DeviceBoolStateType: {StateType}", state.Type); @@ -153,15 +153,23 @@ await _hubContext.Clients.Users(userIds).DeviceStatus([ } /// - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { - return Task.CompletedTask; + await _subscriber.UnsubscribeAllAsync(); } /// public async ValueTask DisposeAsync() { - await _subscriber.UnsubscribeAllAsync(); + try + { + await _subscriber.UnsubscribeAllAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during Redis unsubscribe in DisposeAsync"); + } + GC.SuppressFinalize(this); } From c77a4e5ad293a99c73e562d4698782ad7615a2b4 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 18:09:00 +0200 Subject: [PATCH 16/29] Try out ChannelMessageQueue's --- API/Realtime/RedisSubscriberService.cs | 101 ++++++++++++------ Common/Redis/QueueHelper.cs | 38 +++++++ .../LifetimeManager/HubLifetime.cs | 41 ++++--- 3 files changed, 130 insertions(+), 50 deletions(-) create mode 100644 Common/Redis/QueueHelper.cs diff --git a/API/Realtime/RedisSubscriberService.cs b/API/Realtime/RedisSubscriberService.cs index d5c52de4..25c3d9f8 100644 --- a/API/Realtime/RedisSubscriberService.cs +++ b/API/Realtime/RedisSubscriberService.cs @@ -24,6 +24,12 @@ public sealed class RedisSubscriberService : IHostedService, IAsyncDisposable private readonly ISubscriber _subscriber; private readonly ILogger _logger; + private ChannelMessageQueue? _expiredQueue; + private ChannelMessageQueue? _deviceStatusQueue; + private CancellationTokenSource? _cts; + private Task? _expiredConsumerTask; + private Task? _deviceConsumerTask; + /// /// DI Constructor /// @@ -50,31 +56,34 @@ ILogger logger /// public async Task StartAsync(CancellationToken cancellationToken) { - await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired, HandleKeyExpired); - await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus, HandleDeviceStatus); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _expiredQueue = await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired); + _deviceStatusQueue = await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus); + + _expiredConsumerTask = QueueHelper.ConsumeQueue(_expiredQueue, HandleKeyExpired, _logger, _cts.Token); + _deviceConsumerTask = QueueHelper.ConsumeQueue(_deviceStatusQueue, HandleDeviceStatus, _logger, _cts.Token); } - private void HandleKeyExpired(RedisChannel _, RedisValue message) + private async Task HandleKeyExpired(RedisValue value, CancellationToken cancellationToken) { - if (!message.HasValue) return; - if (message.ToString().Split(':', 2) is not [string guid, string name]) return; + if (value.ToString().Split(':', 2) is not [string guid, string name]) return; if (!Guid.TryParse(guid, out var id)) return; if (typeof(DeviceOnline).FullName == name) { - OsTask.Run(() => LogicDeviceOnlineStatus(id)); + await LogicDeviceOnlineStatus(id, cancellationToken); } } - private void HandleDeviceStatus(RedisChannel _, RedisValue value) + private async Task HandleDeviceStatus(RedisValue value, CancellationToken cancellationToken) { if (!value.HasValue) return; DeviceStatus message; try { - message = MessagePackSerializer.Deserialize((ReadOnlyMemory)value); + message = MessagePackSerializer.Deserialize((ReadOnlyMemory)value, cancellationToken: cancellationToken); if (message is null) return; } catch (Exception e) @@ -83,15 +92,10 @@ private void HandleDeviceStatus(RedisChannel _, RedisValue value) return; } - OsTask.Run(() => HandleDeviceStatusMessage(message)); - } - - private async Task HandleDeviceStatusMessage(DeviceStatus message) - { switch (message.Payload) { case DeviceBoolStatePayload boolState: - await HandleDeviceBoolState(message.DeviceId, boolState); + await HandleDeviceBoolState(message.DeviceId, boolState, cancellationToken); break; default: _logger.LogError("Got DeviceStatus with unknown payload type: {PayloadType}", message.Payload?.GetType().Name); @@ -100,12 +104,12 @@ private async Task HandleDeviceStatusMessage(DeviceStatus message) } - private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload state) + private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload state, CancellationToken cancellationToken) { switch (state.Type) { case DeviceBoolStateType.Online: - await LogicDeviceOnlineStatus(deviceId); // TODO: Handle device offline messages too + await LogicDeviceOnlineStatus(deviceId, cancellationToken); // TODO: Handle device offline messages too break; case DeviceBoolStateType.EStopped: _logger.LogInformation("EStopped state not implemented yet for DeviceId {DeviceId}", deviceId); @@ -116,15 +120,18 @@ private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload s } } - private async Task LogicDeviceOnlineStatus(Guid deviceId) + private async Task LogicDeviceOnlineStatus(Guid deviceId, CancellationToken cancellationToken) { - await using var db = await _dbContextFactory.CreateDbContextAsync(); + await using var db = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var data = await db.Devices.Where(x => x.Id == deviceId).Select(x => new - { - x.OwnerId, - SharedWith = x.Shockers.SelectMany(y => y.UserShares) - }).FirstOrDefaultAsync(); + var data = await db.Devices + .Where(x => x.Id == deviceId) + .Select(x => new + { + x.OwnerId, + SharedWith = x.Shockers.SelectMany(y => y.UserShares) + }) + .FirstOrDefaultAsync(cancellationToken); if (data is null) return; @@ -132,7 +139,7 @@ private async Task LogicDeviceOnlineStatus(Guid deviceId) .Where(s => s.DeviceId == deviceId) .SelectMany(s => s.UserShares) .Select(u => u.SharedWithUserId) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); var userIds = new List { "local#" + data.OwnerId @@ -142,20 +149,46 @@ private async Task LogicDeviceOnlineStatus(Guid deviceId) var devicesOnlineCollection = _redisConnectionProvider.RedisCollection(false); var deviceOnline = await devicesOnlineCollection.FindByIdAsync(deviceId.ToString()); - await _hubContext.Clients.Users(userIds).DeviceStatus([ - new DeviceOnlineState - { - Device = deviceId, - Online = deviceOnline is not null, - FirmwareVersion = deviceOnline?.FirmwareVersion ?? null - } - ]); + await _hubContext.Clients + .Users(userIds) + .DeviceStatus([ + new DeviceOnlineState + { + Device = deviceId, + Online = deviceOnline is not null, + FirmwareVersion = deviceOnline?.FirmwareVersion ?? null + } + ]); } /// public async Task StopAsync(CancellationToken cancellationToken) { - await _subscriber.UnsubscribeAllAsync(); + // Cancel consumers first, then unsubscribe + try + { + _cts?.Cancel(); + } + catch { /* ignore */ } + + // Wait for loops to finish + if (_expiredConsumerTask is not null) + { + try { await _expiredConsumerTask; } catch { /* ignore */ } + } + if (_deviceConsumerTask is not null) + { + try { await _deviceConsumerTask; } catch { /* ignore */ } + } + + if (_expiredQueue is not null) + { + await _subscriber.UnsubscribeAsync(_expiredQueue.Channel); + } + if (_deviceStatusQueue is not null) + { + await _subscriber.UnsubscribeAsync(_deviceStatusQueue.Channel); + } } /// @@ -163,7 +196,7 @@ public async ValueTask DisposeAsync() { try { - await _subscriber.UnsubscribeAllAsync(); + await StopAsync(default); } catch (Exception ex) { diff --git a/Common/Redis/QueueHelper.cs b/Common/Redis/QueueHelper.cs new file mode 100644 index 00000000..00613b49 --- /dev/null +++ b/Common/Redis/QueueHelper.cs @@ -0,0 +1,38 @@ +using OpenShock.Common.Utils; +using StackExchange.Redis; + +namespace OpenShock.Common.Redis; + +public static class QueueHelper +{ + public static Task ConsumeQueue( + ChannelMessageQueue queue, + Func handler, + ILogger logger, + CancellationToken ct) + { + return OsTask.Run(async () => + { + while (!ct.IsCancellationRequested) + { + var msg = await queue.ReadAsync(ct); + if (!msg.Message.HasValue) continue; + + try + { + await handler(msg.Message, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // graceful shutdown + break; + } + catch (Exception ex) + { + // keep the loop alive on individual message failures + logger.LogError(ex, "Error while handling Redis message from {Channel}", msg.Channel); + } + } + }); + } +} diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 04882972..7856c156 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -57,6 +57,11 @@ public sealed class HubLifetime : IAsyncDisposable private ImmutableArray _liveControlClients = ImmutableArray.Empty; private readonly SemaphoreSlim _liveControlClientsLock = new(1); + private ChannelMessageQueue? _deviceMsgQueue; + private Task? _deviceMsgConsumerTask; + + private Task? _updateLoopTask; + /// /// DI Constructor /// @@ -81,7 +86,7 @@ public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, _redisPubService = redisPubService; _logger = logger; - _waitBetweenTicks = TimeSpan.FromMilliseconds(Math.Floor((float)1000 / tps)); + _waitBetweenTicks = TimeSpan.FromMilliseconds(Math.Floor(1000f / tps)); _commandDuration = (ushort)(_waitBetweenTicks.TotalMilliseconds * 2.5); _subscriber = connectionMultiplexer.GetSubscriber(); @@ -102,7 +107,6 @@ public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, _logger.LogWarning("Client already registered, not sure how this happened, probably a bug"); return null; } - _liveControlClients = _liveControlClients.Add(controller); } @@ -162,21 +166,18 @@ public async Task InitAsync(CancellationToken cancellationToken) return false; } -#pragma warning disable CS4014 - OsTask.Run(UpdateLoop); -#pragma warning restore CS4014 + _updateLoopTask = OsTask.Run(UpdateLoop); - await _subscriber.SubscribeAsync(_deviceMsgChannel, HandleRedisMessage); + _deviceMsgQueue = await _subscriber.SubscribeAsync(_deviceMsgChannel); + _deviceMsgConsumerTask = QueueHelper.ConsumeQueue(_deviceMsgQueue, ConsumeDeviceQueue, _logger, _cancellationSource.Token); _state = HubLifetimeState.Idle; // We are fully setup, we can go back to idle state return true; } - private void HandleRedisMessage(RedisChannel _, RedisValue value) + private async Task ConsumeDeviceQueue(RedisValue value, CancellationToken cancellationToken) { - if (!value.HasValue) return; - DeviceMessage message; try { @@ -185,11 +186,11 @@ private void HandleRedisMessage(RedisChannel _, RedisValue value) } catch (Exception e) { - _logger.LogError(e, "Failed to deserialize redis message"); + _logger.LogError(e, "Failed to deserialize DeviceMessage"); return; } - OsTask.Run(() => DeviceMessage(message)); + await DeviceMessage(message); } private async Task DeviceMessage(DeviceMessage message) @@ -410,10 +411,7 @@ private static DateTimeOffset CalculateActiveUntil(byte tps) => /// /// /// - public ValueTask Control(IList shocks) - { - return HubController.Control(shocks); - } + public ValueTask Control(IList shocks) => HubController.Control(shocks); /// /// Control from redis @@ -511,8 +509,19 @@ public async ValueTask DisposeAsync() if (_disposed) return; _disposed = true; - await _subscriber.UnsubscribeAllAsync(); + await _subscriber.UnsubscribeAsync(_deviceMsgChannel); await _cancellationSource.CancelAsync(); + + // ensure the consumer loop ends + if (_deviceMsgConsumerTask is not null) + { + try { await _deviceMsgConsumerTask; } catch { /* ignore */ } + } + if (_updateLoopTask is not null) + { + try { await _updateLoopTask; } catch { /* ignore */ } + } + await DisposeLiveControlClients(); } } From 58f9689c1ff4ebf62f1ed3882ecc23b1983651dd Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 19:39:42 +0200 Subject: [PATCH 17/29] More cleanup --- Common/DeviceControl/ControlShockerObj.cs | 10 +- Common/Models/WebSocket/User/Control.cs | 2 +- Common/Services/ControlSender.cs | 117 ++++++++++++---------- Common/Services/IControlSender.cs | 4 +- 4 files changed, 71 insertions(+), 62 deletions(-) diff --git a/Common/DeviceControl/ControlShockerObj.cs b/Common/DeviceControl/ControlShockerObj.cs index d13cdbe4..fd94132f 100644 --- a/Common/DeviceControl/ControlShockerObj.cs +++ b/Common/DeviceControl/ControlShockerObj.cs @@ -4,11 +4,11 @@ namespace OpenShock.Common.DeviceControl; public sealed class ControlShockerObj { - public required Guid Id { get; init; } - public required string Name { get; init; } - public required ushort RfId { get; init; } - public required Guid Device { get; init; } - public required ShockerModelType Model { get; init; } + public required Guid ShockerId { get; init; } + public required string ShockerName { get; init; } + public required ushort ShockerRfId { get; init; } + public required Guid DeviceId { get; init; } + public required ShockerModelType ShockerModel { get; init; } public required Guid OwnerId { get; init; } public required bool Paused { get; init; } public required SharePermsAndLimits? PermsAndLimits { get; init; } diff --git a/Common/Models/WebSocket/User/Control.cs b/Common/Models/WebSocket/User/Control.cs index 9400133c..1c669e83 100644 --- a/Common/Models/WebSocket/User/Control.cs +++ b/Common/Models/WebSocket/User/Control.cs @@ -6,7 +6,7 @@ namespace OpenShock.Common.Models.WebSocket.User; // ReSharper disable once ClassNeverInstantiated.Global public sealed class Control { - public required Guid Id { get; set; } + public required Guid ShockerId { get; set; } [EnumDataType(typeof(ControlType))] public required ControlType Type { get; set; } diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index daaaea35..c986f2dc 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -26,18 +26,18 @@ public ControlSender(OpenShockContext db, IRedisPubService publisher) _publisher = publisher; } - public async Task> ControlByUser(IReadOnlyList shocks,ControlLogSender sender, IHubClients hubClients) + public async Task> ControlByUser(IReadOnlyList controls,ControlLogSender sender, IHubClients hubClients) { var queryOwn = _db.Shockers .AsNoTracking() .Where(x => x.Device.OwnerId == sender.UserId) .Select(x => new ControlShockerObj { - Id = x.Id, - Name = x.Name, - RfId = x.RfId, - Device = x.DeviceId, - Model = x.Model, + ShockerId = x.Id, + ShockerName = x.Name, + ShockerRfId = x.RfId, + DeviceId = x.DeviceId, + ShockerModel = x.Model, OwnerId = x.Device.OwnerId, Paused = x.IsPaused, PermsAndLimits = null @@ -48,11 +48,11 @@ public async Task x.SharedWithUserId == sender.UserId) .Select(x => new ControlShockerObj { - Id = x.Shocker.Id, - Name = x.Shocker.Name, - RfId = x.Shocker.RfId, - Device = x.Shocker.DeviceId, - Model = x.Shocker.Model, + ShockerId = x.Shocker.Id, + ShockerName = x.Shocker.Name, + ShockerRfId = x.Shocker.RfId, + DeviceId = x.Shocker.DeviceId, + ShockerModel = x.Shocker.Model, OwnerId = x.Shocker.Device.OwnerId, Paused = x.Shocker.IsPaused || x.IsPaused, PermsAndLimits = new SharePermsAndLimits @@ -68,20 +68,21 @@ public async Task> ControlPublicShare(IReadOnlyList shocks, ControlLogSender sender, IHubClients hubClients, Guid publicShareId) + public async Task> ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId) { var publicShareShockers = await _db.PublicShareShockerMappings + .AsNoTracking() .Where(x => x.PublicShareId == publicShareId && (x.PublicShare.ExpiresAt > DateTime.UtcNow || x.PublicShare.ExpiresAt == null)) .Select(x => new ControlShockerObj { - Id = x.Shocker.Id, - Name = x.Shocker.Name, - RfId = x.Shocker.RfId, - Device = x.Shocker.DeviceId, - Model = x.Shocker.Model, + ShockerId = x.Shocker.Id, + ShockerName = x.Shocker.Name, + ShockerRfId = x.Shocker.RfId, + DeviceId = x.Shocker.DeviceId, + ShockerModel = x.Shocker.Model, OwnerId = x.Shocker.Device.OwnerId, Paused = x.Shocker.IsPaused || x.IsPaused, PermsAndLimits = new SharePermsAndLimits @@ -93,69 +94,77 @@ public async Task> ControlInternal(IReadOnlyList shocks, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers) + private static void Clamp(Control control, SharePermsAndLimits? limits) + { + var durationMax = limits?.Duration ?? HardLimits.MaxControlDuration; + var intensityMax = limits?.Intensity ?? HardLimits.MaxControlIntensity; + + control.Intensity = Math.Clamp(control.Intensity, HardLimits.MinControlIntensity, intensityMax); + control.Duration = Math.Clamp(control.Duration, HardLimits.MinControlDuration, durationMax); + } + + private async Task> ControlInternal(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers) { + // Messages grouped by device var messages = new Dictionary>(); var logs = new Dictionary>(); - var curTime = DateTime.UtcNow; - var distinctShocks = shocks.DistinctBy(x => x.Id); + var now = DateTime.UtcNow; - foreach (var shock in distinctShocks) + foreach (var ( control, shocker ) in controls.Select(x => (control: x, shocker: allowedShockers.FirstOrDefault(s => s.ShockerId == x.ShockerId)))) { - var shockerInfo = allowedShockers.FirstOrDefault(x => x.Id == shock.Id); - - if (shockerInfo is null) return new ShockerNotFoundOrNoAccess(shock.Id); - - if (shockerInfo.Paused) return new ShockerPaused(shock.Id); + if (shocker is null) + return new ShockerNotFoundOrNoAccess(control.ShockerId); + + if (shocker.Paused) + return new ShockerPaused(control.ShockerId); - if (!PermissionUtils.IsAllowed(shock.Type, false, shockerInfo.PermsAndLimits)) return new ShockerNoPermission(shock.Id); - var durationMax = shockerInfo.PermsAndLimits?.Duration ?? HardLimits.MaxControlDuration; - var intensityMax = shockerInfo.PermsAndLimits?.Intensity ?? HardLimits.MaxControlIntensity; + if (!PermissionUtils.IsAllowed(control.Type, false, shocker.PermsAndLimits)) + return new ShockerNoPermission(control.ShockerId); - var intensity = Math.Clamp(shock.Intensity, HardLimits.MinControlIntensity, intensityMax); - var duration = Math.Clamp(shock.Duration, HardLimits.MinControlDuration, durationMax); + Clamp(control, shocker.PermsAndLimits); - messages.AppendValue(shockerInfo.Device, new ShockerControlCommand + messages.AppendValue(shocker.DeviceId, new ShockerControlCommand { - RfId = shockerInfo.RfId, - Duration = duration, - Intensity = intensity, - Type = shock.Type, - Model = shockerInfo.Model, - Exclusive = shock.Exclusive + RfId = shocker.ShockerRfId, + Duration = control.Duration, + Intensity = control.Intensity, + Type = control.Type, + Model = shocker.ShockerModel, + Exclusive = control.Exclusive }); - logs.AppendValue(shockerInfo.OwnerId, new ControlLog + logs.AppendValue(shocker.OwnerId, new ControlLog { Shocker = new BasicShockerInfo { - Id = shockerInfo.Id, - Name = shockerInfo.Name + Id = shocker.ShockerId, + Name = shocker.ShockerName }, - Type = shock.Type, - Intensity = intensity, - Duration = duration, - ExecutedAt = curTime + Type = control.Type, + Intensity = control.Intensity, + Duration = control.Duration, + ExecutedAt = now }); _db.ShockerControlLogs.Add(new ShockerControlLog { Id = Guid.CreateVersion7(), - ShockerId = shockerInfo.Id, + ShockerId = shocker.ShockerId, ControlledByUserId = sender.UserId == Guid.Empty ? null : sender.UserId, - Intensity = intensity, - Duration = duration, - Type = shock.Type, + Intensity = control.Intensity, + Duration = control.Duration, + Type = control.Type, CustomName = sender.CustomName, - CreatedAt = curTime + CreatedAt = now }); } - // Save all db cahnges before continuing + // Save all db changes before continuing await _db.SaveChangesAsync(); // Then send all network events diff --git a/Common/Services/IControlSender.cs b/Common/Services/IControlSender.cs index 5a284a59..5ebd5af8 100644 --- a/Common/Services/IControlSender.cs +++ b/Common/Services/IControlSender.cs @@ -9,9 +9,9 @@ namespace OpenShock.Common.DeviceControl; public interface IControlSender { - public Task> ControlByUser(IReadOnlyList shocks, ControlLogSender sender, IHubClients hubClients); + public Task> ControlByUser(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients); - public Task> ControlPublicShare(IReadOnlyList shocks, ControlLogSender sender, IHubClients hubClients, Guid publicShareId); + public Task> ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId); } public readonly record struct ShockerNotFoundOrNoAccess(Guid Value); From 91ebce99b30d051d63e3646448ab2fd71ee3e917 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 19:40:59 +0200 Subject: [PATCH 18/29] cleaner --- Common/Services/ControlSender.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index c986f2dc..a6821f60 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -116,8 +116,9 @@ private async Task>(); var now = DateTime.UtcNow; - foreach (var ( control, shocker ) in controls.Select(x => (control: x, shocker: allowedShockers.FirstOrDefault(s => s.ShockerId == x.ShockerId)))) + foreach (var control in controls) { + var shocker = allowedShockers.FirstOrDefault(s => s.ShockerId == control.ShockerId); if (shocker is null) return new ShockerNotFoundOrNoAccess(control.ShockerId); From 2f8f2b85f6ba8180474b28c7eed41676eaaee25c Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 19:53:30 +0200 Subject: [PATCH 19/29] oops --- API/Controller/Shockers/GetShockerLogs.cs | 4 ++-- API/Controller/Shockers/SendControl.cs | 2 +- Common/Hubs/PublicShareHub.cs | 4 ++-- Common/Hubs/UserHub.cs | 2 +- Common/Models/ControlLogSender.cs | 2 +- Common/Services/ControlSender.cs | 12 +++++++----- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/API/Controller/Shockers/GetShockerLogs.cs b/API/Controller/Shockers/GetShockerLogs.cs index a35e9203..cd656b14 100644 --- a/API/Controller/Shockers/GetShockerLogs.cs +++ b/API/Controller/Shockers/GetShockerLogs.cs @@ -49,14 +49,14 @@ [FromQuery] [Range(1, 500)] uint limit = 100) ControlledBy = x.ControlledByUser == null ? new ControlLogSenderLight { - UserId = Guid.Empty, + Id = Guid.Empty, Name = "Guest", Image = GravatarUtils.GuestImageUrl, CustomName = x.CustomName } : new ControlLogSenderLight { - UserId = x.ControlledByUser.Id, + Id = x.ControlledByUser.Id, Name = x.ControlledByUser.Name, Image = x.ControlledByUser.GetImageUrl(), CustomName = x.CustomName diff --git a/API/Controller/Shockers/SendControl.cs b/API/Controller/Shockers/SendControl.cs index a8ce8d28..892fc59c 100644 --- a/API/Controller/Shockers/SendControl.cs +++ b/API/Controller/Shockers/SendControl.cs @@ -32,7 +32,7 @@ public async Task SendControl( { var sender = new ControlLogSender { - UserId = CurrentUser.Id, + Id = CurrentUser.Id, Name = CurrentUser.Name, Image = CurrentUser.GetImageUrl(), ConnectionId = HttpContext.Connection.Id, diff --git a/Common/Hubs/PublicShareHub.cs b/Common/Hubs/PublicShareHub.cs index 39c158fe..b33f2ec4 100644 --- a/Common/Hubs/PublicShareHub.cs +++ b/Common/Hubs/PublicShareHub.cs @@ -95,7 +95,7 @@ public override async Task OnConnectedAsync() CachedControlLogSender = user is null ? new ControlLogSender { - UserId = Guid.Empty, + Id = Guid.Empty, Name = "Guest", Image = GravatarUtils.GuestImageUrl, ConnectionId = Context.ConnectionId, @@ -104,7 +104,7 @@ public override async Task OnConnectedAsync() } : new ControlLogSender { - UserId = user.Id, + Id = user.Id, Image = user.Image, Name = user.Name, ConnectionId = Context.ConnectionId, diff --git a/Common/Hubs/UserHub.cs b/Common/Hubs/UserHub.cs index e8451d77..884368c8 100644 --- a/Common/Hubs/UserHub.cs +++ b/Common/Hubs/UserHub.cs @@ -85,7 +85,7 @@ public async Task ControlV2(IReadOnlyList shocks, var sender = await _db.Users.Where(x => x.Id == UserId).Select(x => new ControlLogSender { - UserId = x.Id, + Id = x.Id, Name = x.Name, Image = x.GetImageUrl(), ConnectionId = Context.ConnectionId, diff --git a/Common/Models/ControlLogSender.cs b/Common/Models/ControlLogSender.cs index 8eae6725..0785f7d8 100644 --- a/Common/Models/ControlLogSender.cs +++ b/Common/Models/ControlLogSender.cs @@ -2,7 +2,7 @@ public class ControlLogSenderLight { - public required Guid UserId { get; set; } + public required Guid Id { get; set; } public required string Name { get; set; } public required Uri Image { get; set; } public required string? CustomName { get; set; } diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index a6821f60..f53d4152 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -30,7 +30,7 @@ public async Task x.Device.OwnerId == sender.UserId) + .Where(x => x.Device.OwnerId == sender.Id) .Select(x => new ControlShockerObj { ShockerId = x.Id, @@ -45,7 +45,7 @@ public async Task x.SharedWithUserId == sender.UserId) + .Where(x => x.SharedWithUserId == sender.Id) .Select(x => new ControlShockerObj { ShockerId = x.Shocker.Id, @@ -116,9 +116,11 @@ private async Task>(); var now = DateTime.UtcNow; - foreach (var control in controls) + foreach (var (control, shocker) in controls + .Select(c => (Control: c, Shocker: allowedShockers.FirstOrDefault(s => s.ShockerId == c.ShockerId))) + .GroupBy(x => (x.Control.ShockerId, x.Shocker?.ShockerRfId)) + .Select(x => x.Last())) { - var shocker = allowedShockers.FirstOrDefault(s => s.ShockerId == control.ShockerId); if (shocker is null) return new ShockerNotFoundOrNoAccess(control.ShockerId); @@ -156,7 +158,7 @@ private async Task Date: Mon, 1 Sep 2025 19:54:41 +0200 Subject: [PATCH 20/29] Update LiveControlGateway/LifetimeManager/HubLifetime.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- LiveControlGateway/LifetimeManager/HubLifetime.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 7856c156..151ad515 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -86,7 +86,7 @@ public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, _redisPubService = redisPubService; _logger = logger; - _waitBetweenTicks = TimeSpan.FromMilliseconds(Math.Floor(1000f / tps)); + _waitBetweenTicks = TimeSpan.FromMilliseconds(1000 / tps); _commandDuration = (ushort)(_waitBetweenTicks.TotalMilliseconds * 2.5); _subscriber = connectionMultiplexer.GetSubscriber(); From 56109490caf4372d017a1dce025aff04d45d3273 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Mon, 1 Sep 2025 19:57:01 +0200 Subject: [PATCH 21/29] Update Common/Services/RedisPubSub/RedisChannels.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Common/Services/RedisPubSub/RedisChannels.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Common/Services/RedisPubSub/RedisChannels.cs b/Common/Services/RedisPubSub/RedisChannels.cs index 789a4fa9..63f540e2 100644 --- a/Common/Services/RedisPubSub/RedisChannels.cs +++ b/Common/Services/RedisPubSub/RedisChannels.cs @@ -6,7 +6,7 @@ public static class RedisChannels { public static readonly RedisChannel KeyEventExpired = new("__keyevent@0__:expired", RedisChannel.PatternMode.Literal); - public static RedisChannel DeviceMessage(Guid deviceId) => new($"device-msg:{deviceId}", RedisChannel.PatternMode.Pattern); + public static RedisChannel DeviceMessage(Guid deviceId) => new($"device-msg:{deviceId}", RedisChannel.PatternMode.Literal); public static readonly RedisChannel DeviceStatus = new("device-status", RedisChannel.PatternMode.Literal); } \ No newline at end of file From 4b1d9c476fd93bd700f38815af72e03d107c959d Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 20:14:04 +0200 Subject: [PATCH 22/29] Revert --- API/Realtime/RedisSubscriberService.cs | 104 +++++++++---------------- 1 file changed, 35 insertions(+), 69 deletions(-) diff --git a/API/Realtime/RedisSubscriberService.cs b/API/Realtime/RedisSubscriberService.cs index 25c3d9f8..581117a1 100644 --- a/API/Realtime/RedisSubscriberService.cs +++ b/API/Realtime/RedisSubscriberService.cs @@ -24,12 +24,6 @@ public sealed class RedisSubscriberService : IHostedService, IAsyncDisposable private readonly ISubscriber _subscriber; private readonly ILogger _logger; - private ChannelMessageQueue? _expiredQueue; - private ChannelMessageQueue? _deviceStatusQueue; - private CancellationTokenSource? _cts; - private Task? _expiredConsumerTask; - private Task? _deviceConsumerTask; - /// /// DI Constructor /// @@ -56,35 +50,31 @@ ILogger logger /// public async Task StartAsync(CancellationToken cancellationToken) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _expiredQueue = await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired); - _deviceStatusQueue = await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus); - - _expiredConsumerTask = QueueHelper.ConsumeQueue(_expiredQueue, HandleKeyExpired, _logger, _cts.Token); - _deviceConsumerTask = QueueHelper.ConsumeQueue(_deviceStatusQueue, HandleDeviceStatus, _logger, _cts.Token); + await _subscriber.SubscribeAsync(RedisChannels.KeyEventExpired, HandleKeyExpired); + await _subscriber.SubscribeAsync(RedisChannels.DeviceStatus, HandleDeviceStatus); } - private async Task HandleKeyExpired(RedisValue value, CancellationToken cancellationToken) + private void HandleKeyExpired(RedisChannel _, RedisValue message) { - if (value.ToString().Split(':', 2) is not [string guid, string name]) return; + if (!message.HasValue) return; + if (message.ToString().Split(':', 2) is not [{ } guid, { } name]) return; if (!Guid.TryParse(guid, out var id)) return; if (typeof(DeviceOnline).FullName == name) { - await LogicDeviceOnlineStatus(id, cancellationToken); + OsTask.Run(() => LogicDeviceOnlineStatus(id)); } } - private async Task HandleDeviceStatus(RedisValue value, CancellationToken cancellationToken) + private void HandleDeviceStatus(RedisChannel _, RedisValue value) { if (!value.HasValue) return; DeviceStatus message; try { - message = MessagePackSerializer.Deserialize((ReadOnlyMemory)value, cancellationToken: cancellationToken); - if (message is null) return; + message = MessagePackSerializer.Deserialize(value); } catch (Exception e) { @@ -92,24 +82,29 @@ private async Task HandleDeviceStatus(RedisValue value, CancellationToken cancel return; } + OsTask.Run(() => HandleDeviceStatusMessage(message)); + } + + private async Task HandleDeviceStatusMessage(DeviceStatus message) + { switch (message.Payload) { case DeviceBoolStatePayload boolState: - await HandleDeviceBoolState(message.DeviceId, boolState, cancellationToken); + await HandleDeviceBoolState(message.DeviceId, boolState); break; default: - _logger.LogError("Got DeviceStatus with unknown payload type: {PayloadType}", message.Payload?.GetType().Name); + _logger.LogError("Got DeviceStatus with unknown payload type: {PayloadType}", message.Payload.GetType().Name); break; } } - private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload state, CancellationToken cancellationToken) + private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload state) { switch (state.Type) { case DeviceBoolStateType.Online: - await LogicDeviceOnlineStatus(deviceId, cancellationToken); // TODO: Handle device offline messages too + await LogicDeviceOnlineStatus(deviceId); // TODO: Handle device offline messages too break; case DeviceBoolStateType.EStopped: _logger.LogInformation("EStopped state not implemented yet for DeviceId {DeviceId}", deviceId); @@ -120,18 +115,15 @@ private async Task HandleDeviceBoolState(Guid deviceId, DeviceBoolStatePayload s } } - private async Task LogicDeviceOnlineStatus(Guid deviceId, CancellationToken cancellationToken) + private async Task LogicDeviceOnlineStatus(Guid deviceId) { - await using var db = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + await using var db = await _dbContextFactory.CreateDbContextAsync(); - var data = await db.Devices - .Where(x => x.Id == deviceId) - .Select(x => new - { - x.OwnerId, - SharedWith = x.Shockers.SelectMany(y => y.UserShares) - }) - .FirstOrDefaultAsync(cancellationToken); + var data = await db.Devices.Where(x => x.Id == deviceId).Select(x => new + { + x.OwnerId, + SharedWith = x.Shockers.SelectMany(y => y.UserShares) + }).FirstOrDefaultAsync(); if (data is null) return; @@ -139,7 +131,7 @@ private async Task LogicDeviceOnlineStatus(Guid deviceId, CancellationToken canc .Where(s => s.DeviceId == deviceId) .SelectMany(s => s.UserShares) .Select(u => u.SharedWithUserId) - .ToArrayAsync(cancellationToken); + .ToArrayAsync(); var userIds = new List { "local#" + data.OwnerId @@ -149,46 +141,20 @@ private async Task LogicDeviceOnlineStatus(Guid deviceId, CancellationToken canc var devicesOnlineCollection = _redisConnectionProvider.RedisCollection(false); var deviceOnline = await devicesOnlineCollection.FindByIdAsync(deviceId.ToString()); - await _hubContext.Clients - .Users(userIds) - .DeviceStatus([ - new DeviceOnlineState - { - Device = deviceId, - Online = deviceOnline is not null, - FirmwareVersion = deviceOnline?.FirmwareVersion ?? null - } - ]); + await _hubContext.Clients.Users(userIds).DeviceStatus([ + new DeviceOnlineState + { + Device = deviceId, + Online = deviceOnline is not null, + FirmwareVersion = deviceOnline?.FirmwareVersion ?? null + } + ]); } /// public async Task StopAsync(CancellationToken cancellationToken) { - // Cancel consumers first, then unsubscribe - try - { - _cts?.Cancel(); - } - catch { /* ignore */ } - - // Wait for loops to finish - if (_expiredConsumerTask is not null) - { - try { await _expiredConsumerTask; } catch { /* ignore */ } - } - if (_deviceConsumerTask is not null) - { - try { await _deviceConsumerTask; } catch { /* ignore */ } - } - - if (_expiredQueue is not null) - { - await _subscriber.UnsubscribeAsync(_expiredQueue.Channel); - } - if (_deviceStatusQueue is not null) - { - await _subscriber.UnsubscribeAsync(_deviceStatusQueue.Channel); - } + await _subscriber.UnsubscribeAllAsync(); } /// @@ -196,7 +162,7 @@ public async ValueTask DisposeAsync() { try { - await StopAsync(default); + await _subscriber.UnsubscribeAllAsync(); } catch (Exception ex) { From c3372a67809e1eb62bb965fba7fd7db3f3db3e12 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 20:16:58 +0200 Subject: [PATCH 23/29] Revert --- Common/Models/WebSocket/User/Control.cs | 2 +- Common/Services/ControlSender.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Common/Models/WebSocket/User/Control.cs b/Common/Models/WebSocket/User/Control.cs index 1c669e83..9400133c 100644 --- a/Common/Models/WebSocket/User/Control.cs +++ b/Common/Models/WebSocket/User/Control.cs @@ -6,7 +6,7 @@ namespace OpenShock.Common.Models.WebSocket.User; // ReSharper disable once ClassNeverInstantiated.Global public sealed class Control { - public required Guid ShockerId { get; set; } + public required Guid Id { get; set; } [EnumDataType(typeof(ControlType))] public required ControlType Type { get; set; } diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index f53d4152..061ae34d 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -117,18 +117,18 @@ private async Task (Control: c, Shocker: allowedShockers.FirstOrDefault(s => s.ShockerId == c.ShockerId))) - .GroupBy(x => (x.Control.ShockerId, x.Shocker?.ShockerRfId)) + .Select(c => (Control: c, Shocker: allowedShockers.FirstOrDefault(s => s.ShockerId == c.Id))) + .GroupBy(x => (ShockerId: x.Control.Id, x.Shocker?.ShockerRfId)) .Select(x => x.Last())) { if (shocker is null) - return new ShockerNotFoundOrNoAccess(control.ShockerId); + return new ShockerNotFoundOrNoAccess(control.Id); if (shocker.Paused) - return new ShockerPaused(control.ShockerId); + return new ShockerPaused(control.Id); if (!PermissionUtils.IsAllowed(control.Type, false, shocker.PermsAndLimits)) - return new ShockerNoPermission(control.ShockerId); + return new ShockerNoPermission(control.Id); Clamp(control, shocker.PermsAndLimits); From 6c0b08f69a0f1f5b77d583498eb991a5162e1910 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 20:44:32 +0200 Subject: [PATCH 24/29] Revert --- Common/Redis/PubSub/DeviceMessage.cs | 19 ++++++++-------- Common/Redis/PubSub/DeviceStatus.cs | 4 ++-- Common/Services/ControlSender.cs | 1 + .../LifetimeManager/HubLifetime.cs | 22 ++++++++++++++++--- .../LifetimeManager/HubLifetimeManager.cs | 5 ++--- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/Common/Redis/PubSub/DeviceMessage.cs b/Common/Redis/PubSub/DeviceMessage.cs index 08ce5724..b8e0879a 100644 --- a/Common/Redis/PubSub/DeviceMessage.cs +++ b/Common/Redis/PubSub/DeviceMessage.cs @@ -46,7 +46,7 @@ public enum DeviceTriggerType : byte [MessagePackObject] public sealed class DeviceTriggerPayload : IDeviceMessagePayload { - [Key(0)] public DeviceTriggerType Type { get; init; } + [Key(0)] public required DeviceTriggerType Type { get; init; } } public enum DeviceToggleTarget : byte @@ -57,8 +57,8 @@ public enum DeviceToggleTarget : byte [MessagePackObject] public sealed class DeviceTogglePayload : IDeviceMessagePayload { - [Key(0)] public DeviceToggleTarget Target { get; init; } - [Key(1)] public bool State { get; init; } + [Key(0)] public required DeviceToggleTarget Target { get; init; } + [Key(1)] public required bool State { get; init; } } [MessagePackObject] @@ -70,12 +70,13 @@ public sealed class DeviceControlPayload : IDeviceMessagePayload [MessagePackObject] public sealed class ShockerControlCommand { - [Key(0)] public ushort RfId { get; init; } - [Key(1)] public byte Intensity { get; init; } - [Key(2)] public ushort Duration { get; init; } - [Key(3)] public ControlType Type { get; init; } - [Key(4)] public ShockerModelType Model { get; init; } - [Key(5)] public bool Exclusive { get; init; } + [Key(0)] public required Guid ShockerId { get; init; } + [Key(1)] public required ushort RfId { get; init; } + [Key(2)] public required byte Intensity { get; init; } + [Key(3)] public required ushort Duration { get; init; } + [Key(4)] public required ControlType Type { get; init; } + [Key(5)] public required ShockerModelType Model { get; init; } + [Key(6)] public required bool Exclusive { get; init; } } [MessagePackObject] diff --git a/Common/Redis/PubSub/DeviceStatus.cs b/Common/Redis/PubSub/DeviceStatus.cs index 1a830a8b..bbd9c597 100644 --- a/Common/Redis/PubSub/DeviceStatus.cs +++ b/Common/Redis/PubSub/DeviceStatus.cs @@ -31,6 +31,6 @@ public enum DeviceBoolStateType : byte [MessagePackObject] public sealed class DeviceBoolStatePayload : IDeviceStatusPayload { - [Key(0)] public DeviceBoolStateType Type { get; init; } - [Key(1)] public bool State { get; init; } + [Key(0)] public required DeviceBoolStateType Type { get; init; } + [Key(1)] public required bool State { get; init; } } \ No newline at end of file diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index 061ae34d..ce00a36e 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -134,6 +134,7 @@ private async Task @@ -409,9 +409,25 @@ private static DateTimeOffset CalculateActiveUntil(byte tps) => /// /// Control from redis, aka a regular command /// - /// + /// /// - public ValueTask Control(IList shocks) => HubController.Control(shocks); + public ValueTask Control(IReadOnlyList commands) + { + var shocksTransformed = new List(commands.Count); + + foreach (var command in commands) + { + if (!_shockerStates.TryGetValue(command.ShockerId, out var state)) continue; + + state.ExclusiveUntil = command.Exclusive && command.Type != ControlType.Stop + ? DateTimeOffset.UtcNow.AddMilliseconds(command.Duration) + : DateTimeOffset.MinValue; + + shocksTransformed.Add(FbsMapper.ToFbsShockerCommand(command)); + } + + return HubController.Control(shocksTransformed); + } /// /// Control from redis diff --git a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs index d6982173..9ca52048 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs @@ -219,11 +219,10 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// /// - public async Task> Control(Guid device, - IReadOnlyList shocks) + public async Task> Control(Guid device, IReadOnlyList shocks) { if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); - await deviceLifetime.Control(shocks.Select(FbsMapper.ToFbsShockerCommand).ToArray()); + await deviceLifetime.Control(shocks); return new Success(); } From ee9ddff598ee0621d45e259ce272e9cf08f75d64 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 21:09:38 +0200 Subject: [PATCH 25/29] Revert some logic --- Common/Services/ControlSender.cs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index ce00a36e..92177458 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -111,17 +111,16 @@ private static void Clamp(Control control, SharePermsAndLimits? limits) private async Task> ControlInternal(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers) { - // Messages grouped by device - var messages = new Dictionary>(); - var logs = new Dictionary>(); + var shockersById = allowedShockers.ToDictionary(s => s.ShockerId, s => s); + var now = DateTime.UtcNow; + + var messagesByDevice = new Dictionary>(); + var logsByOwner = new Dictionary>(); - foreach (var (control, shocker) in controls - .Select(c => (Control: c, Shocker: allowedShockers.FirstOrDefault(s => s.ShockerId == c.Id))) - .GroupBy(x => (ShockerId: x.Control.Id, x.Shocker?.ShockerRfId)) - .Select(x => x.Last())) + foreach (var control in controls.DistinctBy(x => x.Id)) { - if (shocker is null) + if (!shockersById.TryGetValue(control.Id, out var shocker)) return new ShockerNotFoundOrNoAccess(control.Id); if (shocker.Paused) @@ -132,7 +131,7 @@ private async Task _publisher.SendDeviceControl(kvp.Key, kvp.Value)), - ..logs.Select(x => hubClients.User(x.Key.ToString()).Log(sender, x.Value)) + ..messagesByDevice.Select(kvp => _publisher.SendDeviceControl(kvp.Key, kvp.Value)), + ..logsByOwner.Select(x => hubClients.User(x.Key.ToString()).Log(sender, x.Value)) ]); return new Success(); From d66a44207226cc24d1b3a874af4ddf2e69f6376e Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 21:14:25 +0200 Subject: [PATCH 26/29] Update HubLifetime.cs --- LiveControlGateway/LifetimeManager/HubLifetime.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index ba38bcf2..cf442691 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -86,7 +86,7 @@ public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, _redisPubService = redisPubService; _logger = logger; - _waitBetweenTicks = TimeSpan.FromMilliseconds(1000 / tps); + _waitBetweenTicks = TimeSpan.FromMilliseconds(1000.0 / tps); _commandDuration = (ushort)(_waitBetweenTicks.TotalMilliseconds * 2.5); _subscriber = connectionMultiplexer.GetSubscriber(); @@ -404,7 +404,7 @@ public OneOf ReceiveFrame(Guid shocker, Con } private static DateTimeOffset CalculateActiveUntil(byte tps) => - DateTimeOffset.UtcNow.AddMilliseconds(Math.Max(1000 / (float)tps * 2.5, 250)); + DateTimeOffset.UtcNow.AddMilliseconds(Math.Max(1000.0 / tps * 2.5, 250.0)); /// /// Control from redis, aka a regular command From 053c9264e5f32b7e2c57c32867b988bd0c53071f Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 21:19:49 +0200 Subject: [PATCH 27/29] Less allocs --- .../LifetimeManager/HubLifetime.cs | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index cf442691..07857d7f 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -181,8 +181,7 @@ private async Task ConsumeDeviceQueue(RedisValue value, CancellationToken cancel DeviceMessage message; try { - message = MessagePackSerializer.Deserialize((ReadOnlyMemory)value); - if (message is null) return; + message = MessagePackSerializer.Deserialize((ReadOnlyMemory)value, cancellationToken: cancellationToken); } catch (Exception e) { @@ -335,19 +334,23 @@ private async Task UpdateLoop() private async Task Update() { var now = DateTimeOffset.UtcNow; - var commandList = _shockerStates - .Where(kvp => kvp.Value.ActiveUntil > now && kvp.Value.ExclusiveUntil < now) - .Select(kvp => new ShockerCommand - { - Model = FbsMapper.ToFbsModelType(kvp.Value.Model), - Id = kvp.Value.RfId, - Type = FbsMapper.ToFbsCommandType(kvp.Value.LastType), - Intensity = kvp.Value.LastIntensity, - Duration = _commandDuration, - }) - .ToArray(); - - if (commandList.Length == 0) return; + var commandList = new List(_shockerStates.Count); + + commandList.AddRange( + _shockerStates + .Values + .Where(x => x.ActiveUntil > now && x.ExclusiveUntil < now) + .Select(x => new ShockerCommand + { + Model = FbsMapper.ToFbsModelType(x.Model), + Id = x.RfId, + Type = FbsMapper.ToFbsCommandType(x.LastType), + Intensity = x.LastIntensity, + Duration = _commandDuration, + }) + ); + + if (commandList.Count == 0) return; await HubController.Control(commandList); } From 288466e61e72f9bd1f9ab8833debb6af06d0becb Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 21:21:05 +0200 Subject: [PATCH 28/29] Update HubLifetime.cs --- .../LifetimeManager/HubLifetime.cs | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 07857d7f..2f69cfd2 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -335,20 +335,18 @@ private async Task Update() { var now = DateTimeOffset.UtcNow; var commandList = new List(_shockerStates.Count); - - commandList.AddRange( - _shockerStates - .Values - .Where(x => x.ActiveUntil > now && x.ExclusiveUntil < now) - .Select(x => new ShockerCommand - { - Model = FbsMapper.ToFbsModelType(x.Model), - Id = x.RfId, - Type = FbsMapper.ToFbsCommandType(x.LastType), - Intensity = x.LastIntensity, - Duration = _commandDuration, - }) - ); + + foreach (var x in _shockerStates.Values.Where(x => x.ActiveUntil > now && x.ExclusiveUntil < now)) + { + commandList.Add(new ShockerCommand + { + Model = FbsMapper.ToFbsModelType(x.Model), + Id = x.RfId, + Type = FbsMapper.ToFbsCommandType(x.LastType), + Intensity = x.LastIntensity, + Duration = _commandDuration, + }); + } if (commandList.Count == 0) return; From b0a30d8977816c96634455ed886452cc406d285b Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 1 Sep 2025 21:23:09 +0200 Subject: [PATCH 29/29] Update HubLifetime.cs --- LiveControlGateway/LifetimeManager/HubLifetime.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 2f69cfd2..06223d90 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -336,14 +336,16 @@ private async Task Update() var now = DateTimeOffset.UtcNow; var commandList = new List(_shockerStates.Count); - foreach (var x in _shockerStates.Values.Where(x => x.ActiveUntil > now && x.ExclusiveUntil < now)) + foreach (var state in _shockerStates.Values) { + if (state.ActiveUntil < now || state.ExclusiveUntil >= now) continue; + commandList.Add(new ShockerCommand { - Model = FbsMapper.ToFbsModelType(x.Model), - Id = x.RfId, - Type = FbsMapper.ToFbsCommandType(x.LastType), - Intensity = x.LastIntensity, + Model = FbsMapper.ToFbsModelType(state.Model), + Id = state.RfId, + Type = FbsMapper.ToFbsCommandType(state.LastType), + Intensity = state.LastIntensity, Duration = _commandDuration, }); }