Skip to content

Commit e870ce4

Browse files
HandyS11claude
andcommitted
feat(alarms): inline per-device reachability (relay + renderer + EN/FR)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d551633 commit e870ce4

11 files changed

Lines changed: 235 additions & 18 deletions

File tree

src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66

77
namespace RustPlusBot.Features.Alarms.Hosting;
88

9-
/// <summary>Runs the alarm-pairing loop, the alarm-triggered relay loop, and the connection-status relay loop.</summary>
9+
/// <summary>Runs the alarm-pairing loop, the alarm-triggered relay loop, the connection-status relay loop, and the per-device reachability loop.</summary>
1010
/// <param name="eventBus">The in-process event bus.</param>
1111
/// <param name="coordinator">Handles paired alarms.</param>
12-
/// <param name="relay">Re-renders alarms on trigger/connection changes.</param>
12+
/// <param name="relay">Re-renders alarms on trigger/connection/reachability changes.</param>
1313
/// <param name="logger">The logger.</param>
1414
internal sealed partial class AlarmsHostedService(
1515
IEventBus eventBus,
@@ -19,6 +19,7 @@ internal sealed partial class AlarmsHostedService(
1919
{
2020
private readonly CancellationTokenSource _cts = new();
2121
private Task? _pairedLoop;
22+
private Task? _reachabilityLoop;
2223
private Task? _statusLoop;
2324
private Task? _triggeredLoop;
2425

@@ -31,6 +32,7 @@ public Task StartAsync(CancellationToken cancellationToken)
3132
_pairedLoop = Task.Run(() => ConsumePairedAsync(_cts.Token), CancellationToken.None);
3233
_triggeredLoop = Task.Run(() => ConsumeTriggeredAsync(_cts.Token), CancellationToken.None);
3334
_statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
35+
_reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None);
3436
return Task.CompletedTask;
3537
}
3638

@@ -40,7 +42,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
4042
await _cts.CancelAsync().ConfigureAwait(false);
4143
foreach (var loop in new[]
4244
{
43-
_pairedLoop, _triggeredLoop, _statusLoop
45+
_pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop
4446
}.Where(t => t is not null))
4547
{
4648
try
@@ -122,6 +124,28 @@ private async Task ConsumeStatusAsync(CancellationToken cancellationToken)
122124
}
123125
}
124126

127+
private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellationToken)
128+
{
129+
try
130+
{
131+
await foreach (var evt in eventBus.SubscribeAsync<DeviceReachabilityChangedEvent>(cancellationToken)
132+
.ConfigureAwait(false))
133+
{
134+
await relay.HandleReachabilityChangedAsync(evt, cancellationToken).ConfigureAwait(false);
135+
}
136+
}
137+
catch (OperationCanceledException)
138+
{
139+
// Shutting down.
140+
}
141+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
142+
catch (Exception ex)
143+
#pragma warning restore CA1031
144+
{
145+
LogReachabilityLoopFaulted(logger, ex);
146+
}
147+
}
148+
125149
[LoggerMessage(Level = LogLevel.Error, Message = "Alarm pairing loop faulted.")]
126150
private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception);
127151

@@ -130,4 +154,7 @@ private async Task ConsumeStatusAsync(CancellationToken cancellationToken)
130154

131155
[LoggerMessage(Level = LogLevel.Error, Message = "Alarm connection-status relay loop faulted.")]
132156
private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception);
157+
158+
[LoggerMessage(Level = LogLevel.Error, Message = "Alarm reachability relay loop faulted.")]
159+
private static partial void LogReachabilityLoopFaulted(ILogger logger, Exception exception);
133160
}

src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using RustPlusBot.Localization;
1111
using RustPlusBot.Persistence.Alarms;
1212
using RustPlusBot.Persistence.Connections;
13+
using RustPlusBot.Abstractions.Connections;
1314
using RustPlusBot.Persistence.Workspace;
1415

1516
namespace RustPlusBot.Features.Alarms.Relaying;
@@ -134,6 +135,39 @@ public async Task HandleConnectionStatusAsync(ConnectionStatusChangedEvent evt,
134135
}
135136
}
136137

138+
/// <summary>
139+
/// Handles a per-device reachability change: if the entity belongs to a managed alarm, persists the new
140+
/// reachability and triggers a refresh. Foreign entities are silently ignored.
141+
/// </summary>
142+
/// <param name="evt">The device-reachability-changed event.</param>
143+
/// <param name="cancellationToken">A cancellation token.</param>
144+
/// <returns>A task that completes when the embed has been re-rendered (or the entity was ignored).</returns>
145+
public async Task HandleReachabilityChangedAsync(
146+
DeviceReachabilityChangedEvent evt,
147+
CancellationToken cancellationToken)
148+
{
149+
ArgumentNullException.ThrowIfNull(evt);
150+
var scope = scopeFactory.CreateAsyncScope();
151+
await using (scope.ConfigureAwait(false))
152+
{
153+
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
154+
if (!await store.ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
155+
.ConfigureAwait(false))
156+
{
157+
return; // not an alarm this relay manages — ignore.
158+
}
159+
160+
await store.SetReachabilityAsync(evt.GuildId, evt.ServerId, evt.EntityId, evt.Reachability,
161+
cancellationToken)
162+
.ConfigureAwait(false);
163+
}
164+
165+
// The refresher re-loads + renders; server-down 'unreachable' stays false here — the connection-status
166+
// path owns that flag. The renderer reads alarm.Reachability for the per-device reason.
167+
await refresher.RefreshAsync(evt.GuildId, evt.ServerId, evt.EntityId, unreachable: false, cancellationToken)
168+
.ConfigureAwait(false);
169+
}
170+
137171
private async Task RelayToTeamChatSafeAsync(SmartDeviceTriggeredEvent evt, string name, CancellationToken ct)
138172
{
139173
try

src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Globalization;
22
using Discord;
3+
using RustPlusBot.Abstractions.Connections;
34
using RustPlusBot.Abstractions.Time;
45
using RustPlusBot.Domain.Alarms;
56
using RustPlusBot.Localization;
@@ -22,7 +23,19 @@ internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock)
2223

2324
string statusKey;
2425
#pragma warning disable IDE0045 // Collapsing to a nested ternary trips RCS1238/S3358 (nested conditional); the if/else chain is intentional.
25-
if (unreachable)
26+
if (alarm.Reachability == DeviceReachability.Removed)
27+
{
28+
statusKey = "alarm.status.removed";
29+
}
30+
else if (alarm.Reachability == DeviceReachability.NoPrivilege)
31+
{
32+
statusKey = "alarm.status.noprivilege";
33+
}
34+
else if (alarm.Reachability == DeviceReachability.NoResponse)
35+
{
36+
statusKey = "alarm.status.noresponse";
37+
}
38+
else if (unreachable)
2639
{
2740
statusKey = "alarm.status.unreachable";
2841
}
@@ -36,6 +49,11 @@ internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock)
3649
}
3750
#pragma warning restore IDE0045
3851

52+
// Removed and NoPrivilege disable controls (device is gone or locked); NoResponse leaves them enabled
53+
// so the user can still interact while the device is temporarily unresponsive.
54+
var blocked = alarm.Reachability is DeviceReachability.Removed or DeviceReachability.NoPrivilege;
55+
var disableButtons = unreachable || blocked;
56+
3957
var triggered = alarm.LastTriggeredUtc is { } t
4058
? localizer.Get("alarm.embed.lasttriggered", culture, CompactDuration(clock.UtcNow - t))
4159
: localizer.Get("alarm.embed.nevertriggered", culture);
@@ -51,11 +69,11 @@ internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock)
5169
var relayKey = alarm.RelayToTeamChat ? "alarm.button.relay.on" : "alarm.button.relay.off";
5270
var components = new ComponentBuilder()
5371
.WithButton(localizer.Get(pingKey, culture), AlarmComponentIds.PingTogglePrefix + tail,
54-
alarm.PingEveryone ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: unreachable)
72+
alarm.PingEveryone ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: disableButtons)
5573
.WithButton(localizer.Get(relayKey, culture), AlarmComponentIds.RelayTogglePrefix + tail,
56-
alarm.RelayToTeamChat ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: unreachable)
74+
alarm.RelayToTeamChat ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: disableButtons)
5775
.WithButton(localizer.Get("alarm.button.rename", culture), AlarmComponentIds.RenamePrefix + tail,
58-
ButtonStyle.Secondary, disabled: unreachable)
76+
ButtonStyle.Secondary, disabled: disableButtons)
5977
.Build();
6078

6179
return (embed, components);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,8 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
263263
return null;
264264
}
265265

266-
var reading = await live.Connection.GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, cancellationToken)
266+
var reading = await live.Connection
267+
.GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, cancellationToken)
267268
.ConfigureAwait(false);
268269
return reading.IsActive;
269270
}

src/RustPlusBot.Localization/Strings.fr.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@
6060
<data name="alarm.status.armed" xml:space="preserve">
6161
<value>🔔 Armée</value>
6262
</data>
63+
<data name="alarm.status.noprivilege" xml:space="preserve">
64+
<value>⛔ Aucun privilège de construction</value>
65+
</data>
66+
<data name="alarm.status.noresponse" xml:space="preserve">
67+
<value>⚠️ Aucune réponse</value>
68+
</data>
69+
<data name="alarm.status.removed" xml:space="preserve">
70+
<value>❌ Supprimé en jeu</value>
71+
</data>
6372
<data name="alarm.status.unreachable" xml:space="preserve">
6473
<value>⚠️ Injoignable</value>
6574
</data>

src/RustPlusBot.Localization/Strings.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@
6060
<data name="alarm.status.armed" xml:space="preserve">
6161
<value>🔔 Armed</value>
6262
</data>
63+
<data name="alarm.status.noprivilege" xml:space="preserve">
64+
<value>⛔ No building privilege</value>
65+
</data>
66+
<data name="alarm.status.noresponse" xml:space="preserve">
67+
<value>⚠️ No response</value>
68+
</data>
69+
<data name="alarm.status.removed" xml:space="preserve">
70+
<value>❌ Removed in-game</value>
71+
</data>
6372
<data name="alarm.status.unreachable" xml:space="preserve">
6473
<value>⚠️ Unreachable</value>
6574
</data>

tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Discord;
22
using NSubstitute;
3+
using RustPlusBot.Abstractions.Connections;
34
using RustPlusBot.Abstractions.Time;
45
using RustPlusBot.Domain.Alarms;
56
using RustPlusBot.Features.Alarms.Rendering;
@@ -272,6 +273,65 @@ public void RenderPrompt_french_uses_french_strings()
272273
Assert.Contains("détectée", embed.Title ?? string.Empty, StringComparison.Ordinal);
273274
}
274275

276+
// ── Per-device reachability reason ───────────────────────────────────────
277+
278+
[Theory]
279+
[InlineData(DeviceReachability.Removed, "Removed")]
280+
[InlineData(DeviceReachability.NoPrivilege, "privilege")]
281+
[InlineData(DeviceReachability.NoResponse, "response")]
282+
public void RenderAlarm_non_reachable_shows_reason_status(DeviceReachability reachability, string expectedFragment)
283+
{
284+
var alarm = new SmartAlarm
285+
{
286+
GuildId = 10UL,
287+
ServerId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
288+
EntityId = 42UL,
289+
Name = "Trap",
290+
Reachability = reachability,
291+
};
292+
var (embed, _) = Create().RenderAlarm(alarm, unreachable: false, "en");
293+
294+
Assert.Contains(expectedFragment, embed.Description ?? string.Empty, StringComparison.OrdinalIgnoreCase);
295+
}
296+
297+
[Theory]
298+
[InlineData(DeviceReachability.Removed)]
299+
[InlineData(DeviceReachability.NoPrivilege)]
300+
public void RenderAlarm_removed_or_noprivilege_disables_buttons(DeviceReachability reachability)
301+
{
302+
var alarm = new SmartAlarm
303+
{
304+
GuildId = 10UL,
305+
ServerId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
306+
EntityId = 42UL,
307+
Name = "Trap",
308+
Reachability = reachability,
309+
};
310+
var (_, components) = Create().RenderAlarm(alarm, unreachable: false, "en");
311+
var buttons = Buttons(components);
312+
313+
Assert.NotEmpty(buttons);
314+
Assert.All(buttons, b => Assert.True(b.IsDisabled));
315+
}
316+
317+
[Fact]
318+
public void RenderAlarm_noresponse_leaves_buttons_enabled()
319+
{
320+
var alarm = new SmartAlarm
321+
{
322+
GuildId = 10UL,
323+
ServerId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
324+
EntityId = 42UL,
325+
Name = "Trap",
326+
Reachability = DeviceReachability.NoResponse,
327+
};
328+
var (_, components) = Create().RenderAlarm(alarm, unreachable: false, "en");
329+
var buttons = Buttons(components);
330+
331+
Assert.NotEmpty(buttons);
332+
Assert.All(buttons, b => Assert.False(b.IsDisabled));
333+
}
334+
275335
[Fact]
276336
public void RenderAlarm_null_alarm_throws_argument_null()
277337
{

tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Microsoft.Extensions.Logging.Abstractions;
33
using NSubstitute;
44
using NSubstitute.ExceptionExtensions;
5+
using RustPlusBot.Abstractions.Connections;
56
using RustPlusBot.Abstractions.Events;
67
using RustPlusBot.Abstractions.Time;
78
using RustPlusBot.Domain.Alarms;
@@ -336,6 +337,53 @@ await h.Refresher.DidNotReceive()
336337
.RefreshAsync(Arg.Any<SmartAlarm>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
337338
}
338339

340+
// ──────────────────────────────────────────────────────────────────────────
341+
// Reachability changed
342+
// ──────────────────────────────────────────────────────────────────────────
343+
344+
/// <summary>A reachability change for an owned alarm persists the new value and triggers a refresh.</summary>
345+
[Fact]
346+
public async Task ReachabilityChanged_owned_alarm_persists_and_refreshes()
347+
{
348+
var serverId = Guid.NewGuid();
349+
var h = Create();
350+
351+
h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>()).Returns(true);
352+
353+
await h.Relay.HandleReachabilityChangedAsync(
354+
new DeviceReachabilityChangedEvent(10UL, serverId, 42UL, DeviceReachability.NoPrivilege),
355+
CancellationToken.None);
356+
357+
await h.Store.Received(1)
358+
.SetReachabilityAsync(10UL, serverId, 42UL, DeviceReachability.NoPrivilege,
359+
Arg.Any<CancellationToken>());
360+
await h.Refresher.Received(1)
361+
.RefreshAsync(10UL, serverId, 42UL, unreachable: false, Arg.Any<CancellationToken>());
362+
}
363+
364+
/// <summary>A reachability change for a foreign entity (not in the alarm store) is silently ignored.</summary>
365+
[Fact]
366+
public async Task ReachabilityChanged_foreign_entity_is_ignored()
367+
{
368+
var serverId = Guid.NewGuid();
369+
var h = Create();
370+
371+
// ExistsAsync returns false by default for unknown entities.
372+
h.Store.ExistsAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<ulong>(), Arg.Any<CancellationToken>())
373+
.Returns(false);
374+
375+
await h.Relay.HandleReachabilityChangedAsync(
376+
new DeviceReachabilityChangedEvent(10UL, serverId, 99UL, DeviceReachability.Removed),
377+
CancellationToken.None);
378+
379+
await h.Store.DidNotReceive()
380+
.SetReachabilityAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<ulong>(),
381+
Arg.Any<DeviceReachability>(), Arg.Any<CancellationToken>());
382+
await h.Refresher.DidNotReceive()
383+
.RefreshAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<ulong>(),
384+
Arg.Any<bool>(), Arg.Any<CancellationToken>());
385+
}
386+
339387
private sealed record Harness(
340388
AlarmStateRelay Relay,
341389
IAlarmStore Store,

tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ internal sealed class FakeRustSocketSource : IRustSocketSource
2020
{
2121
private readonly ConcurrentQueue<SocketConnectOutcome> _connectOutcomes = new();
2222
private readonly ConcurrentQueue<HeartbeatResult> _heartbeats = new();
23+
private readonly Dictionary<ulong, DeviceReachability> _pendingDeviceReachabilityOverrides = [];
2324
private readonly ConcurrentQueue<IReadOnlyList<MapMarkerSnapshot>> _pendingMarkerScript = new();
2425
private readonly Dictionary<ulong, StorageContentsSnapshot?> _pendingStorageContents = [];
25-
private readonly Dictionary<ulong, DeviceReachability> _pendingDeviceReachabilityOverrides = [];
2626
private int _createCount;
2727

2828
private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0);
@@ -245,9 +245,12 @@ public Task<DeviceReading> GetSmartDeviceInfoAsync(ulong entityId,
245245
TimeSpan timeout,
246246
CancellationToken cancellationToken)
247247
{
248-
var reachability = DeviceReachabilityOverrides.TryGetValue(entityId, out var r) ? r : DeviceReachability.Reachable;
248+
var reachability = DeviceReachabilityOverrides.TryGetValue(entityId, out var r)
249+
? r
250+
: DeviceReachability.Reachable;
249251
var state = SwitchStates.TryGetValue(entityId, out var s) ? s : null;
250-
return Task.FromResult(new DeviceReading(reachability == DeviceReachability.Reachable ? state : null, reachability));
252+
return Task.FromResult(new DeviceReading(reachability == DeviceReachability.Reachable ? state : null,
253+
reachability));
251254
}
252255
#pragma warning restore RCS1163
253256

@@ -256,9 +259,12 @@ public Task<StorageReading> GetStorageMonitorInfoAsync(ulong entityId,
256259
TimeSpan timeout,
257260
CancellationToken cancellationToken)
258261
{
259-
var reachability = DeviceReachabilityOverrides.TryGetValue(entityId, out var r) ? r : DeviceReachability.Reachable;
262+
var reachability = DeviceReachabilityOverrides.TryGetValue(entityId, out var r)
263+
? r
264+
: DeviceReachability.Reachable;
260265
var contents = StorageContents.TryGetValue(entityId, out var c) ? c : null;
261-
return Task.FromResult(new StorageReading(reachability == DeviceReachability.Reachable ? contents : null, reachability));
266+
return Task.FromResult(new StorageReading(reachability == DeviceReachability.Reachable ? contents : null,
267+
reachability));
262268
}
263269
#pragma warning restore RCS1163
264270

0 commit comments

Comments
 (0)