Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.2" />
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.2" />
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.3" />
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.3" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<!-- Pinned to override the transitive 2.1.11 pulled by Microsoft.EntityFrameworkCore.Sqlite,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>A managed smart device (switch/alarm/…) changed state in-game, as reported by the socket broadcast.</summary>
/// <param name="GuildId">The owning Discord guild snowflake.</param>
/// <param name="ServerId">The local Rust server id.</param>
/// <param name="EntityId">The in-game entity id (the discriminant — features filter to the ids they manage).</param>
/// <param name="IsActive">The new on/off state, carried directly on the broadcast (no re-read).</param>
public sealed record SmartDeviceTriggeredEvent(ulong GuildId, Guid ServerId, ulong EntityId, bool IsActive);
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ internal interface IRustServerConnection : IAsyncDisposable
/// <returns>True if the promotion succeeded; false on failure/timeout.</returns>
Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken);

/// <summary>Reads a smart switch's on/off state, or null on failure/timeout. Also primes the socket's interest in the entity.</summary>
/// <param name="entityId">The in-game smart-switch entity id.</param>
/// <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>
/// <param name="entityId">The in-game entity id (switch or alarm).</param>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True/false for on/off, or null on failure/timeout.</returns>
Task<bool?> GetSmartSwitchInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);
Task<bool?> GetSmartDeviceInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);

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

/// <summary>Raised when a smart switch's state changes in-game; carries the entity id.</summary>
event EventHandler<ulong>? SmartSwitchTriggered;
/// <summary>Raised when a managed smart device's state changes in-game; carries the entity id and new state.</summary>
event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered;
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT
public Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) =>
Task.FromResult(false);

public Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
public Task<bool?> GetSmartDeviceInfoAsync(ulong entityId,
TimeSpan timeout,
CancellationToken cancellationToken) =>
Task.FromResult<bool?>(null);
Expand Down Expand Up @@ -87,7 +87,7 @@ public event EventHandler<TeamChatLine>? TeamMessageReceived
remove { _ = value; }
}

public event EventHandler<ulong>? SmartSwitchTriggered
public event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered
{
add { _ = value; }
remove { _ = value; }
Expand Down Expand Up @@ -116,7 +116,7 @@ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerTo
var connection = new RustPlusConnection(ip, port, steamId, playerToken, UseFacepunchProxy: false);
_rustPlus = new RustPlus(connection);
_rustPlus.OnTeamChatReceived += OnTeamChatReceived;
_rustPlus.OnSmartSwitchTriggered += OnSmartSwitchTriggered;
_rustPlus.OnSmartDeviceTriggered += OnSmartDeviceTriggered;
}

public async Task<SocketConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
Expand Down Expand Up @@ -345,20 +345,20 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
}
}

public event EventHandler<ulong>? SmartSwitchTriggered;
public event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered;

public async Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
public async Task<bool?> GetSmartDeviceInfoAsync(ulong entityId,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.2): GetSmartSwitchInfoAsync(ulong, CancellationToken) returns
// Task of Response of SmartSwitchInfo; Response.IsSuccess and Response.Data are the accessors and
// SmartSwitchInfo.IsActive is a bool. The call also primes the socket's interest in this
// entity, so OnSmartSwitchTriggered fires for it thereafter.
// CONFIRMED (2.0.0-beta.3): GetSmartSwitchInfoAsync(ulong, CancellationToken) returns
// Task of Response of SmartDeviceInfo; Response.IsSuccess and Response.Data are the accessors and
// SmartDeviceInfo.IsActive is a bool. The call also primes the socket's interest in this
// entity, so OnSmartDeviceTriggered fires for it thereafter.
var response = await _rustPlus.GetSmartSwitchInfoAsync(entityId, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return response.IsSuccess && response.Data is { } info ? info.IsActive : null;
Expand All @@ -385,8 +385,8 @@ public async Task<bool> SetSmartSwitchValueAsync(ulong entityId,
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.2): SetSmartSwitchValueAsync(ulong, bool, CancellationToken) returns
// Task of Response of SmartSwitchInfo; Response.IsSuccess indicates the outcome.
// CONFIRMED (2.0.0-beta.3): SetSmartSwitchValueAsync(ulong, bool, CancellationToken) returns
// Task of Response of SmartDeviceInfo; Response.IsSuccess indicates the outcome.
var response = await _rustPlus.SetSmartSwitchValueAsync(entityId, value, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return response.IsSuccess;
Expand Down Expand Up @@ -414,8 +414,8 @@ public async Task<bool> StrobeSmartSwitchAsync(ulong entityId,
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.2): StrobeSmartSwitchAsync(ulong, int timeoutMs, bool value, CancellationToken)
// returns Task of Response of SmartSwitchInfo; Response.IsSuccess indicates the outcome.
// CONFIRMED (2.0.0-beta.3): StrobeSmartSwitchAsync(ulong, int timeoutMs, bool value, CancellationToken)
// returns Task of Response of SmartDeviceInfo; Response.IsSuccess indicates the outcome.
var response = await _rustPlus.StrobeSmartSwitchAsync(entityId, timeoutMs, value, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return response.IsSuccess;
Expand Down Expand Up @@ -556,7 +556,7 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
public async ValueTask DisposeAsync()
{
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
_rustPlus.OnSmartSwitchTriggered -= OnSmartSwitchTriggered;
_rustPlus.OnSmartDeviceTriggered -= OnSmartDeviceTriggered;
try
{
// CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
Expand All @@ -571,8 +571,8 @@ public async ValueTask DisposeAsync()
}
}

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

private static void AddMarkers<TMarker>(
List<MapMarkerSnapshot> into,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace RustPlusBot.Features.Connections.Listening;

/// <summary>An in-game device-state broadcast: the entity id and its new on/off state.</summary>
/// <param name="EntityId">The in-game entity id.</param>
/// <param name="IsActive">The new on/off state carried on the broadcast.</param>
internal sealed record SmartDeviceTrigger(ulong EntityId, bool IsActive);
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
return null;
}

return await live.Connection.GetSmartSwitchInfoAsync(entityId, _options.HeartbeatTimeout, cancellationToken)
return await live.Connection.GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, cancellationToken)
.ConfigureAwait(false);
}

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

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

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

_liveSockets.TryRemove(key, out _);
connection.TeamMessageReceived -= OnTeamMessage;
connection.SmartSwitchTriggered -= OnSmartSwitch;
connection.SmartDeviceTriggered -= OnSmartDevice;
}
}

Expand Down Expand Up @@ -799,7 +799,7 @@ private async Task PublishTeamMessageAsync((ulong Guild, Guid Server) key, ulong
}
}

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

Expand All @@ -834,7 +834,7 @@ private async Task PrimeSwitchesAsync(
// Best-effort per switch: one failure must not crash the connected loop or block the heartbeat.
try
{
await PublishSwitchStateAsync(key, connection, sw.EntityId).ConfigureAwait(false);
await PublishDevicePrimeAsync(key, connection, sw.EntityId).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Expand All @@ -844,12 +844,47 @@ private async Task PrimeSwitchesAsync(
catch (Exception ex)
#pragma warning restore CA1031
{
LogSwitchPrimeFailed(logger, ex, sw.EntityId, key.Server);
LogDevicePrimeFailed(logger, ex, sw.EntityId, key.Server);
}
}
}

private async Task PublishSwitchStateAsync(
/// <summary>Trigger path: IsActive is carried on the broadcast arg — no re-read.</summary>
/// <param name="key">The (guild, server) routing key.</param>
/// <param name="trigger">The device trigger carrying the entity id and active state.</param>
private async Task PublishDeviceTriggerAsync(
(ulong Guild, Guid Server) key,
SmartDeviceTrigger trigger)
{
if (_disposed)
{
return;
}

try
{
await eventBus.PublishAsync(
new SmartDeviceTriggeredEvent(key.Guild, key.Server, trigger.EntityId, trigger.IsActive),
_shutdown.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Shutting down.
}
#pragma warning disable CA1031 // Broad catch: a device publish failure must not crash the socket callback.
catch (Exception ex)
#pragma warning restore CA1031
{
LogDevicePublishFailed(logger, ex, trigger.EntityId, key.Server);
}
}

/// <summary>Prime path: read state on connect (also primes the socket's interest), then publish.</summary>
/// <param name="key">The (guild, server) routing key.</param>
/// <param name="connection">The live connection used to read device state.</param>
/// <param name="entityId">The entity id of the device to prime.</param>
private async Task PublishDevicePrimeAsync(
(ulong Guild, Guid Server) key,
IRustServerConnection connection,
ulong entityId)
Expand All @@ -862,22 +897,22 @@ private async Task PublishSwitchStateAsync(
try
{
var isActive = await connection
.GetSmartSwitchInfoAsync(entityId, _options.HeartbeatTimeout, _shutdown.Token)
.GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, _shutdown.Token)
.ConfigureAwait(false);
await eventBus.PublishAsync(
new SwitchStateChangedEvent(key.Guild, key.Server, entityId, isActive ?? false),
new SmartDeviceTriggeredEvent(key.Guild, key.Server, entityId, isActive ?? false),
_shutdown.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Shutting down.
}
#pragma warning disable CA1031 // Broad catch: a switch publish failure must not crash the socket callback.
#pragma warning disable CA1031 // Broad catch: a device prime failure must not crash the socket callback.
catch (Exception ex)
#pragma warning restore CA1031
{
LogSwitchPublishFailed(logger, ex, entityId, key.Server);
LogDevicePublishFailed(logger, ex, entityId, key.Server);
}
}

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

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

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

[LoggerMessage(Level = LogLevel.Warning,
Message = "Publishing a smart-switch state for entity {EntityId} on server {ServerId} failed.")]
private static partial void LogSwitchPublishFailed(ILogger logger,
Message = "Publishing a smart-device state for entity {EntityId} on server {ServerId} failed.")]
private static partial void LogDevicePublishFailed(ILogger logger,
Exception exception,
ulong entityId,
Guid serverId);
Expand Down
Loading
Loading