diff --git a/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml
new file mode 100644
index 00000000000..96f136abf0e
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs
new file mode 100644
index 00000000000..b0d0365ef6b
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs
@@ -0,0 +1,215 @@
+using Content.Client.Stylesheets;
+using Content.Shared.Atmos;
+using Content.Shared.Atmos.Components;
+using Content.Shared.Atmos.Monitor;
+using Content.Shared.FixedPoint;
+using Content.Shared.Temperature;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Map;
+using System.Linq;
+
+namespace Content.Client.Atmos.Consoles;
+
+[GenerateTypedNameReferences]
+public sealed partial class AtmosAlarmEntryContainer : BoxContainer
+{
+ public NetEntity NetEntity;
+ public EntityCoordinates? Coordinates;
+
+ private readonly IEntityManager _entManager;
+ private readonly IResourceCache _cache;
+
+ private Dictionary _alarmStrings = new Dictionary()
+ {
+ [AtmosAlarmType.Invalid] = "atmos-alerts-window-invalid-state",
+ [AtmosAlarmType.Normal] = "atmos-alerts-window-normal-state",
+ [AtmosAlarmType.Warning] = "atmos-alerts-window-warning-state",
+ [AtmosAlarmType.Danger] = "atmos-alerts-window-danger-state",
+ };
+
+ private Dictionary _gasShorthands = new Dictionary()
+ {
+ [Gas.Ammonia] = "NH₃",
+ [Gas.CarbonDioxide] = "CO₂",
+ [Gas.Frezon] = "F",
+ [Gas.Nitrogen] = "N₂",
+ [Gas.NitrousOxide] = "N₂O",
+ [Gas.Oxygen] = "O₂",
+ [Gas.Plasma] = "P",
+ [Gas.Tritium] = "T",
+ [Gas.WaterVapor] = "H₂O",
+ };
+
+ public AtmosAlarmEntryContainer(NetEntity uid, EntityCoordinates? coordinates)
+ {
+ RobustXamlLoader.Load(this);
+
+ _entManager = IoCManager.Resolve();
+ _cache = IoCManager.Resolve();
+
+ NetEntity = uid;
+ Coordinates = coordinates;
+
+ // Load fonts
+ var headerFont = new VectorFont(_cache.GetResource("/Fonts/NotoSans/NotoSans-Bold.ttf"), 11);
+ var normalFont = new VectorFont(_cache.GetResource("/Fonts/NotoSansDisplay/NotoSansDisplay-Regular.ttf"), 11);
+ var smallFont = new VectorFont(_cache.GetResource("/Fonts/NotoSans/NotoSans-Regular.ttf"), 10);
+
+ // Set fonts
+ TemperatureHeaderLabel.FontOverride = headerFont;
+ PressureHeaderLabel.FontOverride = headerFont;
+ OxygenationHeaderLabel.FontOverride = headerFont;
+ GasesHeaderLabel.FontOverride = headerFont;
+
+ TemperatureLabel.FontOverride = normalFont;
+ PressureLabel.FontOverride = normalFont;
+ OxygenationLabel.FontOverride = normalFont;
+
+ NoDataLabel.FontOverride = headerFont;
+
+ SilenceCheckBox.Label.FontOverride = smallFont;
+ SilenceCheckBox.Label.FontColorOverride = Color.DarkGray;
+ }
+
+ public void UpdateEntry(AtmosAlertsComputerEntry entry, bool isFocus, AtmosAlertsFocusDeviceData? focusData = null)
+ {
+ NetEntity = entry.NetEntity;
+ Coordinates = _entManager.GetCoordinates(entry.Coordinates);
+
+ // Load fonts
+ var normalFont = new VectorFont(_cache.GetResource("/Fonts/NotoSansDisplay/NotoSansDisplay-Regular.ttf"), 11);
+
+ // Update alarm state
+ if (!_alarmStrings.TryGetValue(entry.AlarmState, out var alarmString))
+ alarmString = "atmos-alerts-window-invalid-state";
+
+ AlarmStateLabel.Text = Loc.GetString(alarmString);
+ AlarmStateLabel.FontColorOverride = GetAlarmStateColor(entry.AlarmState);
+
+ // Update alarm name
+ AlarmNameLabel.Text = Loc.GetString("atmos-alerts-window-alarm-label", ("name", entry.EntityName), ("address", entry.Address));
+
+ // Focus updates
+ FocusContainer.Visible = isFocus;
+
+ if (isFocus)
+ SetAsFocus();
+ else
+ RemoveAsFocus();
+
+ if (isFocus && entry.Group == AtmosAlertsComputerGroup.AirAlarm)
+ {
+ MainDataContainer.Visible = (entry.AlarmState != AtmosAlarmType.Invalid);
+ NoDataLabel.Visible = (entry.AlarmState == AtmosAlarmType.Invalid);
+
+ if (focusData != null)
+ {
+ // Update temperature
+ var tempK = (FixedPoint2)focusData.Value.TemperatureData.Item1;
+ var tempC = (FixedPoint2)TemperatureHelpers.KelvinToCelsius(tempK.Float());
+
+ TemperatureLabel.Text = Loc.GetString("atmos-alerts-window-temperature-value", ("valueInC", tempC), ("valueInK", tempK));
+ TemperatureLabel.FontColorOverride = GetAlarmStateColor(focusData.Value.TemperatureData.Item2);
+
+ // Update pressure
+ PressureLabel.Text = Loc.GetString("atmos-alerts-window-pressure-value", ("value", (FixedPoint2)focusData.Value.PressureData.Item1));
+ PressureLabel.FontColorOverride = GetAlarmStateColor(focusData.Value.PressureData.Item2);
+
+ // Update oxygenation
+ var oxygenPercent = (FixedPoint2)0f;
+ var oxygenAlert = AtmosAlarmType.Invalid;
+
+ if (focusData.Value.GasData.TryGetValue(Gas.Oxygen, out var oxygenData))
+ {
+ oxygenPercent = oxygenData.Item2 * 100f;
+ oxygenAlert = oxygenData.Item3;
+ }
+
+ OxygenationLabel.Text = Loc.GetString("atmos-alerts-window-oxygenation-value", ("value", oxygenPercent));
+ OxygenationLabel.FontColorOverride = GetAlarmStateColor(oxygenAlert);
+
+ // Update other present gases
+ GasGridContainer.RemoveAllChildren();
+
+ var gasData = focusData.Value.GasData.Where(g => g.Key != Gas.Oxygen);
+
+ if (gasData.Count() == 0)
+ {
+ // No other gases
+ var gasLabel = new Label()
+ {
+ Text = Loc.GetString("atmos-alerts-window-other-gases-value-nil"),
+ FontOverride = normalFont,
+ FontColorOverride = StyleNano.DisabledFore,
+ HorizontalAlignment = HAlignment.Center,
+ VerticalAlignment = VAlignment.Center,
+ HorizontalExpand = true,
+ Margin = new Thickness(0, 2, 0, 0),
+ SetHeight = 24f,
+ };
+
+ GasGridContainer.AddChild(gasLabel);
+ }
+
+ else
+ {
+ // Add an entry for each gas
+ foreach ((var gas, (var mol, var percent, var alert)) in gasData)
+ {
+ var gasPercent = (FixedPoint2)0f;
+ gasPercent = percent * 100f;
+
+ if (!_gasShorthands.TryGetValue(gas, out var gasShorthand))
+ gasShorthand = "X";
+
+ var gasLabel = new Label()
+ {
+ Text = Loc.GetString("atmos-alerts-window-other-gases-value", ("shorthand", gasShorthand), ("value", gasPercent)),
+ FontOverride = normalFont,
+ FontColorOverride = GetAlarmStateColor(alert),
+ HorizontalAlignment = HAlignment.Center,
+ VerticalAlignment = VAlignment.Center,
+ HorizontalExpand = true,
+ Margin = new Thickness(0, 2, 0, 0),
+ SetHeight = 24f,
+ };
+
+ GasGridContainer.AddChild(gasLabel);
+ }
+ }
+ }
+ }
+ }
+
+ public void SetAsFocus()
+ {
+ FocusButton.AddStyleClass(StyleNano.StyleClassButtonColorGreen);
+ ArrowTexture.TexturePath = "/Textures/Interface/Nano/inverted_triangle.svg.png";
+ }
+
+ public void RemoveAsFocus()
+ {
+ FocusButton.RemoveStyleClass(StyleNano.StyleClassButtonColorGreen);
+ ArrowTexture.TexturePath = "/Textures/Interface/Nano/triangle_right.png";
+ FocusContainer.Visible = false;
+ }
+
+ private Color GetAlarmStateColor(AtmosAlarmType alarmType)
+ {
+ switch (alarmType)
+ {
+ case AtmosAlarmType.Normal:
+ return StyleNano.GoodGreenFore;
+ case AtmosAlarmType.Warning:
+ return StyleNano.ConcerningOrangeFore;
+ case AtmosAlarmType.Danger:
+ return StyleNano.DangerousRedFore;
+ }
+
+ return StyleNano.DisabledFore;
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/Atmos/Consoles/AtmosAlertsComputerBoundUserInterface.cs b/Content.Client/Atmos/Consoles/AtmosAlertsComputerBoundUserInterface.cs
new file mode 100644
index 00000000000..08cae979b9b
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlertsComputerBoundUserInterface.cs
@@ -0,0 +1,52 @@
+using Content.Shared.Atmos.Components;
+
+namespace Content.Client.Atmos.Consoles;
+
+public sealed class AtmosAlertsComputerBoundUserInterface : BoundUserInterface
+{
+ [ViewVariables]
+ private AtmosAlertsComputerWindow? _menu;
+
+ public AtmosAlertsComputerBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) { }
+
+ protected override void Open()
+ {
+ _menu = new AtmosAlertsComputerWindow(this, Owner);
+ _menu.OpenCentered();
+ _menu.OnClose += Close;
+
+ EntMan.TryGetComponent(Owner, out var xform);
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ var castState = (AtmosAlertsComputerBoundInterfaceState) state;
+
+ if (castState == null)
+ return;
+
+ EntMan.TryGetComponent(Owner, out var xform);
+ _menu?.UpdateUI(xform?.Coordinates, castState.AirAlarms, castState.FireAlarms, castState.FocusData);
+ }
+
+ public void SendFocusChangeMessage(NetEntity? netEntity)
+ {
+ SendMessage(new AtmosAlertsComputerFocusChangeMessage(netEntity));
+ }
+
+ public void SendDeviceSilencedMessage(NetEntity netEntity, bool silenceDevice)
+ {
+ SendMessage(new AtmosAlertsComputerDeviceSilencedMessage(netEntity, silenceDevice));
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing)
+ return;
+
+ _menu?.Dispose();
+ }
+}
diff --git a/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml
new file mode 100644
index 00000000000..8824a776ee6
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs
new file mode 100644
index 00000000000..a55321833cd
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs
@@ -0,0 +1,550 @@
+using Content.Client.Message;
+using Content.Client.Pinpointer.UI;
+using Content.Client.Stylesheets;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Atmos.Components;
+using Content.Shared.Atmos.Monitor;
+using Content.Shared.Pinpointer;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Map;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using Robust.Shared.ContentPack;
+using Robust.Shared.Prototypes;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace Content.Client.Atmos.Consoles;
+
+[GenerateTypedNameReferences]
+public sealed partial class AtmosAlertsComputerWindow : FancyWindow
+{
+ private readonly IEntityManager _entManager;
+ private readonly SpriteSystem _spriteSystem;
+
+ private EntityUid? _owner;
+ private NetEntity? _trackedEntity;
+
+ private AtmosAlertsComputerEntry[]? _airAlarms = null;
+ private AtmosAlertsComputerEntry[]? _fireAlarms = null;
+ private IEnumerable? _allAlarms = null;
+
+ private IEnumerable? _activeAlarms = null;
+ private Dictionary _deviceSilencingProgress = new();
+
+ public event Action? SendFocusChangeMessageAction;
+ public event Action? SendDeviceSilencedMessageAction;
+
+ private bool _autoScrollActive = false;
+ private bool _autoScrollAwaitsUpdate = false;
+
+ private const float SilencingDuration = 2.5f;
+
+ public AtmosAlertsComputerWindow(AtmosAlertsComputerBoundUserInterface userInterface, EntityUid? owner)
+ {
+ RobustXamlLoader.Load(this);
+ _entManager = IoCManager.Resolve();
+ _spriteSystem = _entManager.System();
+
+ // Pass the owner to nav map
+ _owner = owner;
+ NavMap.Owner = _owner;
+
+ // Set nav map colors
+ NavMap.WallColor = new Color(64, 64, 64);
+ NavMap.TileColor = Color.DimGray * NavMap.WallColor;
+
+ // Set nav map grid uid
+ var stationName = Loc.GetString("atmos-alerts-window-unknown-location");
+
+ if (_entManager.TryGetComponent(owner, out var xform))
+ {
+ NavMap.MapUid = xform.GridUid;
+
+ // Assign station name
+ if (_entManager.TryGetComponent(xform.GridUid, out var stationMetaData))
+ stationName = stationMetaData.EntityName;
+
+ var msg = new FormattedMessage();
+ msg.AddMarkup(Loc.GetString("atmos-alerts-window-station-name", ("stationName", stationName)));
+
+ StationName.SetMessage(msg);
+ }
+
+ else
+ {
+ StationName.SetMessage(stationName);
+ NavMap.Visible = false;
+ }
+
+ // Set trackable entity selected action
+ NavMap.TrackedEntitySelectedAction += SetTrackedEntityFromNavMap;
+
+ // Update nav map
+ NavMap.ForceNavMapUpdate();
+
+ // Set tab container headers
+ MasterTabContainer.SetTabTitle(0, Loc.GetString("atmos-alerts-window-tab-no-alerts"));
+ MasterTabContainer.SetTabTitle(1, Loc.GetString("atmos-alerts-window-tab-air-alarms"));
+ MasterTabContainer.SetTabTitle(2, Loc.GetString("atmos-alerts-window-tab-fire-alarms"));
+
+ // Set UI toggles
+ ShowInactiveAlarms.OnToggled += _ => OnShowAlarmsToggled(ShowInactiveAlarms, AtmosAlarmType.Invalid);
+ ShowNormalAlarms.OnToggled += _ => OnShowAlarmsToggled(ShowNormalAlarms, AtmosAlarmType.Normal);
+ ShowWarningAlarms.OnToggled += _ => OnShowAlarmsToggled(ShowWarningAlarms, AtmosAlarmType.Warning);
+ ShowDangerAlarms.OnToggled += _ => OnShowAlarmsToggled(ShowDangerAlarms, AtmosAlarmType.Danger);
+
+ // Set atmos monitoring message action
+ SendFocusChangeMessageAction += userInterface.SendFocusChangeMessage;
+ SendDeviceSilencedMessageAction += userInterface.SendDeviceSilencedMessage;
+ }
+
+ #region Toggle handling
+
+ private void OnShowAlarmsToggled(CheckBox toggle, AtmosAlarmType toggledAlarmState)
+ {
+ if (_owner == null)
+ return;
+
+ if (!_entManager.TryGetComponent(_owner.Value, out var console))
+ return;
+
+ foreach (var device in console.AtmosDevices)
+ {
+ var alarmState = GetAlarmState(device.NetEntity);
+
+ if (toggledAlarmState != alarmState)
+ continue;
+
+ if (toggle.Pressed)
+ AddTrackedEntityToNavMap(device, alarmState);
+
+ else
+ NavMap.TrackedEntities.Remove(device.NetEntity);
+ }
+ }
+
+ private void OnSilenceAlertsToggled(NetEntity netEntity, bool toggleState)
+ {
+ if (!_entManager.TryGetComponent(_owner, out var console))
+ return;
+
+ if (toggleState)
+ _deviceSilencingProgress[netEntity] = SilencingDuration;
+
+ else
+ _deviceSilencingProgress.Remove(netEntity);
+
+ foreach (AtmosAlarmEntryContainer entryContainer in AlertsTable.Children)
+ {
+ if (entryContainer.NetEntity == netEntity)
+ entryContainer.SilenceAlarmProgressBar.Visible = toggleState;
+ }
+
+ SendDeviceSilencedMessageAction?.Invoke(netEntity, toggleState);
+ }
+
+ #endregion
+
+ public void UpdateUI(EntityCoordinates? consoleCoords, AtmosAlertsComputerEntry[] airAlarms, AtmosAlertsComputerEntry[] fireAlarms, AtmosAlertsFocusDeviceData? focusData)
+ {
+ if (_owner == null)
+ return;
+
+ if (!_entManager.TryGetComponent(_owner.Value, out var console))
+ return;
+
+ if (_trackedEntity != focusData?.NetEntity)
+ {
+ SendFocusChangeMessageAction?.Invoke(_trackedEntity);
+ focusData = null;
+ }
+
+ // Retain alarm data for use inbetween updates
+ _airAlarms = airAlarms;
+ _fireAlarms = fireAlarms;
+ _allAlarms = airAlarms.Concat(fireAlarms);
+
+ var silenced = console.SilencedDevices;
+
+ _activeAlarms = _allAlarms.Where(x => x.AlarmState > AtmosAlarmType.Normal &&
+ (!silenced.Contains(x.NetEntity) || _deviceSilencingProgress.ContainsKey(x.NetEntity)));
+
+ // Reset nav map data
+ NavMap.TrackedCoordinates.Clear();
+ NavMap.TrackedEntities.Clear();
+
+ // Add tracked entities to the nav map
+ foreach (var device in console.AtmosDevices)
+ {
+ if (!NavMap.Visible)
+ continue;
+
+ var alarmState = GetAlarmState(device.NetEntity);
+
+ if (_trackedEntity != device.NetEntity)
+ {
+ // Skip air alarms if the appropriate overlay is off
+ if (!ShowInactiveAlarms.Pressed && alarmState == AtmosAlarmType.Invalid)
+ continue;
+
+ if (!ShowNormalAlarms.Pressed && alarmState == AtmosAlarmType.Normal)
+ continue;
+
+ if (!ShowWarningAlarms.Pressed && alarmState == AtmosAlarmType.Warning)
+ continue;
+
+ if (!ShowDangerAlarms.Pressed && alarmState == AtmosAlarmType.Danger)
+ continue;
+ }
+
+ AddTrackedEntityToNavMap(device, alarmState);
+ }
+
+ // Show the monitor location
+ var consoleUid = _entManager.GetNetEntity(_owner);
+
+ if (consoleCoords != null && consoleUid != null)
+ {
+ var texture = _spriteSystem.Frame0(new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_circle.png")));
+ var blip = new NavMapBlip(consoleCoords.Value, texture, Color.Cyan, true, false);
+ NavMap.TrackedEntities[consoleUid.Value] = blip;
+ }
+
+ // Update the nav map
+ NavMap.ForceNavMapUpdate();
+
+ // Clear excess children from the tables
+ var activeAlarmCount = _activeAlarms.Count();
+
+ while (AlertsTable.ChildCount > activeAlarmCount)
+ AlertsTable.RemoveChild(AlertsTable.GetChild(AlertsTable.ChildCount - 1));
+
+ while (AirAlarmsTable.ChildCount > airAlarms.Length)
+ AirAlarmsTable.RemoveChild(AirAlarmsTable.GetChild(AirAlarmsTable.ChildCount - 1));
+
+ while (FireAlarmsTable.ChildCount > fireAlarms.Length)
+ FireAlarmsTable.RemoveChild(FireAlarmsTable.GetChild(FireAlarmsTable.ChildCount - 1));
+
+ // Update all entries in each table
+ for (int index = 0; index < _activeAlarms.Count(); index++)
+ {
+ var entry = _activeAlarms.ElementAt(index);
+ UpdateUIEntry(entry, index, AlertsTable, console, focusData);
+ }
+
+ for (int index = 0; index < airAlarms.Count(); index++)
+ {
+ var entry = airAlarms.ElementAt(index);
+ UpdateUIEntry(entry, index, AirAlarmsTable, console, focusData);
+ }
+
+ for (int index = 0; index < fireAlarms.Count(); index++)
+ {
+ var entry = fireAlarms.ElementAt(index);
+ UpdateUIEntry(entry, index, FireAlarmsTable, console, focusData);
+ }
+
+ // If no alerts are active, display a message
+ if (MasterTabContainer.CurrentTab == 0 && activeAlarmCount == 0)
+ {
+ var label = new RichTextLabel()
+ {
+ HorizontalExpand = true,
+ VerticalExpand = true,
+ HorizontalAlignment = HAlignment.Center,
+ VerticalAlignment = VAlignment.Center,
+ };
+
+ label.SetMarkup(Loc.GetString("atmos-alerts-window-no-active-alerts", ("color", StyleNano.GoodGreenFore.ToHexNoAlpha())));
+
+ AlertsTable.AddChild(label);
+ }
+
+ // Update the alerts tab with the number of active alerts
+ if (activeAlarmCount == 0)
+ MasterTabContainer.SetTabTitle(0, Loc.GetString("atmos-alerts-window-tab-no-alerts"));
+
+ else
+ MasterTabContainer.SetTabTitle(0, Loc.GetString("atmos-alerts-window-tab-alerts", ("value", activeAlarmCount)));
+
+ // Auto-scroll re-enable
+ if (_autoScrollAwaitsUpdate)
+ {
+ _autoScrollActive = true;
+ _autoScrollAwaitsUpdate = false;
+ }
+ }
+
+ private void AddTrackedEntityToNavMap(AtmosAlertsDeviceNavMapData metaData, AtmosAlarmType alarmState)
+ {
+ var data = GetBlipTexture(alarmState);
+
+ if (data == null)
+ return;
+
+ var texture = data.Value.Item1;
+ var color = data.Value.Item2;
+ var coords = _entManager.GetCoordinates(metaData.NetCoordinates);
+
+ if (_trackedEntity != null && _trackedEntity != metaData.NetEntity)
+ color *= Color.DimGray;
+
+ var selectable = true;
+ var blip = new NavMapBlip(coords, _spriteSystem.Frame0(texture), color, _trackedEntity == metaData.NetEntity, selectable);
+
+ NavMap.TrackedEntities[metaData.NetEntity] = blip;
+ }
+
+ private void UpdateUIEntry(AtmosAlertsComputerEntry entry, int index, Control table, AtmosAlertsComputerComponent console, AtmosAlertsFocusDeviceData? focusData = null)
+ {
+ // Make new UI entry if required
+ if (index >= table.ChildCount)
+ {
+ var newEntryContainer = new AtmosAlarmEntryContainer(entry.NetEntity, _entManager.GetCoordinates(entry.Coordinates));
+
+ // On click
+ newEntryContainer.FocusButton.OnButtonUp += args =>
+ {
+ if (_trackedEntity == newEntryContainer.NetEntity)
+ {
+ _trackedEntity = null;
+ }
+
+ else
+ {
+ _trackedEntity = newEntryContainer.NetEntity;
+
+ if (newEntryContainer.Coordinates != null)
+ NavMap.CenterToCoordinates(newEntryContainer.Coordinates.Value);
+ }
+
+ // Send message to console that the focus has changed
+ SendFocusChangeMessageAction?.Invoke(_trackedEntity);
+
+ // Update affected UI elements across all tables
+ UpdateConsoleTable(console, AlertsTable, _trackedEntity);
+ UpdateConsoleTable(console, AirAlarmsTable, _trackedEntity);
+ UpdateConsoleTable(console, FireAlarmsTable, _trackedEntity);
+ };
+
+ // On toggling the silence check box
+ newEntryContainer.SilenceCheckBox.OnToggled += _ => OnSilenceAlertsToggled(newEntryContainer.NetEntity, newEntryContainer.SilenceCheckBox.Pressed);
+
+ // Add the entry to the current table
+ table.AddChild(newEntryContainer);
+ }
+
+ // Update values and UI elements
+ var tableChild = table.GetChild(index);
+
+ if (tableChild is not AtmosAlarmEntryContainer)
+ {
+ table.RemoveChild(tableChild);
+ UpdateUIEntry(entry, index, table, console, focusData);
+
+ return;
+ }
+
+ var entryContainer = (AtmosAlarmEntryContainer)tableChild;
+
+ entryContainer.UpdateEntry(entry, entry.NetEntity == _trackedEntity, focusData);
+
+ if (_trackedEntity != entry.NetEntity)
+ {
+ var silenced = console.SilencedDevices;
+ entryContainer.SilenceCheckBox.Pressed = (silenced.Contains(entry.NetEntity) || _deviceSilencingProgress.ContainsKey(entry.NetEntity));
+ }
+
+ entryContainer.SilenceAlarmProgressBar.Visible = (table == AlertsTable && _deviceSilencingProgress.ContainsKey(entry.NetEntity));
+ }
+
+ private void UpdateConsoleTable(AtmosAlertsComputerComponent console, Control table, NetEntity? currTrackedEntity)
+ {
+ foreach (var tableChild in table.Children)
+ {
+ if (tableChild is not AtmosAlarmEntryContainer)
+ continue;
+
+ var entryContainer = (AtmosAlarmEntryContainer)tableChild;
+
+ if (entryContainer.NetEntity != currTrackedEntity)
+ entryContainer.RemoveAsFocus();
+
+ else if (entryContainer.NetEntity == currTrackedEntity)
+ entryContainer.SetAsFocus();
+ }
+ }
+
+ private void SetTrackedEntityFromNavMap(NetEntity? netEntity)
+ {
+ if (netEntity == null)
+ return;
+
+ if (!_entManager.TryGetComponent(_owner, out var console))
+ return;
+
+ _trackedEntity = netEntity;
+
+ if (netEntity != null)
+ {
+ // Tab switching
+ if (MasterTabContainer.CurrentTab != 0 || _activeAlarms?.Any(x => x.NetEntity == netEntity) == false)
+ {
+ var device = console.AtmosDevices.FirstOrNull(x => x.NetEntity == netEntity);
+
+ switch (device?.Group)
+ {
+ case AtmosAlertsComputerGroup.AirAlarm:
+ MasterTabContainer.CurrentTab = 1; break;
+ case AtmosAlertsComputerGroup.FireAlarm:
+ MasterTabContainer.CurrentTab = 2; break;
+ }
+ }
+
+ // Get the scroll position of the selected entity on the selected button the UI
+ ActivateAutoScrollToFocus();
+ }
+
+ // Send message to console that the focus has changed
+ SendFocusChangeMessageAction?.Invoke(_trackedEntity);
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ AutoScrollToFocus();
+
+ // Device silencing update
+ foreach ((var device, var remainingTime) in _deviceSilencingProgress)
+ {
+ var t = remainingTime - args.DeltaSeconds;
+
+ if (t <= 0)
+ {
+ _deviceSilencingProgress.Remove(device);
+
+ if (device == _trackedEntity)
+ _trackedEntity = null;
+ }
+
+ else
+ _deviceSilencingProgress[device] = t;
+ }
+ }
+
+ private void ActivateAutoScrollToFocus()
+ {
+ _autoScrollActive = false;
+ _autoScrollAwaitsUpdate = true;
+ }
+
+ private void AutoScrollToFocus()
+ {
+ if (!_autoScrollActive)
+ return;
+
+ var scroll = MasterTabContainer.Children.ElementAt(MasterTabContainer.CurrentTab) as ScrollContainer;
+ if (scroll == null)
+ return;
+
+ if (!TryGetVerticalScrollbar(scroll, out var vScrollbar))
+ return;
+
+ if (!TryGetNextScrollPosition(out float? nextScrollPosition))
+ return;
+
+ vScrollbar.ValueTarget = nextScrollPosition.Value;
+
+ if (MathHelper.CloseToPercent(vScrollbar.Value, vScrollbar.ValueTarget))
+ _autoScrollActive = false;
+ }
+
+ private bool TryGetVerticalScrollbar(ScrollContainer scroll, [NotNullWhen(true)] out VScrollBar? vScrollBar)
+ {
+ vScrollBar = null;
+
+ foreach (var child in scroll.Children)
+ {
+ if (child is not VScrollBar)
+ continue;
+
+ var castChild = child as VScrollBar;
+
+ if (castChild != null)
+ {
+ vScrollBar = castChild;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private bool TryGetNextScrollPosition([NotNullWhen(true)] out float? nextScrollPosition)
+ {
+ nextScrollPosition = null;
+
+ var scroll = MasterTabContainer.Children.ElementAt(MasterTabContainer.CurrentTab) as ScrollContainer;
+ if (scroll == null)
+ return false;
+
+ var container = scroll.Children.ElementAt(0) as BoxContainer;
+ if (container == null || container.Children.Count() == 0)
+ return false;
+
+ // Exit if the heights of the children haven't been initialized yet
+ if (!container.Children.Any(x => x.Height > 0))
+ return false;
+
+ nextScrollPosition = 0;
+
+ foreach (var control in container.Children)
+ {
+ if (control == null || control is not AtmosAlarmEntryContainer)
+ continue;
+
+ if (((AtmosAlarmEntryContainer)control).NetEntity == _trackedEntity)
+ return true;
+
+ nextScrollPosition += control.Height;
+ }
+
+ // Failed to find control
+ nextScrollPosition = null;
+
+ return false;
+ }
+
+ private AtmosAlarmType GetAlarmState(NetEntity netEntity)
+ {
+ var alarmState = _allAlarms?.FirstOrNull(x => x.NetEntity == netEntity)?.AlarmState;
+
+ if (alarmState == null)
+ return AtmosAlarmType.Invalid;
+
+ return alarmState.Value;
+ }
+
+ private (SpriteSpecifier.Texture, Color)? GetBlipTexture(AtmosAlarmType alarmState)
+ {
+ (SpriteSpecifier.Texture, Color)? output = null;
+
+ switch (alarmState)
+ {
+ case AtmosAlarmType.Invalid:
+ output = (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_circle.png")), StyleNano.DisabledFore); break;
+ case AtmosAlarmType.Normal:
+ output = (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_circle.png")), Color.LimeGreen); break;
+ case AtmosAlarmType.Warning:
+ output = (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_triangle.png")), new Color(255, 182, 72)); break;
+ case AtmosAlarmType.Danger:
+ output = (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_square.png")), new Color(255, 67, 67)); break;
+ }
+
+ return output;
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/Atmos/Consoles/AtmosAlertsComputerSystem.cs b/Content.Server/Atmos/Consoles/AtmosAlertsComputerSystem.cs
new file mode 100644
index 00000000000..758fde88f13
--- /dev/null
+++ b/Content.Server/Atmos/Consoles/AtmosAlertsComputerSystem.cs
@@ -0,0 +1,356 @@
+using Content.Server.Atmos.Monitor.Components;
+using Content.Server.DeviceNetwork.Components;
+using Content.Server.Power.Components;
+using Content.Shared.Atmos;
+using Content.Shared.Atmos.Components;
+using Content.Shared.Atmos.Consoles;
+using Content.Shared.Atmos.Monitor;
+using Content.Shared.Atmos.Monitor.Components;
+using Content.Shared.Pinpointer;
+using Robust.Server.GameObjects;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Player;
+using Robust.Shared.ContentPack;
+using Robust.Shared.Prototypes;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Content.Shared.Access.Components;
+using Content.Shared.Database;
+using Content.Shared.NameIdentifier;
+using Content.Shared.Stacks;
+using JetBrains.Annotations;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Atmos.Monitor.Systems;
+
+public sealed class AtmosAlertsComputerSystem : SharedAtmosAlertsComputerSystem
+{
+ [Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
+ [Dependency] private readonly AirAlarmSystem _airAlarmSystem = default!;
+ [Dependency] private readonly AtmosDeviceNetworkSystem _atmosDevNet = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+
+ private const float UpdateTime = 1.0f;
+
+ // Note: this data does not need to be saved
+ private float _updateTimer = 1.0f;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ // Console events
+ SubscribeLocalEvent(OnConsoleInit);
+ SubscribeLocalEvent(OnConsoleParentChanged);
+ SubscribeLocalEvent(OnFocusChangedMessage);
+
+ // Grid events
+ SubscribeLocalEvent(OnGridSplit);
+ SubscribeLocalEvent(OnDeviceAnchorChanged);
+ }
+
+ #region Event handling
+
+ private void OnConsoleInit(EntityUid uid, AtmosAlertsComputerComponent component, ComponentInit args)
+ {
+ InitalizeConsole(uid, component);
+ }
+
+ private void OnConsoleParentChanged(EntityUid uid, AtmosAlertsComputerComponent component, EntParentChangedMessage args)
+ {
+ InitalizeConsole(uid, component);
+ }
+
+ private void OnFocusChangedMessage(EntityUid uid, AtmosAlertsComputerComponent component, AtmosAlertsComputerFocusChangeMessage args)
+ {
+ component.FocusDevice = args.FocusDevice;
+ }
+
+ private void OnGridSplit(ref GridSplitEvent args)
+ {
+ // Collect grids
+ var allGrids = args.NewGrids.ToList();
+
+ if (!allGrids.Contains(args.Grid))
+ allGrids.Add(args.Grid);
+
+ // Update atmos monitoring consoles that stand upon an updated grid
+ var query = AllEntityQuery();
+ while (query.MoveNext(out var ent, out var entConsole, out var entXform))
+ {
+ if (entXform.GridUid == null)
+ continue;
+
+ if (!allGrids.Contains(entXform.GridUid.Value))
+ continue;
+
+ InitalizeConsole(ent, entConsole);
+ }
+ }
+
+ private void OnDeviceAnchorChanged(EntityUid uid, AtmosAlertsDeviceComponent component, AnchorStateChangedEvent args)
+ {
+ var xform = Transform(uid);
+ var gridUid = xform.GridUid;
+
+ if (gridUid == null)
+ return;
+
+ if (!TryGetAtmosDeviceNavMapData(uid, component, xform, gridUid.Value, out var data))
+ return;
+
+ var netEntity = EntityManager.GetNetEntity(uid);
+
+ var query = AllEntityQuery();
+ while (query.MoveNext(out var ent, out var entConsole, out var entXform))
+ {
+ if (gridUid != entXform.GridUid)
+ continue;
+
+ if (args.Anchored)
+ entConsole.AtmosDevices.Add(data.Value);
+
+ else if (!args.Anchored)
+ entConsole.AtmosDevices.RemoveWhere(x => x.NetEntity == netEntity);
+ }
+ }
+
+ #endregion
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ _updateTimer += frameTime;
+
+ if (_updateTimer >= UpdateTime)
+ {
+ _updateTimer -= UpdateTime;
+
+ // Keep a list of UI entries for each gridUid, in case multiple consoles stand on the same grid
+ var airAlarmEntriesForEachGrid = new Dictionary();
+ var fireAlarmEntriesForEachGrid = new Dictionary();
+
+ var query = AllEntityQuery();
+ while (query.MoveNext(out var ent, out var entConsole, out var entXform))
+ {
+ if (entXform?.GridUid == null)
+ continue;
+
+ // Make a list of alarm state data for all the air and fire alarms on the grid
+ if (!airAlarmEntriesForEachGrid.TryGetValue(entXform.GridUid.Value, out var airAlarmEntries))
+ {
+ airAlarmEntries = GetAlarmStateData(entXform.GridUid.Value, AtmosAlertsComputerGroup.AirAlarm).ToArray();
+ airAlarmEntriesForEachGrid[entXform.GridUid.Value] = airAlarmEntries;
+ }
+
+ if (!fireAlarmEntriesForEachGrid.TryGetValue(entXform.GridUid.Value, out var fireAlarmEntries))
+ {
+ fireAlarmEntries = GetAlarmStateData(entXform.GridUid.Value, AtmosAlertsComputerGroup.FireAlarm).ToArray();
+ fireAlarmEntriesForEachGrid[entXform.GridUid.Value] = fireAlarmEntries;
+ }
+
+ // Determine the highest level of alert for the console (based on non-silenced alarms)
+ var highestAlert = AtmosAlarmType.Invalid;
+
+ foreach (var entry in airAlarmEntries)
+ {
+ if (entry.AlarmState > highestAlert && !entConsole.SilencedDevices.Contains(entry.NetEntity))
+ highestAlert = entry.AlarmState;
+ }
+
+ foreach (var entry in fireAlarmEntries)
+ {
+ if (entry.AlarmState > highestAlert && !entConsole.SilencedDevices.Contains(entry.NetEntity))
+ highestAlert = entry.AlarmState;
+ }
+
+ // Update the appearance of the console based on the highest recorded level of alert
+ if (TryComp(ent, out var entAppearance))
+ _appearance.SetData(ent, AtmosAlertsComputerVisuals.ComputerLayerScreen, (int) highestAlert, entAppearance);
+
+ // If the console UI is open, send UI data to each subscribed session
+ UpdateUIState(ent, airAlarmEntries, fireAlarmEntries, entConsole, entXform);
+ }
+ }
+ }
+
+ public void UpdateUIState
+ (EntityUid uid,
+ AtmosAlertsComputerEntry[] airAlarmStateData,
+ AtmosAlertsComputerEntry[] fireAlarmStateData,
+ AtmosAlertsComputerComponent component,
+ TransformComponent xform)
+ {
+ if (!_uiSystem.IsUiOpen(uid, AtmosAlertsComputerUiKey.Key))
+ return;
+
+ var gridUid = xform.GridUid!.Value;
+
+ if (!HasComp(gridUid))
+ return;
+
+ // The grid must have a NavMapComponent to visualize the map in the UI
+ EnsureComp(gridUid);
+
+ // Gathering remaining data to be send to the client
+ var focusAlarmData = GetFocusAlarmData(uid, GetEntity(component.FocusDevice), gridUid);
+
+ // Set the UI state
+ _uiSystem.TrySetUiState(uid, AtmosAlertsComputerUiKey.Key,
+ new AtmosAlertsComputerBoundInterfaceState(airAlarmStateData, fireAlarmStateData, focusAlarmData));
+ }
+
+ private List GetAlarmStateData(EntityUid gridUid, AtmosAlertsComputerGroup group)
+ {
+ var alarmStateData = new List();
+
+ var queryAlarms = AllEntityQuery();
+ while (queryAlarms.MoveNext(out var ent, out var entDevice, out var entAtmosAlarmable, out var entDeviceNetwork, out var entXform))
+ {
+ if (entXform.GridUid != gridUid)
+ continue;
+
+ if (!entXform.Anchored)
+ continue;
+
+ if (entDevice.Group != group)
+ continue;
+
+ // If emagged, change the alarm type to normal
+ var alarmState = (entAtmosAlarmable.LastAlarmState == AtmosAlarmType.Emagged) ? AtmosAlarmType.Normal : entAtmosAlarmable.LastAlarmState;
+
+ // Unpowered alarms can't sound
+ if (TryComp(ent, out var entAPCPower) && !entAPCPower.Powered)
+ alarmState = AtmosAlarmType.Invalid;
+
+ var entry = new AtmosAlertsComputerEntry
+ (GetNetEntity(ent),
+ GetNetCoordinates(entXform.Coordinates),
+ entDevice.Group,
+ alarmState,
+ MetaData(ent).EntityName,
+ entDeviceNetwork.Address);
+
+ alarmStateData.Add(entry);
+ }
+
+ return alarmStateData;
+ }
+
+ private AtmosAlertsFocusDeviceData? GetFocusAlarmData(EntityUid uid, EntityUid? focusDevice, EntityUid gridUid)
+ {
+ if (focusDevice == null)
+ return null;
+
+ var focusDeviceXform = Transform(focusDevice.Value);
+
+ if (!focusDeviceXform.Anchored ||
+ focusDeviceXform.GridUid != gridUid ||
+ !TryComp(focusDevice.Value, out var focusDeviceAirAlarm))
+ {
+ return null;
+ }
+
+ // Force update the sensors attached to the alarm
+ if (!_uiSystem.IsUiOpen(focusDevice.Value, SharedAirAlarmInterfaceKey.Key))
+ {
+ _atmosDevNet.Register(focusDevice.Value, null);
+ _atmosDevNet.Sync(focusDevice.Value, null);
+
+ foreach ((var address, var _) in focusDeviceAirAlarm.SensorData)
+ _atmosDevNet.Register(uid, null);
+ }
+
+ // Get the sensor data
+ var temperatureData = (_airAlarmSystem.CalculateTemperatureAverage(focusDeviceAirAlarm), AtmosAlarmType.Normal);
+ var pressureData = (_airAlarmSystem.CalculatePressureAverage(focusDeviceAirAlarm), AtmosAlarmType.Normal);
+ var gasData = new Dictionary();
+
+ foreach ((var address, var sensorData) in focusDeviceAirAlarm.SensorData)
+ {
+ if (sensorData.TemperatureThreshold.CheckThreshold(sensorData.Temperature, out var temperatureState) &&
+ (int) temperatureState > (int) temperatureData.Item2)
+ {
+ temperatureData = (temperatureData.Item1, temperatureState);
+ }
+
+ if (sensorData.PressureThreshold.CheckThreshold(sensorData.Pressure, out var pressureState) &&
+ (int) pressureState > (int) pressureData.Item2)
+ {
+ pressureData = (pressureData.Item1, pressureState);
+ }
+
+ if (focusDeviceAirAlarm.SensorData.Sum(g => g.Value.TotalMoles) > 1e-8)
+ {
+ foreach ((var gas, var threshold) in sensorData.GasThresholds)
+ {
+ if (!gasData.ContainsKey(gas))
+ {
+ float mol = _airAlarmSystem.CalculateGasMolarConcentrationAverage(focusDeviceAirAlarm, gas, out var percentage);
+
+ if (mol < 1e-8)
+ continue;
+
+ gasData[gas] = (mol, percentage, AtmosAlarmType.Normal);
+ }
+
+ if (threshold.CheckThreshold(gasData[gas].Item2, out var gasState) &&
+ (int) gasState > (int) gasData[gas].Item3)
+ {
+ gasData[gas] = (gasData[gas].Item1, gasData[gas].Item2, gasState);
+ }
+ }
+ }
+ }
+
+ return new AtmosAlertsFocusDeviceData(GetNetEntity(focusDevice.Value), temperatureData, pressureData, gasData);
+ }
+
+ private HashSet GetAllAtmosDeviceNavMapData(EntityUid gridUid)
+ {
+ var atmosDeviceNavMapData = new HashSet();
+
+ var query = AllEntityQuery();
+ while (query.MoveNext(out var ent, out var entComponent, out var entXform))
+ {
+ if (TryGetAtmosDeviceNavMapData(ent, entComponent, entXform, gridUid, out var data))
+ atmosDeviceNavMapData.Add(data.Value);
+ }
+
+ return atmosDeviceNavMapData;
+ }
+
+ private bool TryGetAtmosDeviceNavMapData
+ (EntityUid uid,
+ AtmosAlertsDeviceComponent component,
+ TransformComponent xform,
+ EntityUid gridUid,
+ [NotNullWhen(true)] out AtmosAlertsDeviceNavMapData? output)
+ {
+ output = null;
+
+ if (xform.GridUid != gridUid)
+ return false;
+
+ if (!xform.Anchored)
+ return false;
+
+ output = new AtmosAlertsDeviceNavMapData(GetNetEntity(uid), GetNetCoordinates(xform.Coordinates), component.Group);
+
+ return true;
+ }
+
+ private void InitalizeConsole(EntityUid uid, AtmosAlertsComputerComponent component)
+ {
+ var xform = Transform(uid);
+
+ if (xform.GridUid == null)
+ return;
+
+ var grid = xform.GridUid.Value;
+ component.AtmosDevices = GetAllAtmosDeviceNavMapData(grid);
+
+ Dirty(uid, component);
+ }
+}
diff --git a/Content.Server/Atmos/Monitor/Systems/AirAlarmSystem.cs b/Content.Server/Atmos/Monitor/Systems/AirAlarmSystem.cs
index 2922d0796a9..240f21ad42e 100644
--- a/Content.Server/Atmos/Monitor/Systems/AirAlarmSystem.cs
+++ b/Content.Server/Atmos/Monitor/Systems/AirAlarmSystem.cs
@@ -593,6 +593,21 @@ public float CalculateTemperatureAverage(AirAlarmComponent alarm)
: 0f;
}
+ public float CalculateGasMolarConcentrationAverage(AirAlarmComponent alarm, Gas gas, out float percentage)
+ {
+ percentage = 0f;
+
+ var data = alarm.SensorData.Values.SelectMany(v => v.Gases.Where(g => g.Key == gas));
+
+ if (data.Count() == 0)
+ return 0f;
+
+ var averageMol = data.Select(kvp => kvp.Value).Average();
+ percentage = data.Select(kvp => kvp.Value).Sum() / alarm.SensorData.Values.Select(v => v.TotalMoles).Sum();
+
+ return averageMol;
+ }
+
public void UpdateUI(EntityUid uid, AirAlarmComponent? alarm = null, DeviceNetworkComponent? devNet = null, AtmosAlarmableComponent? alarmable = null)
{
if (!Resolve(uid, ref alarm, ref devNet, ref alarmable))
diff --git a/Content.Shared/Atmos/Consoles/Components/AtmosAlertsComputerComponent.cs b/Content.Shared/Atmos/Consoles/Components/AtmosAlertsComputerComponent.cs
new file mode 100644
index 00000000000..d64c8907afb
--- /dev/null
+++ b/Content.Shared/Atmos/Consoles/Components/AtmosAlertsComputerComponent.cs
@@ -0,0 +1,235 @@
+using Content.Shared.Atmos.Consoles;
+using Content.Shared.Atmos.Monitor;
+using Robust.Shared.GameStates;
+using Robust.Shared.Map;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Atmos.Components;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SharedAtmosAlertsComputerSystem))]
+public sealed partial class AtmosAlertsComputerComponent : Component
+{
+ ///
+ /// The current entity of interest (selected via the console UI)
+ ///
+ [ViewVariables]
+ public NetEntity? FocusDevice;
+
+ ///
+ /// A list of all the atmos devices that will be used to populate the nav map
+ ///
+ [ViewVariables, AutoNetworkedField]
+ public HashSet AtmosDevices = new();
+
+ ///
+ /// A list of all the air alarms that have had their alerts silenced on this particular console
+ ///
+ [ViewVariables, AutoNetworkedField]
+ public HashSet SilencedDevices = new();
+}
+
+[Serializable, NetSerializable]
+public struct AtmosAlertsDeviceNavMapData
+{
+ ///
+ /// The entity in question
+ ///
+ public NetEntity NetEntity;
+
+ ///
+ /// Location of the entity
+ ///
+ public NetCoordinates NetCoordinates;
+
+ ///
+ /// Used to determine what map icons to use
+ ///
+ public AtmosAlertsComputerGroup Group;
+
+ ///
+ /// Populate the atmos monitoring console nav map with a single entity
+ ///
+ public AtmosAlertsDeviceNavMapData(NetEntity netEntity, NetCoordinates netCoordinates, AtmosAlertsComputerGroup group)
+ {
+ NetEntity = netEntity;
+ NetCoordinates = netCoordinates;
+ Group = group;
+ }
+}
+
+[Serializable, NetSerializable]
+public struct AtmosAlertsFocusDeviceData
+{
+ ///
+ /// Focus entity
+ ///
+ public NetEntity NetEntity;
+
+ ///
+ /// Temperature (K) and related alert state
+ ///
+ public (float, AtmosAlarmType) TemperatureData;
+
+ ///
+ /// Pressure (kPA) and related alert state
+ ///
+ public (float, AtmosAlarmType) PressureData;
+
+ ///
+ /// Moles, percentage, and related alert state, for all detected gases
+ ///
+ public Dictionary GasData;
+
+ ///
+ /// Populates the atmos monitoring console focus entry with atmospheric data
+ ///
+ public AtmosAlertsFocusDeviceData
+ (NetEntity netEntity,
+ (float, AtmosAlarmType) temperatureData,
+ (float, AtmosAlarmType) pressureData,
+ Dictionary gasData)
+ {
+ NetEntity = netEntity;
+ TemperatureData = temperatureData;
+ PressureData = pressureData;
+ GasData = gasData;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class AtmosAlertsComputerBoundInterfaceState : BoundUserInterfaceState
+{
+ ///
+ /// A list of all air alarms
+ ///
+ public AtmosAlertsComputerEntry[] AirAlarms;
+
+ ///
+ /// A list of all fire alarms
+ ///
+ public AtmosAlertsComputerEntry[] FireAlarms;
+
+ ///
+ /// Data for the UI focus (if applicable)
+ ///
+ public AtmosAlertsFocusDeviceData? FocusData;
+
+ ///
+ /// Sends data from the server to the client to populate the atmos monitoring console UI
+ ///
+ public AtmosAlertsComputerBoundInterfaceState(AtmosAlertsComputerEntry[] airAlarms, AtmosAlertsComputerEntry[] fireAlarms, AtmosAlertsFocusDeviceData? focusData)
+ {
+ AirAlarms = airAlarms;
+ FireAlarms = fireAlarms;
+ FocusData = focusData;
+ }
+}
+
+[Serializable, NetSerializable]
+public struct AtmosAlertsComputerEntry
+{
+ ///
+ /// The entity in question
+ ///
+ public NetEntity NetEntity;
+
+ ///
+ /// Location of the entity
+ ///
+ public NetCoordinates Coordinates;
+
+ ///
+ /// The type of entity
+ ///
+ public AtmosAlertsComputerGroup Group;
+
+ ///
+ /// Current alarm state
+ ///
+ public AtmosAlarmType AlarmState;
+
+ ///
+ /// Localised device name
+ ///
+ public string EntityName;
+
+ ///
+ /// Device network address
+ ///
+ public string Address;
+
+ ///
+ /// Used to populate the atmos monitoring console UI with data from a single air alarm
+ ///
+ public AtmosAlertsComputerEntry
+ (NetEntity entity,
+ NetCoordinates coordinates,
+ AtmosAlertsComputerGroup group,
+ AtmosAlarmType alarmState,
+ string entityName,
+ string address)
+ {
+ NetEntity = entity;
+ Coordinates = coordinates;
+ Group = group;
+ AlarmState = alarmState;
+ EntityName = entityName;
+ Address = address;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class AtmosAlertsComputerFocusChangeMessage : BoundUserInterfaceMessage
+{
+ public NetEntity? FocusDevice;
+
+ ///
+ /// Used to inform the server that the specified focus for the atmos monitoring console has been changed by the client
+ ///
+ public AtmosAlertsComputerFocusChangeMessage(NetEntity? focusDevice)
+ {
+ FocusDevice = focusDevice;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class AtmosAlertsComputerDeviceSilencedMessage : BoundUserInterfaceMessage
+{
+ public NetEntity AtmosDevice;
+ public bool SilenceDevice = true;
+
+ ///
+ /// Used to inform the server that the client has silenced alerts from the specified device to this atmos monitoring console
+ ///
+ public AtmosAlertsComputerDeviceSilencedMessage(NetEntity atmosDevice, bool silenceDevice = true)
+ {
+ AtmosDevice = atmosDevice;
+ SilenceDevice = silenceDevice;
+ }
+}
+
+///
+/// List of all the different atmos device groups
+///
+public enum AtmosAlertsComputerGroup
+{
+ Invalid,
+ AirAlarm,
+ FireAlarm,
+}
+
+[NetSerializable, Serializable]
+public enum AtmosAlertsComputerVisuals
+{
+ ComputerLayerScreen,
+}
+
+///
+/// UI key associated with the atmos monitoring console
+///
+[Serializable, NetSerializable]
+public enum AtmosAlertsComputerUiKey
+{
+ Key
+}
diff --git a/Content.Shared/Atmos/Consoles/Components/AtmosAlertsDeviceComponent.cs b/Content.Shared/Atmos/Consoles/Components/AtmosAlertsDeviceComponent.cs
new file mode 100644
index 00000000000..881d60b084c
--- /dev/null
+++ b/Content.Shared/Atmos/Consoles/Components/AtmosAlertsDeviceComponent.cs
@@ -0,0 +1,14 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Atmos.Components;
+
+[RegisterComponent, NetworkedComponent]
+[Access([])]
+public sealed partial class AtmosAlertsDeviceComponent : Component
+{
+ ///
+ /// The group that the entity belongs to
+ ///
+ [DataField, ViewVariables]
+ public AtmosAlertsComputerGroup Group;
+}
diff --git a/Content.Shared/Atmos/Consoles/SharedAtmosAlertsComputerSystem.cs b/Content.Shared/Atmos/Consoles/SharedAtmosAlertsComputerSystem.cs
new file mode 100644
index 00000000000..7e2b2b04670
--- /dev/null
+++ b/Content.Shared/Atmos/Consoles/SharedAtmosAlertsComputerSystem.cs
@@ -0,0 +1,24 @@
+using Content.Shared.Atmos.Components;
+
+namespace Content.Shared.Atmos.Consoles;
+
+public abstract partial class SharedAtmosAlertsComputerSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnDeviceSilencedMessage);
+ }
+
+ private void OnDeviceSilencedMessage(EntityUid uid, AtmosAlertsComputerComponent component, AtmosAlertsComputerDeviceSilencedMessage args)
+ {
+ if (args.SilenceDevice)
+ component.SilencedDevices.Add(args.AtmosDevice);
+
+ else
+ component.SilencedDevices.Remove(args.AtmosDevice);
+
+ Dirty(uid, component);
+ }
+}
diff --git a/Resources/Locale/en-US/atmos/atmos-alerts-console.ftl b/Resources/Locale/en-US/atmos/atmos-alerts-console.ftl
new file mode 100644
index 00000000000..a1640c5e9d5
--- /dev/null
+++ b/Resources/Locale/en-US/atmos/atmos-alerts-console.ftl
@@ -0,0 +1,35 @@
+atmos-alerts-window-title = Atmospheric Alerts Computer
+atmos-alerts-window-station-name = [color=white][font size=14]{$stationName}[/font][/color]
+atmos-alerts-window-unknown-location = Unknown location
+
+atmos-alerts-window-tab-no-alerts = Alerts
+atmos-alerts-window-tab-alerts = Alerts ({$value})
+atmos-alerts-window-tab-air-alarms = Air alarms
+atmos-alerts-window-tab-fire-alarms = Fire alarms
+
+atmos-alerts-window-alarm-label = {CAPITALIZE($name)} ({$address})
+atmos-alerts-window-temperature-label = Temperature
+atmos-alerts-window-temperature-value = {$valueInC} °C ({$valueInK} K)
+atmos-alerts-window-pressure-label = Pressure
+atmos-alerts-window-pressure-value = {$value} kPa
+atmos-alerts-window-oxygenation-label = Oxygenation
+atmos-alerts-window-oxygenation-value = {$value}%
+atmos-alerts-window-other-gases-label = Other present gases
+atmos-alerts-window-other-gases-value = {$shorthand} ({$value}%)
+atmos-alerts-window-other-gases-value-nil = None
+atmos-alerts-window-silence-alerts = Silence alerts from this alarm
+
+atmos-alerts-window-label-alert-types = Alert levels:
+atmos-alerts-window-normal-state = Normal
+atmos-alerts-window-warning-state = Warning
+atmos-alerts-window-danger-state = Danger!
+atmos-alerts-window-invalid-state = Inactive
+
+atmos-alerts-window-no-active-alerts = [font size=16][color=white]No active alerts -[/color] [color={$color}]situation normal[/color][/font]
+atmos-alerts-window-no-data-available = No data available
+atmos-alerts-window-alerts-being-silenced = Silencing alerts...
+
+atmos-alerts-window-toggle-overlays = Toggle alarm display
+
+atmos-alerts-window-flavor-left = Contact an atmospheric technician for assistance
+atmos-alerts-window-flavor-right = v1.8
\ No newline at end of file
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml
index 1210f302fcc..35a941e759c 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml
@@ -21,8 +21,8 @@
- type: entity
parent: BaseComputerCircuitboard
id: AlertsComputerCircuitboard
- name: alerts computer board
- description: A computer printed circuit board for an alerts computer.
+ name: atmospheric alerts computer board
+ description: A computer printed circuit board for an atmospheric alerts computer.
components:
- type: ComputerBoard
prototype: ComputerAlert
diff --git a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
index 4fd2129f7a5..39fd34016b9 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
@@ -1,8 +1,8 @@
- type: entity
parent: BaseComputer
id: ComputerAlert
- name: alerts computer
- description: Used to access the station's automated alert system.
+ name: atmospheric alerts computer
+ description: Used to access the station's automated atmospheric alert system.
components:
- type: Computer
board: AlertsComputerCircuitboard
@@ -13,9 +13,33 @@
- map: ["computerLayerKeyboard"]
state: generic_keyboard
- map: ["computerLayerScreen"]
- state: alert-2
+ state: alert-0
- map: ["computerLayerKeys"]
state: atmos_key
+ - type: GenericVisualizer
+ visuals:
+ enum.ComputerVisuals.Powered:
+ computerLayerScreen:
+ True: { visible: true, shader: unshaded }
+ False: { visible: false }
+ computerLayerKeys:
+ True: { visible: true, shader: unshaded }
+ False: { visible: true, shader: shaded }
+ enum.AtmosAlertsComputerVisuals.ComputerLayerScreen:
+ computerLayerScreen:
+ 0: { state: alert-0 }
+ 1: { state: alert-0 }
+ 2: { state: alert-1 }
+ 3: { state: alert-2 }
+ 4: { state: alert-2 }
+ - type: AtmosAlertsComputer
+ - type: ActivatableUI
+ singleUser: true
+ key: enum.AtmosAlertsComputerUiKey.Key
+ - type: UserInterface
+ interfaces:
+ - key: enum.AtmosAlertsComputerUiKey.Key
+ type: AtmosAlertsComputerBoundUserInterface
- type: entity
parent: BaseComputer
diff --git a/Resources/Prototypes/Entities/Structures/Wallmounts/air_alarm.yml b/Resources/Prototypes/Entities/Structures/Wallmounts/air_alarm.yml
index 62f2000593b..70e204f694e 100644
--- a/Resources/Prototypes/Entities/Structures/Wallmounts/air_alarm.yml
+++ b/Resources/Prototypes/Entities/Structures/Wallmounts/air_alarm.yml
@@ -53,6 +53,8 @@
- AirAlarm
- type: AtmosDevice
- type: AirAlarm
+ - type: AtmosAlertsDevice
+ group: AirAlarm
- type: Clickable
- type: InteractionOutline
- type: UserInterface
diff --git a/Resources/Prototypes/Entities/Structures/Wallmounts/fire_alarm.yml b/Resources/Prototypes/Entities/Structures/Wallmounts/fire_alarm.yml
index 05988fbc217..c39adcde3b6 100644
--- a/Resources/Prototypes/Entities/Structures/Wallmounts/fire_alarm.yml
+++ b/Resources/Prototypes/Entities/Structures/Wallmounts/fire_alarm.yml
@@ -42,6 +42,8 @@
- type: Clickable
- type: InteractionOutline
- type: FireAlarm
+ - type: AtmosAlertsDevice
+ group: FireAlarm
- type: ContainerFill
containers:
board: [ FireAlarmElectronics ]
diff --git a/Resources/Textures/Interface/AtmosMonitoring/status_bg.png b/Resources/Textures/Interface/AtmosMonitoring/status_bg.png
new file mode 100644
index 00000000000..165a9b9d9f1
Binary files /dev/null and b/Resources/Textures/Interface/AtmosMonitoring/status_bg.png differ