Camera map (#39684)

* Camera map

* I hope this helps

* Review 1

* Review 2

* Review 3

* Review 4

* Review 5

* Colorblind mode support

* Review 6

* Change design

* Map wire

* Logic fix

* Fix a terrible mistake

* Fix

* Fix 2

* Small rename

* More fix

* Better removal

* And another fix

* Will it work?

* It is literally pointless

* some small things
This commit is contained in:
B_Kirill
2026-01-16 07:21:55 +10:00
committed by GitHub
parent 8fb3e138a9
commit b14964398b
12 changed files with 519 additions and 37 deletions

View File

@@ -34,11 +34,17 @@ public sealed class SurveillanceCameraMonitorBoundUserInterface : BoundUserInter
_window.SubnetRefresh += OnSubnetRefresh;
_window.CameraSwitchTimer += OnCameraSwitchTimer;
_window.CameraDisconnect += OnCameraDisconnect;
var xform = EntMan.GetComponent<TransformComponent>(Owner);
var gridUid = xform.GridUid ?? xform.MapUid;
if (gridUid is not null)
_window?.SetMap(gridUid.Value);
}
private void OnCameraSelected(string address)
private void OnCameraSelected(string address, string? subnet)
{
SendMessage(new SurveillanceCameraMonitorSwitchMessage(address));
SendMessage(new SurveillanceCameraMonitorSwitchMessage(address, subnet));
}
private void OnSubnetRequest(string subnet)

View File

@@ -1,25 +1,71 @@
<DefaultWindow xmlns="https://spacestation14.io"
xmlns:viewport="clr-namespace:Content.Client.Viewport"
xmlns:local="clr-namespace:Content.Client.SurveillanceCamera.UI"
Title="{Loc 'surveillance-camera-monitor-ui-window'}">
<BoxContainer Orientation="Horizontal">
<BoxContainer Orientation="Vertical" MinWidth="350" VerticalExpand="True">
<!-- lazy -->
<OptionButton Name="SubnetSelector" />
<Button Name="SubnetRefreshButton" Text="{Loc 'surveillance-camera-monitor-ui-refresh-subnets'}" />
<ScrollContainer VerticalExpand="True">
<ItemList Name="SubnetList" />
</ScrollContainer>
<Button Name="CameraRefreshButton" Text="{Loc 'surveillance-camera-monitor-ui-refresh-cameras'}" />
<Button Name="CameraDisconnectButton" Text="{Loc 'surveillance-camera-monitor-ui-disconnect'}" />
<Label Name="CameraStatus" />
<BoxContainer>
<!-- Panel with tabs -->
<BoxContainer Orientation="Vertical" MinWidth="350">
<TabContainer Name="ViewModeTabs" VerticalExpand="True">
<!-- Camera list tab -->
<BoxContainer Name="{Loc 'surveillance-camera-monitor-ui-tab-list'}" Orientation="Vertical">
<OptionButton Name="SubnetSelector"/>
<Button Name="SubnetRefreshButton" Text="{Loc 'surveillance-camera-monitor-ui-refresh-subnets'}"/>
<ScrollContainer VerticalExpand="True">
<ItemList Name="SubnetList"/>
</ScrollContainer>
<Button Name="CameraRefreshButton" Text="{Loc 'surveillance-camera-monitor-ui-refresh-cameras'}"/>
</BoxContainer>
<!-- Map view tab -->
<BoxContainer Name="{Loc 'surveillance-camera-monitor-ui-tab-map'}" Orientation="Vertical" VerticalExpand="True">
<local:SurveillanceCameraNavMapControl Name="CameraMap"
VerticalExpand="True"
HorizontalExpand="True"
MinSize="350 350"/>
<!-- Map legend -->
<BoxContainer Name="LegendContainer" Margin="0 10">
<TextureRect Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/NavMap/beveled_triangle.png"
Modulate="#FF00FF"
SetSize="20 20"
Margin="10 0 5 0"/>
<Label Text="{Loc 'surveillance-camera-monitor-ui-legend-active'}"/>
<TextureRect Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/NavMap/beveled_triangle.png"
SetSize="20 20"
Modulate="#fbff19ff"
Margin="10 0 5 0"/>
<Label Text="{Loc 'surveillance-camera-monitor-ui-legend-selected'}"/>
<TextureRect Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/NavMap/beveled_circle.png"
SetSize="20 20"
Modulate="#a09f9fff"
Margin="10 0 5 0"/>
<Label Text="{Loc 'surveillance-camera-monitor-ui-legend-inactive'}"/>
<TextureRect Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/NavMap/beveled_square.png"
SetSize="20 20"
Modulate="#fa1f1fff"
Margin="10 0 5 0"/>
<Label Text="{Loc 'surveillance-camera-monitor-ui-legend-invalid'}"/>
</BoxContainer>
<Button Name="SubnetRefreshButtonMap" Text="{Loc 'surveillance-camera-monitor-ui-refresh-subnets'}"/>
</BoxContainer>
</TabContainer>
<Button Name="CameraDisconnectButton" Text="{Loc 'surveillance-camera-monitor-ui-disconnect'}"/>
</BoxContainer>
<!-- Right panel with camera view -->
<BoxContainer Orientation="Vertical" HorizontalExpand="True">
<Label Name="CameraStatus"/>
<Control VerticalExpand="True" Margin="5" Name="CameraViewBox">
<viewport:ScalingViewport Name="CameraView" MinSize="500 500" MouseFilter="Ignore"/>
<TextureRect MinSize="500 500" Name="CameraViewBackground" />
</Control>
</BoxContainer>
<Control VerticalExpand="True" HorizontalExpand="True" Margin="5 5 5 5" Name="CameraViewBox">
<viewport:ScalingViewport Name="CameraView"
VerticalExpand="True"
HorizontalExpand="True"
MinSize="500 500"
MouseFilter="Ignore" />
<TextureRect VerticalExpand="True" HorizontalExpand="True" MinSize="500 500" Name="CameraViewBackground" />
</Control>
</BoxContainer>
</DefaultWindow>

View File

@@ -3,6 +3,7 @@ using Content.Client.Resources;
using Content.Client.Viewport;
using Content.Shared.DeviceNetwork;
using Content.Shared.SurveillanceCamera;
using Content.Shared.SurveillanceCamera.Components;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
@@ -21,8 +22,15 @@ public sealed partial class SurveillanceCameraMonitorWindow : DefaultWindow
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
/// <summary>
/// Triggered when a camera is selected.
/// First parameter contains the camera's address.
/// Second optional parameter contains a subnet - if possible, the monitor will switch to this subnet.
/// </summary>
public event Action<string, string?>? CameraSelected;
public event Action<string>? CameraSelected;
public event Action<string>? SubnetOpened;
public event Action? CameraRefresh;
public event Action? SubnetRefresh;
@@ -33,6 +41,7 @@ public sealed partial class SurveillanceCameraMonitorWindow : DefaultWindow
private bool _isSwitching;
private readonly FixedEye _defaultEye = new();
private readonly Dictionary<string, int> _subnetMap = new();
private EntityUid? _mapUid;
private string? SelectedSubnet
{
@@ -68,11 +77,15 @@ public sealed partial class SurveillanceCameraMonitorWindow : DefaultWindow
SubnetSelector.OnItemSelected += args =>
{
// piss
SubnetOpened!((string) args.Button.GetItemMetadata(args.Id)!);
SubnetOpened?.Invoke((string) args.Button.GetItemMetadata(args.Id)!);
};
SubnetRefreshButton.OnPressed += _ => SubnetRefresh!();
CameraRefreshButton.OnPressed += _ => CameraRefresh!();
CameraDisconnectButton.OnPressed += _ => CameraDisconnect!();
SubnetRefreshButton.OnPressed += _ => SubnetRefresh?.Invoke();
SubnetRefreshButtonMap.OnPressed += _ => SubnetRefresh?.Invoke();
CameraRefreshButton.OnPressed += _ => CameraRefresh?.Invoke();
CameraDisconnectButton.OnPressed += _ => CameraDisconnect?.Invoke();
CameraMap.EnableCameraSelection = true;
CameraMap.CameraSelected += OnCameraMapSelected;
}
@@ -80,6 +93,9 @@ public sealed partial class SurveillanceCameraMonitorWindow : DefaultWindow
// pass it here so that the UI can change its view.
public void UpdateState(IEye? eye, HashSet<string> subnets, string activeAddress, string activeSubnet, Dictionary<string, string> cameras)
{
CameraMap.SetActiveCameraAddress(activeAddress);
CameraMap.SetAvailableSubnets(subnets);
_currentAddress = activeAddress;
SetCameraView(eye);
@@ -189,6 +205,25 @@ public sealed partial class SurveillanceCameraMonitorWindow : DefaultWindow
private void OnSubnetListSelect(ItemList.ItemListSelectedEventArgs args)
{
CameraSelected!((string) SubnetList[args.ItemIndex].Metadata!);
CameraSelected!((string) SubnetList[args.ItemIndex].Metadata!, null);
}
public void SetMap(EntityUid mapUid)
{
CameraMap.MapUid = _mapUid = mapUid;
}
private void OnCameraMapSelected(NetEntity netEntity)
{
if (_mapUid is null || !_entityManager.TryGetComponent<SurveillanceCameraMapComponent>(_mapUid.Value, out var mapComp))
return;
if (!mapComp.Cameras.TryGetValue(netEntity, out var marker) || !marker.Active)
return;
if (!string.IsNullOrEmpty(marker.Address))
CameraSelected?.Invoke(marker.Address, marker.Subnet);
else
_entityManager.RaisePredictiveEvent(new RequestCameraMarkerUpdateMessage(netEntity));
}
}

View File

@@ -0,0 +1,130 @@
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Shared.Map;
using Content.Client.Pinpointer.UI;
using Content.Client.Resources;
using Content.Shared.SurveillanceCamera.Components;
namespace Content.Client.SurveillanceCamera.UI;
public sealed class SurveillanceCameraNavMapControl : NavMapControl
{
[Dependency] private readonly IEntityManager _entityManager = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
private static readonly Color CameraActiveColor = Color.FromHex("#FF00FF");
private static readonly Color CameraInactiveColor = Color.FromHex("#a09f9fff");
private static readonly Color CameraSelectedColor = Color.FromHex("#fbff19ff");
private static readonly Color CameraInvalidColor = Color.FromHex("#fa1f1fff");
private readonly Texture _activeTexture;
private readonly Texture _inactiveTexture;
private readonly Texture _selectedTexture;
private readonly Texture _invalidTexture;
private string _activeCameraAddress = string.Empty;
private HashSet<string> _availableSubnets = new();
private (Dictionary<NetEntity, CameraMarker> Cameras, string ActiveAddress, HashSet<string> AvailableSubnets) _lastState;
public bool EnableCameraSelection { get; set; }
public event Action<NetEntity>? CameraSelected;
public SurveillanceCameraNavMapControl()
{
IoCManager.InjectDependencies(this);
_activeTexture = _resourceCache.GetTexture("/Textures/Interface/NavMap/beveled_triangle.png");
_selectedTexture = _activeTexture;
_inactiveTexture = _resourceCache.GetTexture("/Textures/Interface/NavMap/beveled_circle.png");
_invalidTexture = _resourceCache.GetTexture("/Textures/Interface/NavMap/beveled_square.png");
TrackedEntitySelectedAction += entity =>
{
if (entity.HasValue)
CameraSelected?.Invoke(entity.Value);
};
}
public void SetActiveCameraAddress(string address)
{
if (_activeCameraAddress == address)
return;
_activeCameraAddress = address;
ForceNavMapUpdate();
}
public void SetAvailableSubnets(HashSet<string> subnets)
{
if (_availableSubnets.SetEquals(subnets))
return;
_availableSubnets = subnets;
ForceNavMapUpdate();
}
protected override void UpdateNavMap()
{
base.UpdateNavMap();
if (MapUid is null || !_entityManager.TryGetComponent<SurveillanceCameraMapComponent>(MapUid, out var mapComp))
return;
var currentState = (mapComp.Cameras, _activeCameraAddress, _availableSubnets);
if (_lastState.Equals(currentState))
return;
_lastState = currentState;
UpdateCameraMarkers(mapComp);
}
private void UpdateCameraMarkers(SurveillanceCameraMapComponent mapComp)
{
TrackedEntities.Clear();
if (MapUid is null)
return;
foreach (var (netEntity, marker) in mapComp.Cameras)
{
if (!marker.Visible || !_availableSubnets.Contains(marker.Subnet))
continue;
var coords = new EntityCoordinates(MapUid.Value, marker.Position);
Texture texture;
Color color;
if (string.IsNullOrEmpty(marker.Address))
{
color = CameraInvalidColor;
texture = _invalidTexture;
}
else if (marker.Address == _activeCameraAddress)
{
color = CameraSelectedColor;
texture = _selectedTexture;
}
else if (marker.Active)
{
color = CameraActiveColor;
texture = _activeTexture;
}
else
{
color = CameraInactiveColor;
texture = _inactiveTexture;
}
TrackedEntities[netEntity] = new NavMapBlip(
coords,
texture,
color,
false,
EnableCameraSelection
);
}
}
}

View File

@@ -0,0 +1,38 @@
using Content.Server.Wires;
using Content.Shared.SurveillanceCamera.Components;
using Content.Shared.Wires;
namespace Content.Server.SurveillanceCamera;
public sealed partial class CameraMapVisibilityWireAction : ComponentWireAction<SurveillanceCameraComponent>
{
private SurveillanceCameraMapSystem _cameraMapSystem => EntityManager.System<SurveillanceCameraMapSystem>();
public override string Name { get; set; } = "wire-name-camera-map";
public override Color Color { get; set; } = Color.Teal;
public override object StatusKey => "OnMapVisibility";
public override StatusLightState? GetLightState(Wire wire, SurveillanceCameraComponent component)
{
return _cameraMapSystem.IsCameraVisible(wire.Owner)
? StatusLightState.On
: StatusLightState.Off;
}
public override bool Cut(EntityUid user, Wire wire, SurveillanceCameraComponent component)
{
_cameraMapSystem.SetCameraVisibility(wire.Owner, false);
return true;
}
public override bool Mend(EntityUid user, Wire wire, SurveillanceCameraComponent component)
{
_cameraMapSystem.SetCameraVisibility(wire.Owner, true);
return true;
}
public override void Pulse(EntityUid user, Wire wire, SurveillanceCameraComponent component)
{
}
}

View File

@@ -0,0 +1,148 @@
using System.Numerics;
using Content.Server.Power.Components;
using Content.Shared.DeviceNetwork.Components;
using Content.Shared.SurveillanceCamera.Components;
namespace Content.Server.SurveillanceCamera;
public sealed class SurveillanceCameraMapSystem : EntitySystem
{
[Dependency] private readonly SharedTransformSystem _transform = default!;
public override void Initialize()
{
SubscribeLocalEvent<SurveillanceCameraComponent, MoveEvent>(OnCameraMoved);
SubscribeLocalEvent<SurveillanceCameraComponent, EntityUnpausedEvent>(OnCameraUnpaused);
SubscribeNetworkEvent<RequestCameraMarkerUpdateMessage>(OnRequestCameraMarkerUpdate);
}
private void OnCameraUnpaused(EntityUid uid, SurveillanceCameraComponent comp, ref EntityUnpausedEvent args)
{
if (Terminating(uid))
return;
UpdateCameraMarker((uid, comp));
}
private void OnCameraMoved(EntityUid uid, SurveillanceCameraComponent comp, ref MoveEvent args)
{
if (Terminating(uid))
return;
var oldGridUid = _transform.GetGrid(args.OldPosition);
var newGridUid = _transform.GetGrid(args.NewPosition);
if (oldGridUid != newGridUid && oldGridUid is not null && !Terminating(oldGridUid.Value))
{
if (TryComp<SurveillanceCameraMapComponent>(oldGridUid, out var oldMapComp))
{
var netEntity = GetNetEntity(uid);
if (oldMapComp.Cameras.Remove(netEntity))
Dirty(oldGridUid.Value, oldMapComp);
}
}
if (newGridUid is not null && !Terminating(newGridUid.Value))
UpdateCameraMarker((uid, comp));
}
private void OnRequestCameraMarkerUpdate(RequestCameraMarkerUpdateMessage args)
{
var cameraEntity = GetEntity(args.CameraEntity);
if (TryComp<SurveillanceCameraComponent>(cameraEntity, out var comp)
&& HasComp<DeviceNetworkComponent>(cameraEntity))
UpdateCameraMarker((cameraEntity, comp));
}
/// <summary>
/// Updates camera data in the SurveillanceCameraMapComponent for the specified camera entity.
/// </summary>
public void UpdateCameraMarker(Entity<SurveillanceCameraComponent> camera)
{
var (uid, comp) = camera;
if (Terminating(uid))
return;
if (!TryComp(uid, out TransformComponent? xform) || !TryComp(uid, out DeviceNetworkComponent? deviceNet))
return;
var gridUid = xform.GridUid ?? xform.MapUid;
if (gridUid is null)
return;
var netEntity = GetNetEntity(uid);
var mapComp = EnsureComp<SurveillanceCameraMapComponent>(gridUid.Value);
var worldPos = _transform.GetWorldPosition(xform);
var gridMatrix = _transform.GetInvWorldMatrix(Transform(gridUid.Value));
var localPos = Vector2.Transform(worldPos, gridMatrix);
var address = deviceNet.Address;
var subnet = deviceNet.ReceiveFrequencyId ?? string.Empty;
var powered = CompOrNull<ApcPowerReceiverComponent>(uid)?.Powered ?? true;
var active = comp.Active && powered;
bool exists = mapComp.Cameras.TryGetValue(netEntity, out var existing);
if (exists &&
existing.Position.Equals(localPos) &&
existing.Active == active &&
existing.Address == address &&
existing.Subnet == subnet)
{
return;
}
var visible = exists ? existing.Visible : true;
mapComp.Cameras[netEntity] = new CameraMarker
{
Position = localPos,
Active = active,
Address = address,
Subnet = subnet,
Visible = visible
};
Dirty(gridUid.Value, mapComp);
}
/// <summary>
/// Sets the visibility state of a camera on the camera map.
/// </summary>
public void SetCameraVisibility(EntityUid cameraUid, bool visible)
{
if (!TryComp(cameraUid, out TransformComponent? xform))
return;
var gridUid = xform.GridUid ?? xform.MapUid;
if (gridUid == null || !TryComp<SurveillanceCameraMapComponent>(gridUid.Value, out var mapComp))
return;
var netEntity = GetNetEntity(cameraUid);
if (mapComp.Cameras.TryGetValue(netEntity, out var marker))
{
marker.Visible = visible;
mapComp.Cameras[netEntity] = marker;
Dirty(gridUid.Value, mapComp);
}
}
/// <summary>
/// Checks if a camera is currently visible on the camera map.
/// </summary>
public bool IsCameraVisible(EntityUid cameraUid)
{
if (!TryComp(cameraUid, out TransformComponent? xform))
return false;
var gridUid = xform.GridUid ?? xform.MapUid;
if (gridUid == null || !TryComp<SurveillanceCameraMapComponent>(gridUid, out var mapComp))
return false;
var netEntity = GetNetEntity(cameraUid);
return mapComp.Cameras.TryGetValue(netEntity, out var marker) && marker.Visible;
}
}

View File

@@ -185,7 +185,7 @@ public sealed class SurveillanceCameraMonitorSystem : EntitySystem
// there would be a null check here, but honestly
// whichever one is the "latest" switch message gets to
// do the switch
TrySwitchCameraByAddress(uid, message.Address, component);
TrySwitchCameraByAddress(uid, message.Address, message.CameraSubnet, component);
}
private void OnPowerChanged(EntityUid uid, SurveillanceCameraMonitorComponent component, ref PowerChangedEvent args)
@@ -426,15 +426,18 @@ public sealed class SurveillanceCameraMonitorSystem : EntitySystem
UpdateUserInterface(uid, monitor);
}
private void TrySwitchCameraByAddress(EntityUid uid, string address,
SurveillanceCameraMonitorComponent? monitor = null)
private void TrySwitchCameraByAddress(EntityUid uid, string address, string? cameraSubnet = null, SurveillanceCameraMonitorComponent? monitor = null)
{
if (!Resolve(uid, ref monitor)
|| string.IsNullOrEmpty(monitor.ActiveSubnet)
|| !monitor.KnownSubnets.TryGetValue(monitor.ActiveSubnet, out var subnetAddress))
{
if (!Resolve(uid, ref monitor))
return;
if (cameraSubnet != null && cameraSubnet != monitor.ActiveSubnet)
SetActiveSubnet(uid, cameraSubnet, monitor);
var activeSubnet = monitor.ActiveSubnet;
if (string.IsNullOrEmpty(activeSubnet) || !monitor.KnownSubnets.TryGetValue(activeSubnet, out var subnetAddress))
return;
}
var payload = new NetworkPayload()
{

View File

@@ -21,7 +21,7 @@ public sealed class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
[Dependency] private readonly UserInterfaceSystem _userInterface = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly SurveillanceCameraMapSystem _cameraMapSystem = default!;
// Pings a surveillance camera subnet. All cameras will always respond
// with a data message if they are on the same subnet.
@@ -270,6 +270,8 @@ public sealed class SurveillanceCameraSystem : SharedSurveillanceCameraSystem
}
UpdateVisuals(camera, component);
_cameraMapSystem.UpdateCameraMarker((camera, component));
}
public void AddActiveViewer(EntityUid camera, EntityUid player, EntityUid? monitor = null, SurveillanceCameraComponent? component = null, ActorComponent? actor = null)

View File

@@ -0,0 +1,64 @@
using System.Numerics;
using Robust.Shared.GameStates;
using Robust.Shared.Serialization;
namespace Content.Shared.SurveillanceCamera.Components;
/// <summary>
/// Stores surveillance camera data for the camera map.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class SurveillanceCameraMapComponent : Component
{
/// <summary>
/// Dictionary of cameras on on the current grid.
/// </summary>
[AutoNetworkedField]
public Dictionary<NetEntity, CameraMarker> Cameras = new();
}
/// <summary>
/// Represents a camera marker on the camera map.
/// </summary>
[Serializable, NetSerializable, DataDefinition]
public partial struct CameraMarker
{
/// <summary>
/// Position of the camera in local map coordinates.
/// </summary>
[DataField]
public Vector2 Position;
/// <summary>
/// Whether the camera is active.
/// </summary>
[DataField]
public bool Active;
/// <summary>
/// Network address of the camera.
/// </summary>
[DataField]
public string Address;
/// <summary>
/// Subnet the camera is connected to.
/// </summary>
[DataField]
public string Subnet;
/// <summary>
/// Should the camera be displayed on the camera map.
/// </summary>
[DataField]
public bool Visible = true;
}
/// <summary>
/// Network event for requesting camera marker updates.
/// </summary>
[Serializable, NetSerializable]
public sealed class RequestCameraMarkerUpdateMessage(NetEntity cameraEntity) : EntityEventArgs
{
public NetEntity CameraEntity { get; } = cameraEntity;
}

View File

@@ -39,10 +39,12 @@ public sealed class SurveillanceCameraMonitorUiState : BoundUserInterfaceState
public sealed class SurveillanceCameraMonitorSwitchMessage : BoundUserInterfaceMessage
{
public string Address { get; }
public string? CameraSubnet { get; }
public SurveillanceCameraMonitorSwitchMessage(string address)
public SurveillanceCameraMonitorSwitchMessage(string address, string? cameraSubnet = null)
{
Address = address;
CameraSubnet = cameraSubnet;
}
}

View File

@@ -7,7 +7,14 @@ surveillance-camera-monitor-ui-status-connecting = Connecting:
surveillance-camera-monitor-ui-status-connected = Connected:
surveillance-camera-monitor-ui-status-disconnected = Disconnected
surveillance-camera-monitor-ui-no-subnets = No Subnets
surveillance-camera-monitor-ui-tab-list = List
surveillance-camera-monitor-ui-tab-map = Map
surveillance-camera-monitor-ui-legend-active = Active
surveillance-camera-monitor-ui-legend-inactive = Inactive
surveillance-camera-monitor-ui-legend-selected = Selected
surveillance-camera-monitor-ui-legend-invalid = Invalid
surveillance-camera-setup = Setup
surveillance-camera-setup-ui-set = Set
wire-name-camera-map = MAP

View File

@@ -111,6 +111,7 @@
wires:
- !type:PowerWireAction
- !type:AiVisionWireAction
- !type:CameraMapVisibilityWireAction
- type: wireLayout
id: CryoPod