Skip to content

Commit ca715ac

Browse files
HandyS11claude
andauthored
Subsystem 4e: Per-device unreachable status (switches/alarms/storage, inline) (#36)
* feat(devices): add DeviceReachability vocabulary (enum, readings, event) * feat(devices): persist per-device Reachability (+ migration, store mutators) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(devices): pure RustPlusErrorCode -> DeviceReachability mapper * refactor(devices): widen connection reads/actuation to carry DeviceReachability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(devices): connect-prime publishes per-device reachability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(devices): collapse deliberation comment in SwitchPrimingTests * feat(devices): periodic reachability poll publishes on change Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(switches): inline per-device reachability (relay + renderer + EN/FR) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(alarms): inline per-device reachability (relay + renderer + EN/FR) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(storage): inline per-device reachability (relay + renderer + EN/FR) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(switches): surface actuation reachability reason + update embed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1ce78e5 commit ca715ac

57 files changed

Lines changed: 2320 additions & 113 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace RustPlusBot.Abstractions.Connections;
2+
3+
/// <summary>Per-device reachability, independent of whole-server connection status.</summary>
4+
public enum DeviceReachability
5+
{
6+
/// <summary>The device read/actuated successfully.</summary>
7+
Reachable = 0,
8+
9+
/// <summary>The in-game entity no longer exists (was destroyed). Maps from <c>not_found</c>.</summary>
10+
Removed = 1,
11+
12+
/// <summary>The active player lacks building privilege / token access. Maps from <c>access_denied</c>.</summary>
13+
NoPrivilege = 2,
14+
15+
/// <summary>The device did not answer in time (timeout / unknown / other server error).</summary>
16+
NoResponse = 3,
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace RustPlusBot.Abstractions.Connections;
2+
3+
/// <summary>A smart-switch/alarm read: on/off state plus reachability. <see cref="IsActive"/> is null unless <see cref="Reachability"/> is Reachable.</summary>
4+
/// <param name="IsActive">The device on/off state; null unless Reachable.</param>
5+
/// <param name="Reachability">The device reachability.</param>
6+
public readonly record struct DeviceReading(bool? IsActive, DeviceReachability Reachability);
7+
8+
/// <summary>A storage-monitor read: contents plus reachability. <see cref="Contents"/> is null unless <see cref="Reachability"/> is Reachable.</summary>
9+
/// <param name="Contents">The storage contents snapshot; null unless Reachable.</param>
10+
/// <param name="Reachability">The device reachability.</param>
11+
public readonly record struct StorageReading(StorageContentsSnapshot? Contents, DeviceReachability Reachability);

src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,28 +79,28 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
7979
ulong entityId,
8080
CancellationToken cancellationToken);
8181

82-
/// <summary>Sets a smart switch on/off; returns false when there is no live socket or the call fails.</summary>
82+
/// <summary>Sets a smart switch on/off; returns the reachability reason when the call fails or there is no live socket.</summary>
8383
/// <param name="guildId">The owning guild snowflake.</param>
8484
/// <param name="serverId">The target server id.</param>
8585
/// <param name="entityId">The in-game smart-switch entity id.</param>
8686
/// <param name="value">True to turn on, false to turn off.</param>
8787
/// <param name="cancellationToken">A cancellation token.</param>
88-
/// <returns>True on success; false when unavailable or the call fails.</returns>
89-
Task<bool> SetSmartSwitchAsync(ulong guildId,
88+
/// <returns><see cref="DeviceReachability.Reachable"/> on success; the failure reason otherwise.</returns>
89+
Task<DeviceReachability> SetSmartSwitchAsync(ulong guildId,
9090
Guid serverId,
9191
ulong entityId,
9292
bool value,
9393
CancellationToken cancellationToken);
9494

95-
/// <summary>Strobes a smart switch; returns false when there is no live socket or the call fails.</summary>
95+
/// <summary>Strobes a smart switch; returns the reachability reason when the call fails or there is no live socket.</summary>
9696
/// <param name="guildId">The owning guild snowflake.</param>
9797
/// <param name="serverId">The target server id.</param>
9898
/// <param name="entityId">The in-game smart-switch entity id.</param>
9999
/// <param name="timeoutMs">The in-game strobe duration in milliseconds.</param>
100100
/// <param name="value">The terminal value after strobing.</param>
101101
/// <param name="cancellationToken">A cancellation token.</param>
102-
/// <returns>True on success; false when unavailable or the call fails.</returns>
103-
Task<bool> StrobeSmartSwitchAsync(ulong guildId,
102+
/// <returns><see cref="DeviceReachability.Reachable"/> on success; the failure reason otherwise.</returns>
103+
Task<DeviceReachability> StrobeSmartSwitchAsync(ulong guildId,
104104
Guid serverId,
105105
ulong entityId,
106106
int timeoutMs,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
3+
namespace RustPlusBot.Abstractions.Events;
4+
5+
/// <summary>Raised when a managed device's reachability changes. Device-agnostic; relays filter by entity ownership.</summary>
6+
/// <param name="GuildId">The owning guild snowflake.</param>
7+
/// <param name="ServerId">The server id.</param>
8+
/// <param name="EntityId">The in-game entity id.</param>
9+
/// <param name="Reachability">The new reachability.</param>
10+
public sealed record DeviceReachabilityChangedEvent(
11+
ulong GuildId,
12+
Guid ServerId,
13+
ulong EntityId,
14+
DeviceReachability Reachability);

src/RustPlusBot.Domain/Alarms/SmartAlarm.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
13
namespace RustPlusBot.Domain.Alarms;
24

35
/// <summary>A paired Smart Alarm the bot manages, surviving restarts. Guild- and server-scoped. Driven by the live socket (primed on connect, reacts to SmartDeviceTriggered) — the entity id is the switch-vs-alarm discriminant.</summary>
@@ -38,4 +40,7 @@ public sealed class SmartAlarm
3840

3941
/// <summary>When the alarm most recently went active (UTC), or null if never triggered.</summary>
4042
public DateTimeOffset? LastTriggeredUtc { get; set; }
43+
44+
/// <summary>Per-device reachability; defaults to Reachable. Orthogonal to whole-server connection status.</summary>
45+
public DeviceReachability Reachability { get; set; } = DeviceReachability.Reachable;
4146
}

src/RustPlusBot.Domain/StorageMonitors/SmartStorageMonitor.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
13
namespace RustPlusBot.Domain.StorageMonitors;
24

35
/// <summary>A paired Smart Storage Monitor the bot manages, surviving restarts. Guild- and server-scoped.</summary>
@@ -26,4 +28,7 @@ public sealed class SmartStorageMonitor
2628

2729
/// <summary>When the monitor was accepted (UTC).</summary>
2830
public DateTimeOffset CreatedUtc { get; set; }
31+
32+
/// <summary>Per-device reachability; defaults to Reachable. Orthogonal to whole-server connection status.</summary>
33+
public DeviceReachability Reachability { get; set; } = DeviceReachability.Reachable;
2934
}

src/RustPlusBot.Domain/Switches/SmartSwitch.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
13
namespace RustPlusBot.Domain.Switches;
24

35
/// <summary>A paired Smart Switch the bot manages, surviving restarts. Guild- and server-scoped.</summary>
@@ -29,4 +31,7 @@ public sealed class SmartSwitch
2931

3032
/// <summary>When the switch was accepted (UTC).</summary>
3133
public DateTimeOffset CreatedUtc { get; set; }
34+
35+
/// <summary>Per-device reachability; defaults to Reachable. Orthogonal to whole-server connection status.</summary>
36+
public DeviceReachability Reachability { get; set; } = DeviceReachability.Reachable;
3237
}

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);

0 commit comments

Comments
 (0)