Skip to content

Commit bd7c330

Browse files
HandyS11claude
andcommitted
feat(devices): periodic reachability poll publishes on change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bbf4601 commit bd7c330

5 files changed

Lines changed: 177 additions & 2 deletions

File tree

src/RustPlusBot.Features.Connections/ConnectionOptions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,7 @@ public sealed class ConnectionOptions
4141

4242
/// <summary>Movement tolerance (world units) below which a member is considered still. Default 1.</summary>
4343
public float AfkEpsilon { get; set; } = 1f;
44+
45+
/// <summary>How often to poll managed devices for reachability changes while connected. Default 5m.</summary>
46+
public TimeSpan ReachabilityPollInterval { get; set; } = TimeSpan.FromMinutes(5);
4447
}

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

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,8 @@ void OnStorage(object? sender, StorageMonitorTrigger trigger)
519519
using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
520520
var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, tracker, pollCts.Token),
521521
CancellationToken.None);
522+
var reachabilityPoll = Task.Run(() => PollReachabilityAsync(key, connection, pollCts.Token),
523+
CancellationToken.None);
522524
try
523525
{
524526
return await RunHeartbeatLoopAsync(key, connection, credentialId, ct).ConfigureAwait(false);
@@ -528,8 +530,8 @@ void OnStorage(object? sender, StorageMonitorTrigger trigger)
528530
await pollCts.CancelAsync().ConfigureAwait(false);
529531
try
530532
{
531-
#pragma warning disable VSTHRD003 // Suppress: markerPoll is owned by this connected window and explicitly joined on exit.
532-
await markerPoll.ConfigureAwait(false);
533+
#pragma warning disable VSTHRD003 // Suppress: markerPoll and reachabilityPoll are owned by this connected window and explicitly joined on exit.
534+
await Task.WhenAll(markerPoll, reachabilityPoll).ConfigureAwait(false);
533535
#pragma warning restore VSTHRD003
534536
}
535537
catch (OperationCanceledException)
@@ -632,6 +634,91 @@ await eventBus.PublishAsync(
632634
}
633635
}
634636

637+
private async Task PollReachabilityAsync(
638+
(ulong Guild, Guid Server) key,
639+
IRustServerConnection connection,
640+
CancellationToken ct)
641+
{
642+
var previous = new Dictionary<ulong, DeviceReachability>();
643+
var seeded = false;
644+
while (!ct.IsCancellationRequested)
645+
{
646+
await Task.Delay(_options.ReachabilityPollInterval, ct).ConfigureAwait(false);
647+
Dictionary<ulong, DeviceReachability> current;
648+
try
649+
{
650+
current = await ReadAllReachabilityAsync(key, connection, ct).ConfigureAwait(false);
651+
}
652+
catch (OperationCanceledException)
653+
{
654+
throw;
655+
}
656+
#pragma warning disable CA1031 // Broad catch: a failed reachability sweep is logged and retried next cycle.
657+
catch (Exception ex)
658+
#pragma warning restore CA1031
659+
{
660+
LogReachabilityPollFailed(logger, ex, key.Server);
661+
continue;
662+
}
663+
664+
if (!seeded)
665+
{
666+
foreach (var kvp in current)
667+
{
668+
previous[kvp.Key] = kvp.Value;
669+
}
670+
671+
seeded = true;
672+
continue; // first cycle: silent baseline
673+
}
674+
675+
foreach (var change in ReachabilitySweep.Diff(previous, current))
676+
{
677+
previous[change.Key] = change.Value;
678+
await eventBus.PublishAsync(
679+
new DeviceReachabilityChangedEvent(key.Guild, key.Server, change.Key, change.Value), ct)
680+
.ConfigureAwait(false);
681+
}
682+
}
683+
}
684+
685+
private async Task<Dictionary<ulong, DeviceReachability>> ReadAllReachabilityAsync(
686+
(ulong Guild, Guid Server) key,
687+
IRustServerConnection connection,
688+
CancellationToken ct)
689+
{
690+
var result = new Dictionary<ulong, DeviceReachability>();
691+
var scope = scopeFactory.CreateAsyncScope();
692+
await using (scope.ConfigureAwait(false))
693+
{
694+
var switches = await scope.ServiceProvider.GetRequiredService<ISwitchStore>()
695+
.ListByServerAsync(key.Guild, key.Server, ct).ConfigureAwait(false);
696+
var alarms = await scope.ServiceProvider.GetRequiredService<IAlarmStore>()
697+
.ListByServerAsync(key.Guild, key.Server, ct).ConfigureAwait(false);
698+
var monitors = await scope.ServiceProvider.GetRequiredService<IStorageMonitorStore>()
699+
.ListByServerAsync(key.Guild, key.Server, ct).ConfigureAwait(false);
700+
701+
foreach (var entityId in switches.Select(s => s.EntityId).Concat(alarms.Select(a => a.EntityId)))
702+
{
703+
var reading = await connection.GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, ct)
704+
.ConfigureAwait(false);
705+
result[entityId] = reading.Reachability;
706+
}
707+
708+
#pragma warning disable S3267 // Not a projection: each iteration awaits with per-monitor best-effort error handling.
709+
foreach (var monitor in monitors)
710+
#pragma warning restore S3267
711+
{
712+
var reading = await connection
713+
.GetStorageMonitorInfoAsync(monitor.EntityId, _options.HeartbeatTimeout, ct)
714+
.ConfigureAwait(false);
715+
result[monitor.EntityId] = reading.Reachability;
716+
}
717+
}
718+
719+
return result;
720+
}
721+
635722
private async Task DetectRigActivationsAsync(
636723
(ulong Guild, Guid Server) key,
637724
IReadOnlyList<MapMarkerSnapshot> current,
@@ -1118,6 +1205,9 @@ await eventBus.PublishAsync(
11181205
[LoggerMessage(Level = LogLevel.Warning, Message = "Marker poll for server {ServerId} failed.")]
11191206
private static partial void LogMarkerPollFailed(ILogger logger, Exception exception, Guid serverId);
11201207

1208+
[LoggerMessage(Level = LogLevel.Warning, Message = "Reachability poll for server {ServerId} failed.")]
1209+
private static partial void LogReachabilityPollFailed(ILogger logger, Exception exception, Guid serverId);
1210+
11211211
[LoggerMessage(Level = LogLevel.Warning,
11221212
Message =
11231213
"Fetching monuments for oil-rig detection on server {ServerId} failed; rig detection disabled for this connection.")]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
3+
namespace RustPlusBot.Features.Connections.Supervisor;
4+
5+
/// <summary>Pure helper: which entities' reachability changed between two poll snapshots.</summary>
6+
internal static class ReachabilitySweep
7+
{
8+
/// <summary>Entities in <paramref name="current"/> whose reachability differs from <paramref name="previous"/> (absent ⇒ Reachable).</summary>
9+
/// <param name="previous">The reachability snapshot from the previous poll cycle.</param>
10+
/// <param name="current">The reachability snapshot from the current poll cycle.</param>
11+
/// <returns>A list of (entityId, newReachability) pairs where the state has changed.</returns>
12+
public static IReadOnlyList<KeyValuePair<ulong, DeviceReachability>> Diff(
13+
IReadOnlyDictionary<ulong, DeviceReachability> previous,
14+
IReadOnlyDictionary<ulong, DeviceReachability> current)
15+
{
16+
var changes = new List<KeyValuePair<ulong, DeviceReachability>>();
17+
foreach (var (entityId, reachability) in current)
18+
{
19+
var before = previous.TryGetValue(entityId, out var p) ? p : DeviceReachability.Reachable;
20+
if (before != reachability)
21+
{
22+
changes.Add(new KeyValuePair<ulong, DeviceReachability>(entityId, reachability));
23+
}
24+
}
25+
26+
return changes;
27+
}
28+
}

tests/RustPlusBot.Features.Connections.Tests/ConnectionOptionsTests.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,12 @@ public void Defaults_match_the_2a_ii_design()
1818
Assert.Equal(TimeSpan.FromMinutes(15), o.RigOfflineWindow);
1919
Assert.Equal(TimeSpan.FromSeconds(30), o.RigTickInterval);
2020
}
21+
22+
/// <summary>Verifies that the reachability poll interval defaults to 5 minutes.</summary>
23+
[Fact]
24+
public void ReachabilityPollInterval_DefaultIs5Minutes()
25+
{
26+
var o = new ConnectionOptions();
27+
Assert.Equal(TimeSpan.FromMinutes(5), o.ReachabilityPollInterval);
28+
}
2129
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
using RustPlusBot.Features.Connections.Supervisor;
3+
4+
namespace RustPlusBot.Features.Connections.Tests;
5+
6+
/// <summary>Unit tests for <see cref="ReachabilitySweep.Diff"/>.</summary>
7+
public sealed class ReachabilitySweepTests
8+
{
9+
/// <summary>Verifies that a first-observed Removed state is reported (absent ⇒ Reachable default, so Removed is a change).</summary>
10+
[Fact]
11+
public void Diff_FirstObservedRemoved_IsReported()
12+
{
13+
var current = new Dictionary<ulong, DeviceReachability> { [1] = DeviceReachability.Removed };
14+
var changes = ReachabilitySweep.Diff(new Dictionary<ulong, DeviceReachability>(), current);
15+
// 1 went from (absent==Reachable default) to Removed
16+
Assert.Single(changes);
17+
}
18+
19+
/// <summary>Verifies that both recoveries and new degradations are reported.</summary>
20+
[Fact]
21+
public void Diff_ReportsChangesIncludingRecovery()
22+
{
23+
var previous = new Dictionary<ulong, DeviceReachability>
24+
{
25+
[1] = DeviceReachability.Removed,
26+
[2] = DeviceReachability.Reachable,
27+
};
28+
var current = new Dictionary<ulong, DeviceReachability>
29+
{
30+
[1] = DeviceReachability.Reachable, // recovered
31+
[2] = DeviceReachability.NoPrivilege, // newly degraded
32+
};
33+
var changes = ReachabilitySweep.Diff(previous, current);
34+
Assert.Equal(2, changes.Count);
35+
Assert.Contains(changes, c => c.Key == 1 && c.Value == DeviceReachability.Reachable);
36+
Assert.Contains(changes, c => c.Key == 2 && c.Value == DeviceReachability.NoPrivilege);
37+
}
38+
39+
/// <summary>Verifies that unchanged states produce an empty result.</summary>
40+
[Fact]
41+
public void Diff_NoChanges_ReturnsEmpty()
42+
{
43+
var same = new Dictionary<ulong, DeviceReachability> { [1] = DeviceReachability.Reachable };
44+
Assert.Empty(ReachabilitySweep.Diff(same, new Dictionary<ulong, DeviceReachability>(same)));
45+
}
46+
}

0 commit comments

Comments
 (0)