Skip to content

Commit 9bf6c36

Browse files
HandyS11claude
andauthored
Smart-device refactor: RustPlusApi beta.3 + device-agnostic trigger event (#22)
* feat(connections): bump RustPlusApi to beta.3 and add SmartDeviceTriggeredEvent Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(connections): rename SmartSwitchTriggered seam to device-generic SmartDeviceTriggered (beta.3) * refactor(connections): supervisor publishes SmartDeviceTriggeredEvent (trigger carries state, prime re-reads) * feat(switches): consume SmartDeviceTriggeredEvent (filtered by ExistsAsync) for live updates * style: jb cleanupcode reformat (smart-device refactor) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4f89eef commit 9bf6c36

12 files changed

Lines changed: 228 additions & 62 deletions

File tree

Directory.Packages.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
1212
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
1313
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
14-
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.2" />
15-
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.2" />
14+
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.3" />
15+
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.3" />
1616
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
1717
<PackageVersion Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
1818
<!-- Pinned to override the transitive 2.1.11 pulled by Microsoft.EntityFrameworkCore.Sqlite,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>A managed smart device (switch/alarm/…) changed state in-game, as reported by the socket broadcast.</summary>
4+
/// <param name="GuildId">The owning Discord guild snowflake.</param>
5+
/// <param name="ServerId">The local Rust server id.</param>
6+
/// <param name="EntityId">The in-game entity id (the discriminant — features filter to the ids they manage).</param>
7+
/// <param name="IsActive">The new on/off state, carried directly on the broadcast (no re-read).</param>
8+
public sealed record SmartDeviceTriggeredEvent(ulong GuildId, Guid ServerId, ulong EntityId, bool IsActive);

src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ internal interface IRustServerConnection : IAsyncDisposable
4747
/// <returns>True if the promotion succeeded; false on failure/timeout.</returns>
4848
Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken);
4949

50-
/// <summary>Reads a smart switch's on/off state, or null on failure/timeout. Also primes the socket's interest in the entity.</summary>
51-
/// <param name="entityId">The in-game smart-switch entity id.</param>
50+
/// <summary>Reads a smart device's on/off state, or null on failure/timeout. Also primes the socket's interest in the entity (so triggers fire for it thereafter).</summary>
51+
/// <param name="entityId">The in-game entity id (switch or alarm).</param>
5252
/// <param name="timeout">How long to wait for the response.</param>
5353
/// <param name="cancellationToken">A cancellation token.</param>
5454
/// <returns>True/false for on/off, or null on failure/timeout.</returns>
55-
Task<bool?> GetSmartSwitchInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);
55+
Task<bool?> GetSmartDeviceInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);
5656

5757
/// <summary>Sets a smart switch on/off; returns true on success, false on failure/timeout.</summary>
5858
/// <param name="entityId">The in-game smart-switch entity id.</param>
@@ -107,6 +107,6 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
107107
/// <summary>Raised for every in-game team chat line received on this socket.</summary>
108108
event EventHandler<TeamChatLine>? TeamMessageReceived;
109109

110-
/// <summary>Raised when a smart switch's state changes in-game; carries the entity id.</summary>
111-
event EventHandler<ulong>? SmartSwitchTriggered;
110+
/// <summary>Raised when a managed smart device's state changes in-game; carries the entity id and new state.</summary>
111+
event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered;
112112
}

src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT
4848
public Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) =>
4949
Task.FromResult(false);
5050

51-
public Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
51+
public Task<bool?> GetSmartDeviceInfoAsync(ulong entityId,
5252
TimeSpan timeout,
5353
CancellationToken cancellationToken) =>
5454
Task.FromResult<bool?>(null);
@@ -87,7 +87,7 @@ public event EventHandler<TeamChatLine>? TeamMessageReceived
8787
remove { _ = value; }
8888
}
8989

90-
public event EventHandler<ulong>? SmartSwitchTriggered
90+
public event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered
9191
{
9292
add { _ = value; }
9393
remove { _ = value; }
@@ -116,7 +116,7 @@ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerTo
116116
var connection = new RustPlusConnection(ip, port, steamId, playerToken, UseFacepunchProxy: false);
117117
_rustPlus = new RustPlus(connection);
118118
_rustPlus.OnTeamChatReceived += OnTeamChatReceived;
119-
_rustPlus.OnSmartSwitchTriggered += OnSmartSwitchTriggered;
119+
_rustPlus.OnSmartDeviceTriggered += OnSmartDeviceTriggered;
120120
}
121121

122122
public async Task<SocketConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
@@ -345,20 +345,20 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
345345
}
346346
}
347347

348-
public event EventHandler<ulong>? SmartSwitchTriggered;
348+
public event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered;
349349

350-
public async Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
350+
public async Task<bool?> GetSmartDeviceInfoAsync(ulong entityId,
351351
TimeSpan timeout,
352352
CancellationToken cancellationToken)
353353
{
354354
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
355355
timeoutCts.CancelAfter(timeout);
356356
try
357357
{
358-
// CONFIRMED (2.0.0-beta.2): GetSmartSwitchInfoAsync(ulong, CancellationToken) returns
359-
// Task of Response of SmartSwitchInfo; Response.IsSuccess and Response.Data are the accessors and
360-
// SmartSwitchInfo.IsActive is a bool. The call also primes the socket's interest in this
361-
// entity, so OnSmartSwitchTriggered fires for it thereafter.
358+
// CONFIRMED (2.0.0-beta.3): GetSmartSwitchInfoAsync(ulong, CancellationToken) returns
359+
// Task of Response of SmartDeviceInfo; Response.IsSuccess and Response.Data are the accessors and
360+
// SmartDeviceInfo.IsActive is a bool. The call also primes the socket's interest in this
361+
// entity, so OnSmartDeviceTriggered fires for it thereafter.
362362
var response = await _rustPlus.GetSmartSwitchInfoAsync(entityId, timeoutCts.Token)
363363
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
364364
return response.IsSuccess && response.Data is { } info ? info.IsActive : null;
@@ -385,8 +385,8 @@ public async Task<bool> SetSmartSwitchValueAsync(ulong entityId,
385385
timeoutCts.CancelAfter(timeout);
386386
try
387387
{
388-
// CONFIRMED (2.0.0-beta.2): SetSmartSwitchValueAsync(ulong, bool, CancellationToken) returns
389-
// Task of Response of SmartSwitchInfo; Response.IsSuccess indicates the outcome.
388+
// CONFIRMED (2.0.0-beta.3): SetSmartSwitchValueAsync(ulong, bool, CancellationToken) returns
389+
// Task of Response of SmartDeviceInfo; Response.IsSuccess indicates the outcome.
390390
var response = await _rustPlus.SetSmartSwitchValueAsync(entityId, value, timeoutCts.Token)
391391
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
392392
return response.IsSuccess;
@@ -414,8 +414,8 @@ public async Task<bool> StrobeSmartSwitchAsync(ulong entityId,
414414
timeoutCts.CancelAfter(timeout);
415415
try
416416
{
417-
// CONFIRMED (2.0.0-beta.2): StrobeSmartSwitchAsync(ulong, int timeoutMs, bool value, CancellationToken)
418-
// returns Task of Response of SmartSwitchInfo; Response.IsSuccess indicates the outcome.
417+
// CONFIRMED (2.0.0-beta.3): StrobeSmartSwitchAsync(ulong, int timeoutMs, bool value, CancellationToken)
418+
// returns Task of Response of SmartDeviceInfo; Response.IsSuccess indicates the outcome.
419419
var response = await _rustPlus.StrobeSmartSwitchAsync(entityId, timeoutMs, value, timeoutCts.Token)
420420
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
421421
return response.IsSuccess;
@@ -556,7 +556,7 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
556556
public async ValueTask DisposeAsync()
557557
{
558558
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
559-
_rustPlus.OnSmartSwitchTriggered -= OnSmartSwitchTriggered;
559+
_rustPlus.OnSmartDeviceTriggered -= OnSmartDeviceTriggered;
560560
try
561561
{
562562
// CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
@@ -571,8 +571,8 @@ public async ValueTask DisposeAsync()
571571
}
572572
}
573573

574-
private void OnSmartSwitchTriggered(object? sender, RustPlusApi.Data.Events.SmartSwitchEventArg e) =>
575-
SmartSwitchTriggered?.Invoke(this, e.Id);
574+
private void OnSmartDeviceTriggered(object? sender, RustPlusApi.Data.Events.SmartDeviceEventArg e) =>
575+
SmartDeviceTriggered?.Invoke(this, new SmartDeviceTrigger(e.Id, e.IsActive));
576576

577577
private static void AddMarkers<TMarker>(
578578
List<MapMarkerSnapshot> into,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace RustPlusBot.Features.Connections.Listening;
2+
3+
/// <summary>An in-game device-state broadcast: the entity id and its new on/off state.</summary>
4+
/// <param name="EntityId">The in-game entity id.</param>
5+
/// <param name="IsActive">The new on/off state carried on the broadcast.</param>
6+
internal sealed record SmartDeviceTrigger(ulong EntityId, bool IsActive);

src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
257257
return null;
258258
}
259259

260-
return await live.Connection.GetSmartSwitchInfoAsync(entityId, _options.HeartbeatTimeout, cancellationToken)
260+
return await live.Connection.GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, cancellationToken)
261261
.ConfigureAwait(false);
262262
}
263263

@@ -467,20 +467,20 @@ void OnTeamMessage(object? sender, TeamChatLine line)
467467
var dims = await connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
468468
var rigs = await GetRigPositionsAsync(key.Server, connection, ct).ConfigureAwait(false);
469469

470-
#pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler<ulong> delegate shape.
471-
void OnSmartSwitch(object? sender, ulong entityId)
470+
#pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler<SmartDeviceTrigger> delegate shape.
471+
void OnSmartDevice(object? sender, SmartDeviceTrigger trigger)
472472
{
473-
// Fire-and-forget: PublishSwitchStateAsync catches everything internally, so the discarded task never
474-
// surfaces an unobserved exception. Switch triggers are low-volume, so unbounded concurrency is fine.
475-
_ = PublishSwitchStateAsync(key, connection, entityId);
473+
// Fire-and-forget: PublishDeviceTriggerAsync catches everything internally, so the discarded task never
474+
// surfaces an unobserved exception. Device triggers are low-volume, so unbounded concurrency is fine.
475+
_ = PublishDeviceTriggerAsync(key, trigger);
476476
}
477477
#pragma warning restore RCS1163
478478

479479
var tracker = new TeamStateTracker();
480480
connection.TeamMessageReceived += OnTeamMessage;
481-
connection.SmartSwitchTriggered += OnSmartSwitch;
481+
connection.SmartDeviceTriggered += OnSmartDevice;
482482
_liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker);
483-
await PrimeSwitchesAsync(key, connection, ct).ConfigureAwait(false);
483+
await PrimeDevicesAsync(key, connection, ct).ConfigureAwait(false);
484484
using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
485485
var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, tracker, pollCts.Token),
486486
CancellationToken.None);
@@ -521,7 +521,7 @@ await PublishStatusAsync(key, ConnectionStatus.Connected, beat.PlayerCount, cred
521521

522522
_liveSockets.TryRemove(key, out _);
523523
connection.TeamMessageReceived -= OnTeamMessage;
524-
connection.SmartSwitchTriggered -= OnSmartSwitch;
524+
connection.SmartDeviceTriggered -= OnSmartDevice;
525525
}
526526
}
527527

@@ -799,7 +799,7 @@ private async Task PublishTeamMessageAsync((ulong Guild, Guid Server) key, ulong
799799
}
800800
}
801801

802-
private async Task PrimeSwitchesAsync(
802+
private async Task PrimeDevicesAsync(
803803
(ulong Guild, Guid Server) key,
804804
IRustServerConnection connection,
805805
CancellationToken ct)
@@ -823,7 +823,7 @@ private async Task PrimeSwitchesAsync(
823823
#pragma warning restore CA1031
824824
{
825825
// Best-effort: a store/DB failure must not crash the connected loop or block the heartbeat.
826-
LogSwitchListFailed(logger, ex, key.Server);
826+
LogDeviceListFailed(logger, ex, key.Server);
827827
return;
828828
}
829829

@@ -834,7 +834,7 @@ private async Task PrimeSwitchesAsync(
834834
// Best-effort per switch: one failure must not crash the connected loop or block the heartbeat.
835835
try
836836
{
837-
await PublishSwitchStateAsync(key, connection, sw.EntityId).ConfigureAwait(false);
837+
await PublishDevicePrimeAsync(key, connection, sw.EntityId).ConfigureAwait(false);
838838
}
839839
catch (OperationCanceledException)
840840
{
@@ -844,12 +844,47 @@ private async Task PrimeSwitchesAsync(
844844
catch (Exception ex)
845845
#pragma warning restore CA1031
846846
{
847-
LogSwitchPrimeFailed(logger, ex, sw.EntityId, key.Server);
847+
LogDevicePrimeFailed(logger, ex, sw.EntityId, key.Server);
848848
}
849849
}
850850
}
851851

852-
private async Task PublishSwitchStateAsync(
852+
/// <summary>Trigger path: IsActive is carried on the broadcast arg — no re-read.</summary>
853+
/// <param name="key">The (guild, server) routing key.</param>
854+
/// <param name="trigger">The device trigger carrying the entity id and active state.</param>
855+
private async Task PublishDeviceTriggerAsync(
856+
(ulong Guild, Guid Server) key,
857+
SmartDeviceTrigger trigger)
858+
{
859+
if (_disposed)
860+
{
861+
return;
862+
}
863+
864+
try
865+
{
866+
await eventBus.PublishAsync(
867+
new SmartDeviceTriggeredEvent(key.Guild, key.Server, trigger.EntityId, trigger.IsActive),
868+
_shutdown.Token)
869+
.ConfigureAwait(false);
870+
}
871+
catch (OperationCanceledException)
872+
{
873+
// Shutting down.
874+
}
875+
#pragma warning disable CA1031 // Broad catch: a device publish failure must not crash the socket callback.
876+
catch (Exception ex)
877+
#pragma warning restore CA1031
878+
{
879+
LogDevicePublishFailed(logger, ex, trigger.EntityId, key.Server);
880+
}
881+
}
882+
883+
/// <summary>Prime path: read state on connect (also primes the socket's interest), then publish.</summary>
884+
/// <param name="key">The (guild, server) routing key.</param>
885+
/// <param name="connection">The live connection used to read device state.</param>
886+
/// <param name="entityId">The entity id of the device to prime.</param>
887+
private async Task PublishDevicePrimeAsync(
853888
(ulong Guild, Guid Server) key,
854889
IRustServerConnection connection,
855890
ulong entityId)
@@ -862,22 +897,22 @@ private async Task PublishSwitchStateAsync(
862897
try
863898
{
864899
var isActive = await connection
865-
.GetSmartSwitchInfoAsync(entityId, _options.HeartbeatTimeout, _shutdown.Token)
900+
.GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, _shutdown.Token)
866901
.ConfigureAwait(false);
867902
await eventBus.PublishAsync(
868-
new SwitchStateChangedEvent(key.Guild, key.Server, entityId, isActive ?? false),
903+
new SmartDeviceTriggeredEvent(key.Guild, key.Server, entityId, isActive ?? false),
869904
_shutdown.Token)
870905
.ConfigureAwait(false);
871906
}
872907
catch (OperationCanceledException)
873908
{
874909
// Shutting down.
875910
}
876-
#pragma warning disable CA1031 // Broad catch: a switch publish failure must not crash the socket callback.
911+
#pragma warning disable CA1031 // Broad catch: a device prime failure must not crash the socket callback.
877912
catch (Exception ex)
878913
#pragma warning restore CA1031
879914
{
880-
LogSwitchPublishFailed(logger, ex, entityId, key.Server);
915+
LogDevicePublishFailed(logger, ex, entityId, key.Server);
881916
}
882917
}
883918

@@ -897,16 +932,16 @@ await eventBus.PublishAsync(
897932
private static partial void LogPublishTeamMessageFailed(ILogger logger, Exception exception, Guid serverId);
898933

899934
[LoggerMessage(Level = LogLevel.Warning,
900-
Message = "Listing smart switches to prime on server {ServerId} failed; priming skipped for this connection.")]
901-
private static partial void LogSwitchListFailed(ILogger logger, Exception exception, Guid serverId);
935+
Message = "Listing smart devices to prime on server {ServerId} failed; priming skipped for this connection.")]
936+
private static partial void LogDeviceListFailed(ILogger logger, Exception exception, Guid serverId);
902937

903-
[LoggerMessage(Level = LogLevel.Warning, Message = "Priming smart switch {EntityId} on server {ServerId} failed.")]
938+
[LoggerMessage(Level = LogLevel.Warning, Message = "Priming smart device {EntityId} on server {ServerId} failed.")]
904939
private static partial void
905-
LogSwitchPrimeFailed(ILogger logger, Exception exception, ulong entityId, Guid serverId);
940+
LogDevicePrimeFailed(ILogger logger, Exception exception, ulong entityId, Guid serverId);
906941

907942
[LoggerMessage(Level = LogLevel.Warning,
908-
Message = "Publishing a smart-switch state for entity {EntityId} on server {ServerId} failed.")]
909-
private static partial void LogSwitchPublishFailed(ILogger logger,
943+
Message = "Publishing a smart-device state for entity {EntityId} on server {ServerId} failed.")]
944+
private static partial void LogDevicePublishFailed(ILogger logger,
910945
Exception exception,
911946
ulong entityId,
912947
Guid serverId);

0 commit comments

Comments
 (0)