499 lines
22 KiB
Plaintext
499 lines
22 KiB
Plaintext
@page "/uns/equipment/new"
|
|
@page "/uns/equipment/{EquipmentId}"
|
|
@* Dedicated tabbed editor for a single equipment, replacing the /uns EquipmentModal. This task builds
|
|
the shell + the Details tab (lifted from EquipmentModal's EditForm) + the create→edit redirect; the
|
|
Tags / Virtual Tags / Alarms tabs render placeholders wired in later tasks. On a successful create the
|
|
page redirects to /uns/equipment/{newId} so the other tabs (disabled while new) become available. *@
|
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
|
@rendermode RenderMode.InteractiveServer
|
|
@using System.ComponentModel.DataAnnotations
|
|
@using Microsoft.AspNetCore.Components.Forms
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
|
|
@inject IUnsTreeService Svc
|
|
@inject NavigationManager Nav
|
|
|
|
<PageTitle>Equipment</PageTitle>
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">@(IsNew ? "New equipment" : (_equipment?.Name ?? EquipmentId))</h4>
|
|
<a href="/uns" class="btn btn-outline-secondary btn-sm">Back to UNS</a>
|
|
</div>
|
|
|
|
@if (_loading)
|
|
{
|
|
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
|
}
|
|
else if (!IsNew && _equipment is null)
|
|
{
|
|
<section class="panel notice rise"><span class="mono">@EquipmentId</span> not found.</section>
|
|
}
|
|
else
|
|
{
|
|
<ul class="nav nav-tabs mb-3">
|
|
<li class="nav-item"><button type="button" class="nav-link @TabClass("details")" @onclick='() => _activeTab = "details"'>Details</button></li>
|
|
<li class="nav-item"><button type="button" class="nav-link @TabClass("tags")" @onclick='() => ShowTabAsync("tags")' disabled="@IsNew">Tags</button></li>
|
|
<li class="nav-item"><button type="button" class="nav-link @TabClass("vtags")" @onclick='() => ShowTabAsync("vtags")' disabled="@IsNew">Virtual Tags</button></li>
|
|
<li class="nav-item"><button type="button" class="nav-link @TabClass("alarms")" @onclick='() => _activeTab = "alarms"' disabled="@IsNew">Alarms</button></li>
|
|
</ul>
|
|
|
|
@if (_activeTab == "details")
|
|
{
|
|
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="equipmentDetails">
|
|
<DataAnnotationsValidator />
|
|
|
|
<h6 class="text-muted">Identity</h6>
|
|
@if (!IsNew)
|
|
{
|
|
<div class="mb-3">
|
|
<label class="form-label">EquipmentId</label>
|
|
<input class="form-control form-control-sm mono" value="@_equipment?.EquipmentId" disabled />
|
|
<div class="form-text">System-generated; never operator-edited.</div>
|
|
</div>
|
|
}
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label" for="eq-name">Name</label>
|
|
<InputText id="eq-name" @bind-Value="_form.Name" class="form-control form-control-sm mono"
|
|
placeholder="machine-01" />
|
|
<div class="form-text">UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.</div>
|
|
<ValidationMessage For="@(() => _form.Name)" />
|
|
</div>
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label" for="eq-machinecode">MachineCode</label>
|
|
<InputText id="eq-machinecode" @bind-Value="_form.MachineCode" class="form-control form-control-sm mono"
|
|
placeholder="machine_001" />
|
|
<ValidationMessage For="@(() => _form.MachineCode)" />
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label" for="eq-line">UNS line</label>
|
|
<InputSelect id="eq-line" @bind-Value="_form.UnsLineId" class="form-select form-select-sm">
|
|
<option value="">— pick a line —</option>
|
|
@foreach (var (id, display) in _lineOptions)
|
|
{
|
|
<option value="@id">@display</option>
|
|
}
|
|
</InputSelect>
|
|
<ValidationMessage For="@(() => _form.UnsLineId)" />
|
|
</div>
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label" for="eq-driver">Driver instance</label>
|
|
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
|
|
<option value="">(none / driver-less)</option>
|
|
@foreach (var (id, display) in _driverOptions)
|
|
{
|
|
<option value="@id">@display</option>
|
|
}
|
|
</InputSelect>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
|
|
<InputText id="eq-ztag" @bind-Value="_form.ZTag" class="form-control form-control-sm" />
|
|
<div class="form-text">Unique fleet-wide via ExternalIdReservation.</div>
|
|
</div>
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label" for="eq-sap">SAPID</label>
|
|
<InputText id="eq-sap" @bind-Value="_form.SAPID" class="form-control form-control-sm" />
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Enabled</label>
|
|
<div class="form-check form-switch">
|
|
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
|
|
<label class="form-check-label">Surface in deployments</label>
|
|
</div>
|
|
</div>
|
|
|
|
<hr />
|
|
<h6 class="text-muted">OPC 40010 identification (optional)</h6>
|
|
<div class="row">
|
|
<div class="col-md-4 mb-3"><label class="form-label">Manufacturer</label><InputText @bind-Value="_form.Manufacturer" class="form-control form-control-sm" /></div>
|
|
<div class="col-md-4 mb-3"><label class="form-label">Model</label><InputText @bind-Value="_form.Model" class="form-control form-control-sm" /></div>
|
|
<div class="col-md-4 mb-3"><label class="form-label">SerialNumber</label><InputText @bind-Value="_form.SerialNumber" class="form-control form-control-sm" /></div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-3 mb-3"><label class="form-label">HardwareRevision</label><InputText @bind-Value="_form.HardwareRevision" class="form-control form-control-sm" /></div>
|
|
<div class="col-md-3 mb-3"><label class="form-label">SoftwareRevision</label><InputText @bind-Value="_form.SoftwareRevision" class="form-control form-control-sm" /></div>
|
|
<div class="col-md-3 mb-3"><label class="form-label">Year of construction</label><InputNumber @bind-Value="_form.YearOfConstruction" class="form-control form-control-sm" /></div>
|
|
<div class="col-md-3 mb-3"><label class="form-label">AssetLocation</label><InputText @bind-Value="_form.AssetLocation" class="form-control form-control-sm" /></div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3"><label class="form-label">ManufacturerUri</label><InputText @bind-Value="_form.ManufacturerUri" class="form-control form-control-sm mono" /></div>
|
|
<div class="col-md-6 mb-3"><label class="form-label">DeviceManualUri</label><InputText @bind-Value="_form.DeviceManualUri" class="form-control form-control-sm mono" /></div>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="text-danger small mt-2">@_error</div> }
|
|
|
|
<div class="mt-3">
|
|
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
|
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
@(IsNew ? "Create" : "Save changes")
|
|
</button>
|
|
</div>
|
|
</EditForm>
|
|
}
|
|
else if (_activeTab == "tags")
|
|
{
|
|
<div class="d-flex justify-content-end mb-2">
|
|
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button>
|
|
</div>
|
|
@if (!string.IsNullOrWhiteSpace(_tagError))
|
|
{
|
|
<div class="text-danger small mb-2">@_tagError</div>
|
|
}
|
|
@if (_tags is null)
|
|
{
|
|
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
|
}
|
|
else if (_tags.Count == 0)
|
|
{
|
|
<p class="text-muted">No tags yet.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th class="text-end">Actions</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var t in _tags)
|
|
{
|
|
<tr @key="t.TagId">
|
|
<td>@t.Name</td>
|
|
<td class="mono">@t.DriverInstanceId</td>
|
|
<td>@t.DataType</td>
|
|
<td>@t.AccessLevel</td>
|
|
<td class="text-end">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
|
|
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
|
|
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
|
|
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
|
|
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
|
|
}
|
|
else if (_activeTab == "vtags")
|
|
{
|
|
<div class="d-flex justify-content-end mb-2">
|
|
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddVirtualTag">Add virtual tag</button>
|
|
</div>
|
|
@if (!string.IsNullOrWhiteSpace(_vtagError))
|
|
{
|
|
<div class="text-danger small mb-2">@_vtagError</div>
|
|
}
|
|
@if (_vtags is null)
|
|
{
|
|
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
|
}
|
|
else if (_vtags.Count == 0)
|
|
{
|
|
<p class="text-muted">No virtual tags yet.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr><th>Name</th><th>Data type</th><th>Script</th><th>Enabled</th><th class="text-end">Actions</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var v in _vtags)
|
|
{
|
|
<tr @key="v.VirtualTagId">
|
|
<td>@v.Name</td>
|
|
<td>@v.DataType</td>
|
|
<td class="mono">@v.ScriptId</td>
|
|
<td>@(v.Enabled ? "Yes" : "No")</td>
|
|
<td class="text-end">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditVirtualTag(v.VirtualTagId)">Edit</button>
|
|
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteVirtualTag(v.VirtualTagId)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
|
|
<VirtualTagModal Visible="_vtagModalVisible" IsNew="_vtagModalIsNew" EquipmentId="@EquipmentId"
|
|
Existing="_vtagModalExisting" Scripts="_vtagScriptOptions"
|
|
OnSaved="OnVirtualTagSavedAsync" OnCancel="@(() => { _vtagModalVisible = false; })" />
|
|
}
|
|
else if (_activeTab == "alarms") { <p class="text-muted">Alarms tab — wired in a later task.</p> }
|
|
}
|
|
|
|
@code {
|
|
/// <summary>The equipment id from the route; null/empty on the <c>/new</c> route (create mode).</summary>
|
|
[Parameter] public string? EquipmentId { get; set; }
|
|
|
|
/// <summary>Optional parent line id supplied as a query string on create, used to default the line select.</summary>
|
|
[SupplyParameterFromQuery] public string? LineId { get; set; }
|
|
|
|
private bool IsNew => string.IsNullOrEmpty(EquipmentId);
|
|
|
|
private string _activeTab = "details";
|
|
private bool _loading = true;
|
|
private bool _busy;
|
|
private string? _error;
|
|
private EquipmentEditDto? _equipment;
|
|
private FormModel _form = new();
|
|
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
|
|
private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();
|
|
|
|
// --- Tags tab state. _tags is null until the tab is first activated (drives the lazy load + spinner). ---
|
|
private IReadOnlyList<EquipmentTagRow>? _tags;
|
|
private string? _tagError;
|
|
private bool _tagModalVisible;
|
|
private bool _tagModalIsNew;
|
|
private TagEditDto? _tagModalExisting;
|
|
private IReadOnlyList<(string Id, string Display, string DriverType)> _tagDriverOptions = Array.Empty<(string, string, string)>();
|
|
|
|
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
|
|
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
|
|
private string? _vtagError;
|
|
private bool _vtagModalVisible;
|
|
private bool _vtagModalIsNew;
|
|
private VirtualTagEditDto? _vtagModalExisting;
|
|
private IReadOnlyList<(string Id, string Display)> _vtagScriptOptions = Array.Empty<(string, string)>();
|
|
|
|
private string TabClass(string tab) => _activeTab == tab ? "active" : "";
|
|
|
|
/// <summary>
|
|
/// Switches to a tab, lazily loading its list on first activation. The Tags/Virtual Tags lists are
|
|
/// null until first shown (and are reset to null in OnParametersSetAsync when the equipment changes),
|
|
/// so the fetch runs once per equipment rather than on every render. Equipment is fixed to
|
|
/// <see cref="EquipmentId"/>; the tabs are disabled while IsNew so this is only ever reached with a
|
|
/// persisted equipment.
|
|
/// </summary>
|
|
private async Task ShowTabAsync(string tab)
|
|
{
|
|
_activeTab = tab;
|
|
if (IsNew) { return; }
|
|
if (tab == "tags" && _tags is null) { await ReloadTagsAsync(); }
|
|
else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); }
|
|
}
|
|
|
|
// --- Tags tab handlers (mirror GlobalUns; the owning equipment is fixed = EquipmentId) ---
|
|
|
|
private async Task ReloadTagsAsync()
|
|
{
|
|
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!);
|
|
}
|
|
|
|
private async Task OpenAddTag()
|
|
{
|
|
_tagError = null;
|
|
_tagModalIsNew = true;
|
|
_tagModalExisting = null;
|
|
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
|
|
_tagModalVisible = true;
|
|
}
|
|
|
|
private async Task OpenEditTag(string tagId)
|
|
{
|
|
_tagError = null;
|
|
var dto = await Svc.LoadTagAsync(tagId);
|
|
if (dto is null) { _tagError = "That tag no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; }
|
|
_tagModalIsNew = false;
|
|
_tagModalExisting = dto;
|
|
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
|
|
_tagModalVisible = true;
|
|
}
|
|
|
|
private async Task OnTagSavedAsync()
|
|
{
|
|
_tagModalVisible = false;
|
|
await ReloadTagsAsync();
|
|
}
|
|
|
|
private async Task DeleteTag(string tagId)
|
|
{
|
|
_tagModalVisible = false;
|
|
_tagError = null;
|
|
// Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete.
|
|
var dto = await Svc.LoadTagAsync(tagId);
|
|
if (dto is null) { await ReloadTagsAsync(); return; }
|
|
var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion);
|
|
if (r.Ok) { await ReloadTagsAsync(); }
|
|
else { _tagError = r.Error; }
|
|
}
|
|
|
|
// --- Virtual Tags tab handlers ---
|
|
|
|
private async Task ReloadVirtualTagsAsync()
|
|
{
|
|
_vtags = await Svc.LoadVirtualTagsForEquipmentAsync(EquipmentId!);
|
|
}
|
|
|
|
private async Task OpenAddVirtualTag()
|
|
{
|
|
_vtagError = null;
|
|
_vtagModalIsNew = true;
|
|
_vtagModalExisting = null;
|
|
_vtagScriptOptions = await Svc.LoadScriptsAsync();
|
|
_vtagModalVisible = true;
|
|
}
|
|
|
|
private async Task OpenEditVirtualTag(string vtagId)
|
|
{
|
|
_vtagError = null;
|
|
var dto = await Svc.LoadVirtualTagAsync(vtagId);
|
|
if (dto is null) { _vtagError = "That virtual tag no longer exists; the list was refreshed."; await ReloadVirtualTagsAsync(); return; }
|
|
_vtagModalIsNew = false;
|
|
_vtagModalExisting = dto;
|
|
_vtagScriptOptions = await Svc.LoadScriptsAsync();
|
|
_vtagModalVisible = true;
|
|
}
|
|
|
|
private async Task OnVirtualTagSavedAsync()
|
|
{
|
|
_vtagModalVisible = false;
|
|
await ReloadVirtualTagsAsync();
|
|
}
|
|
|
|
private async Task DeleteVirtualTag(string vtagId)
|
|
{
|
|
_vtagModalVisible = false;
|
|
_vtagError = null;
|
|
// Load the virtual tag fresh to capture its current RowVersion for the concurrency-guarded delete.
|
|
var dto = await Svc.LoadVirtualTagAsync(vtagId);
|
|
if (dto is null) { await ReloadVirtualTagsAsync(); return; }
|
|
var r = await Svc.DeleteVirtualTagAsync(vtagId, dto.RowVersion);
|
|
if (r.Ok) { await ReloadVirtualTagsAsync(); }
|
|
else { _vtagError = r.Error; }
|
|
}
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
_loading = true;
|
|
_error = null;
|
|
// _activeTab is intentionally NOT reset here: an in-place save reloads the page (re-runs
|
|
// OnParametersSetAsync) and the user's tab selection should survive that. The create→redirect
|
|
// path lands on Details because the field initializes to "details" and a fresh page instance
|
|
// starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the
|
|
// lazy loaders re-fetch for the (possibly different) equipment this parameter set targets.
|
|
_tags = null;
|
|
_vtags = null;
|
|
if (!IsNew)
|
|
{
|
|
_equipment = await Svc.LoadEquipmentAsync(EquipmentId!);
|
|
if (_equipment is not null)
|
|
{
|
|
LoadFormFrom(_equipment);
|
|
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
|
|
_lineOptions = ctx.Lines;
|
|
_driverOptions = ctx.Drivers;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_form = new FormModel { UnsLineId = LineId ?? "" };
|
|
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
|
|
_lineOptions = ctx.Lines;
|
|
_driverOptions = ctx.Drivers;
|
|
}
|
|
_loading = false;
|
|
}
|
|
|
|
/// <summary>Maps a loaded equipment's fields into the working form (mirrors EquipmentModal's edit branch).</summary>
|
|
private void LoadFormFrom(EquipmentEditDto e)
|
|
{
|
|
_form = new FormModel
|
|
{
|
|
Name = e.Name,
|
|
MachineCode = e.MachineCode,
|
|
UnsLineId = e.UnsLineId,
|
|
DriverInstanceId = e.DriverInstanceId,
|
|
ZTag = e.ZTag,
|
|
SAPID = e.SAPID,
|
|
Manufacturer = e.Manufacturer,
|
|
Model = e.Model,
|
|
SerialNumber = e.SerialNumber,
|
|
HardwareRevision = e.HardwareRevision,
|
|
SoftwareRevision = e.SoftwareRevision,
|
|
YearOfConstruction = e.YearOfConstruction,
|
|
AssetLocation = e.AssetLocation,
|
|
ManufacturerUri = e.ManufacturerUri,
|
|
DeviceManualUri = e.DeviceManualUri,
|
|
Enabled = e.Enabled,
|
|
};
|
|
}
|
|
|
|
private async Task SaveAsync()
|
|
{
|
|
_busy = true;
|
|
_error = null;
|
|
try
|
|
{
|
|
var input = new EquipmentInput(
|
|
_form.Name,
|
|
_form.MachineCode,
|
|
_form.UnsLineId,
|
|
_form.DriverInstanceId,
|
|
_form.ZTag,
|
|
_form.SAPID,
|
|
_form.Manufacturer,
|
|
_form.Model,
|
|
_form.SerialNumber,
|
|
_form.HardwareRevision,
|
|
_form.SoftwareRevision,
|
|
_form.YearOfConstruction,
|
|
_form.AssetLocation,
|
|
_form.ManufacturerUri,
|
|
_form.DeviceManualUri,
|
|
_form.Enabled);
|
|
|
|
var result = IsNew
|
|
? await Svc.CreateEquipmentAsync(input)
|
|
: await Svc.UpdateEquipmentAsync(_equipment!.EquipmentId, input, _equipment.RowVersion);
|
|
|
|
if (!result.Ok) { _error = result.Error; return; }
|
|
|
|
if (IsNew)
|
|
{
|
|
// Redirect to the persisted editor so the other tabs (disabled while new) become available.
|
|
Nav.NavigateTo($"/uns/equipment/{result.CreatedId}");
|
|
}
|
|
else
|
|
{
|
|
// Reload to pick up the fresh RowVersion for the next save.
|
|
_equipment = await Svc.LoadEquipmentAsync(EquipmentId!);
|
|
if (_equipment is not null) { LoadFormFrom(_equipment); }
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_busy = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>The working form for the Details tab — identical to EquipmentModal.FormModel.</summary>
|
|
private sealed class FormModel
|
|
{
|
|
[Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")]
|
|
public string Name { get; set; } = "";
|
|
[Required] public string MachineCode { get; set; } = "";
|
|
[Required] public string UnsLineId { get; set; } = "";
|
|
public string? DriverInstanceId { get; set; }
|
|
public string? ZTag { get; set; }
|
|
public string? SAPID { get; set; }
|
|
public string? Manufacturer { get; set; }
|
|
public string? Model { get; set; }
|
|
public string? SerialNumber { get; set; }
|
|
public string? HardwareRevision { get; set; }
|
|
public string? SoftwareRevision { get; set; }
|
|
public short? YearOfConstruction { get; set; }
|
|
public string? AssetLocation { get; set; }
|
|
public string? ManufacturerUri { get; set; }
|
|
public string? DeviceManualUri { get; set; }
|
|
public bool Enabled { get; set; } = true;
|
|
}
|
|
}
|