1082 lines
50 KiB
C#
1082 lines
50 KiB
C#
using adas_core.Application.Exceptions;
|
|
using adas_core.Application.Repositories.Interfaces;
|
|
using adas_core.Application.Services.Interfaces;
|
|
using adas_core.Domain.Enums;
|
|
using adas_core.Domain.Models;
|
|
using adas_core.Domain.Models.AppSettings;
|
|
using adas_core.Domain.Models.GroupedObservations;
|
|
using adas_core.Domain.Models.MongoModels;
|
|
using adas_core.Domain.Utils;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using MongoDB.Bson;
|
|
using Serilog;
|
|
using Patient = adas_core.Domain.Models.MongoModels.Patient;
|
|
|
|
|
|
namespace adas_core.Application.Services;
|
|
|
|
/// <summary>
|
|
/// Provides the concrete implementation of the <see cref="IAlarmService"/> contract,
|
|
/// encapsulating the business logic for managing and processing alarms within the application.
|
|
/// </summary>
|
|
public class AlarmService : IAlarmService
|
|
{
|
|
private readonly IAlarmRepository _alarmRepository;
|
|
private readonly IOptions<ApiSettings> _apiSettings;
|
|
private readonly ILocalAuditService _auditService;
|
|
private readonly Lazy<ICalculatedObservationsService> _calculatedObservationsService;
|
|
private readonly IClientMessageService _clientMessageService;
|
|
private readonly IConfigObservationService _configObservationService;
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
private readonly Lazy<ILightBeaconService> _lightBeaconService;
|
|
private readonly ILogger<AlarmService> _logger;
|
|
private readonly Lazy<IObservationService> _observationService;
|
|
private readonly IPatientService _patientService;
|
|
private readonly IPointOfCareService _pocService;
|
|
private readonly Lazy<IRecordingService> _recordingService;
|
|
private readonly List<PatientObservation> _relayAlarmList = [];
|
|
private readonly Lazy<IRelayService> _relayService;
|
|
|
|
private readonly SemaphoreSlim
|
|
_semaphore = new(1, 1); // Semáforo para evitar la ejecución simultánea del temporizador
|
|
|
|
private readonly ISubscribersService _subscribersService;
|
|
|
|
private readonly IUnitService _unitService;
|
|
//private readonly string _url;
|
|
|
|
private List<PatientObservation> _beaconAlarmList = [];
|
|
|
|
private TimeSpan _interval;
|
|
|
|
public AlarmService(IAlarmRepository alarmRepository,
|
|
ILogger<AlarmService> logger,
|
|
IPatientService patientService,
|
|
IConfigObservationService configObservationService,
|
|
Lazy<IObservationService> observationService,
|
|
IClientMessageService clientMessageService,
|
|
ISubscribersService subscribersService,
|
|
Lazy<ICalculatedObservationsService> calculatedObservationsService,
|
|
Lazy<ILightBeaconService> lightBeaconService,
|
|
Lazy<IRecordingService> recordingService,
|
|
Lazy<IRelayService> relayService,
|
|
IOptions<ApiSettings> apiSettings,
|
|
IUnitService unitService,
|
|
IPointOfCareService pocService,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
ILocalAuditService auditService,
|
|
bool startTimer = true
|
|
)
|
|
{
|
|
_alarmRepository = alarmRepository;
|
|
_logger = logger;
|
|
_patientService = patientService;
|
|
_configObservationService = configObservationService;
|
|
_observationService = observationService;
|
|
_clientMessageService = clientMessageService;
|
|
_subscribersService = subscribersService;
|
|
_calculatedObservationsService = calculatedObservationsService;
|
|
_lightBeaconService = lightBeaconService;
|
|
_recordingService = recordingService;
|
|
_relayService = relayService;
|
|
_apiSettings = apiSettings;
|
|
_unitService = unitService;
|
|
_pocService = pocService;
|
|
_httpContextAccessor = httpContextAccessor;
|
|
_auditService = auditService;
|
|
if (apiSettings == null) throw new Exception("ApiSettings must be defined");
|
|
|
|
if (startTimer) StartTimer();
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Processes and saves an incoming API request, handling ORU_R40 unsolicited alert observation messages.
|
|
/// Validates that the patient number or location unit name is provided, resolves the patient and unit configuration,
|
|
/// and processes any alarm observations; requests are ignored when no patient is found or auto-adt management is disabled.
|
|
/// </summary>
|
|
/// <param name="apiRequest">The API request containing patient, location, observation, and alarm data to process.</param>
|
|
/// <exception cref="InvalidFormatException">Thrown when both the patient number and location unit name are missing.</exception>
|
|
/// <exception cref="ApiRequestException">Thrown when the API request type is not valid for observations.</exception>
|
|
public async Task SaveRequest(ApiRequest apiRequest)
|
|
{
|
|
if (
|
|
string.IsNullOrEmpty(apiRequest.PatientNumber) &&
|
|
string.IsNullOrEmpty(apiRequest.Location?.UnitName)
|
|
)
|
|
{
|
|
_logger.LogDebug("Patient and PointOfCare are nulls");
|
|
throw new InvalidFormatException(HttpEnum.ErrorMessage.BadRequestMissingParameters);
|
|
}
|
|
|
|
var patient = await _patientService.FindPatientByApiRequest(apiRequest);
|
|
|
|
if (patient == null)
|
|
{
|
|
// NO PATIENTS OR LOCATIONS WERE FOUND
|
|
_logger.LogWarning(
|
|
"Observation for patient: {apiRequestpatientNumber} with location: {apiRequestlocation} not found, ignoring",
|
|
apiRequest.PatientNumber, apiRequest.Location);
|
|
return;
|
|
}
|
|
|
|
var unitConfig = await _unitService.FindById(patient.UnitId);
|
|
if (!Hl7Utils.ManageAutoAdt(unitConfig, null, _logger, "ORU Alarm")) return;
|
|
|
|
switch (apiRequest.Type)
|
|
{
|
|
/*
|
|
* ORU_R40 - Unsolicited transmission of an alert observation message
|
|
*/
|
|
case "ORU_R40": // UNSOLICITED ALERT OBSERVATION
|
|
|
|
// OBSERVATIONS
|
|
if (apiRequest.Observation != null && apiRequest.Observations?.FirstOrDefault() == null)
|
|
apiRequest.Observations = [apiRequest.Observation];
|
|
|
|
//var obrcode = apiRequest.ObservationData?.Code ?? "";
|
|
|
|
if (!apiRequest.Alarms.IsNullOrEmpty())
|
|
await ProcessAlarmObservations(apiRequest.Alarms ?? [], apiRequest.Observations ?? [],
|
|
patient, apiRequest.ObservationData?.Time ?? apiRequest.MessageTime,
|
|
apiRequest.ObservationData);
|
|
|
|
break;
|
|
default:
|
|
_logger.LogWarning("ApiRequest type {apiRequesttype} sis not valid for Observations",
|
|
apiRequest.Type);
|
|
throw new ApiRequestException("ApiRequest type " + apiRequest.Type +
|
|
" is not valid for Observations");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asynchronously saves the specified API request by delegating to the underlying save operation.
|
|
/// </summary>
|
|
/// <param name="apiRequest">The API request to be saved.</param>
|
|
public async Task SaveRequestAsync(ApiRequest apiRequest)
|
|
{
|
|
await SaveRequest(apiRequest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a <see cref="PatientObservationAlarm"/> through the configuration observation service and then through the calculated observations service.
|
|
/// If either mapping step returns null, or an exception occurs, the method logs the issue and returns null instead of propagating the error.
|
|
/// </summary>
|
|
/// <param name="obs">The patient observation alarm to be mapped.</param>
|
|
/// <param name="onlyByName">If true, mapping is performed by name only; otherwise the full mapping is applied.</param>
|
|
/// <returns>A mapped <see cref="PatientObservationAlarm"/> if both mapping steps succeed; otherwise, null.</returns>
|
|
public async Task<PatientObservationAlarm?> MapObservation(PatientObservationAlarm obs, bool onlyByName = false)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogTrace("Mapping config Observation obs: {obs} onlyByName: {onlyByName}", obs, onlyByName);
|
|
var obs2 = await _configObservationService.Map(obs, onlyByName);
|
|
if (obs2 == null)
|
|
{
|
|
_logger.LogTrace("Mapping obs2 {obs}: Ignored", obs);
|
|
return null;
|
|
}
|
|
|
|
_logger.LogTrace("Mapping _configObservationService.Map obs2: {obs2}", obs2);
|
|
|
|
|
|
var obs3 = await _calculatedObservationsService.Value.Map(obs2, onlyByName);
|
|
|
|
if (obs3 == null)
|
|
{
|
|
_logger.LogTrace("Mapping obs3 {obs2}: Ignored", obs2);
|
|
return null;
|
|
}
|
|
|
|
return obs3;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error Mapping Observation, Ignoring Observation: {obs} Exception:{ex}", obs,
|
|
ex.Message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves the most recent patient observation alarms for the specified patient, optionally filtered by a set of fields.
|
|
/// </summary>
|
|
/// <param name="patientId">The identifier of the patient whose last observations should be retrieved.</param>
|
|
/// <param name="filterObservations">An optional list of fields used to restrict which observations are returned; when null, no field filter is applied.</param>
|
|
/// <returns>A task that represents the asynchronous operation, containing a list of the patient's last <see cref="PatientObservationAlarm"/> entries.</returns>
|
|
public async Task<List<PatientObservationAlarm>> FindLastObservationsByField(ObjectId patientId,
|
|
List<Field>? filterObservations = null)
|
|
{
|
|
var result =
|
|
await _alarmRepository.AggregatedPatientLastObservationsByField(patientId, filterObservations);
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves the most recent non-expired patient observation alarms for the specified patient, filtered by the given observation fields and alarm configurations.
|
|
/// </summary>
|
|
/// <param name="patientId">The unique identifier of the patient whose alarms are being queried.</param>
|
|
/// <param name="filterObservations">The list of fields used to filter the observations included in the aggregation.</param>
|
|
/// <param name="configAlarm">The list of alarm configurations that define the criteria applied during the aggregation.</param>
|
|
/// <returns>A task that resolves to a list of the latest non-expired <see cref="PatientObservationAlarm"/> entries matching the criteria.</returns>
|
|
public async Task<List<PatientObservationAlarm>> FindLastValuesNotExpired(ObjectId patientId,
|
|
List<Field> filterObservations, List<ConfigObservation> configAlarm)
|
|
{
|
|
var result =
|
|
await _alarmRepository.AggregatedPatientNotExpiredObservationsByField(patientId, filterObservations,
|
|
configAlarm);
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps the provided patient observation alarm to its corresponding configuration by name, returning the mapped alarm if a matching configuration is found.
|
|
/// </summary>
|
|
/// <param name="obs">The patient observation alarm to be mapped by name.</param>
|
|
/// <returns>A task that returns the mapped <see cref="PatientObservationAlarm"/> if a matching configuration is found; otherwise, <c>null</c>.</returns>
|
|
public async Task<PatientObservationAlarm?> MapObservationsByName(PatientObservationAlarm obs)
|
|
{
|
|
return await _configObservationService.Map(obs, true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes and persists a list of patient observation alarms, enriching each alarm with patient, timing, and coding metadata. When a matching non-alarm observation is found in the provided list, its coding fields are reused; otherwise defaults derived from the alarm priority (Ph/Pm/Pl) are applied. For alarms that carry source data, related observations are mapped and submitted through the observation service before the alarm itself is inserted.
|
|
/// </summary>
|
|
/// <param name="alarmObservations">The alarm observations to process and insert.</param>
|
|
/// <param name="observations">Existing non-alarm observations used to look up and reuse coding information when a value match is found.</param>
|
|
/// <param name="patient">The patient the alarms are associated with.</param>
|
|
/// <param name="messageTime">The message time assigned to each alarm.</param>
|
|
/// <param name="observationData">Optional parent observation metadata applied to all alarms in the batch.</param>
|
|
public async Task ProcessAlarmObservations(List<PatientObservationAlarm> alarmObservations,
|
|
List<PatientObservation> observations, Patient patient,
|
|
DateTime messageTime, ObservationData? observationData = null)
|
|
{
|
|
_logger.LogDebug(
|
|
"Patient: {patientid} PoC {patientpointOfCare} {patientbed} msg {messageTime} INSERTING {observationsCount} OBSERVATIONS",
|
|
patient.Id, patient.UnitId, patient.PointOfCareId, messageTime, alarmObservations.Count);
|
|
|
|
ParentDataClass? parentData = null;
|
|
if (observationData != null)
|
|
parentData = new ParentDataClass
|
|
{
|
|
Code = observationData.Code,
|
|
CodingSystem = observationData.CodingSystem,
|
|
Name = observationData.Text
|
|
};
|
|
|
|
var listToInsert = new List<PatientObservationAlarm>();
|
|
|
|
foreach (var obs in alarmObservations)
|
|
{
|
|
obs.ParentData = parentData;
|
|
obs.MessageTime = messageTime;
|
|
obs.PatientId = patient.Id;
|
|
obs.Patient = patient;
|
|
obs.Id = ObjectId.GenerateNewId();
|
|
|
|
var intObsTime = new DateTimeOffset(obs.Time).ToUnixTimeSeconds();
|
|
|
|
if (obs.Time == DateTime.MinValue || intObsTime <= 10)
|
|
obs.Time = DateTime.UtcNow;
|
|
|
|
var intMessageTime = new DateTimeOffset(obs.MessageTime).ToUnixTimeSeconds();
|
|
|
|
if (obs.MessageTime == DateTime.MinValue || intMessageTime <= 10)
|
|
obs.MessageTime = DateTime.UtcNow;
|
|
|
|
if (obs.Value.ToString() == "System.Object")
|
|
{
|
|
obs.Value = obs.Event?.ToString()??string.Empty;
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Patient: {patientId} PoC {patientpointOfCare} {patientbed} msg {messageTime} INSERTING ALARM OBSERAVTION {obs}",
|
|
patient.Id, patient.PointOfCare, patient.Bed, messageTime, obs);
|
|
listToInsert.Add(obs);
|
|
}
|
|
|
|
//listToInsert.ForEach(async obs => await InsertObservation(obs));
|
|
foreach (var alarmToInsert in listToInsert)
|
|
{
|
|
var alarmData =
|
|
observations.FirstOrDefault(obs => obs.Value.ToString() == alarmToInsert.Value.ToString());
|
|
if (alarmData != null)
|
|
{
|
|
alarmToInsert.Code = alarmData.Code;
|
|
alarmToInsert.Name = alarmData.Code;
|
|
alarmToInsert.CodingSystem = alarmData.CodingSystem;
|
|
}
|
|
else
|
|
{
|
|
alarmToInsert.CodingSystem = parentData?.CodingSystem?? "MDIL-ALARM";
|
|
alarmToInsert.Code = alarmToInsert.Priority.ToString();
|
|
|
|
alarmToInsert.Name = alarmToInsert.Priority switch
|
|
{
|
|
AlarmEnum.ObservationAlarmPriority.Ph => "RedAlarm_Ph",
|
|
AlarmEnum.ObservationAlarmPriority.Pm => "YellowAlarm_Pm",
|
|
AlarmEnum.ObservationAlarmPriority.Pl => "BlueAlarm_Pl",
|
|
_ => "",
|
|
};
|
|
}
|
|
|
|
if (!alarmToInsert.Sources.IsNullOrEmpty())
|
|
{
|
|
var apiRequestObs = new ApiRequest
|
|
{
|
|
Type = "ORU_R01",
|
|
MessageTime = messageTime,
|
|
ObservationData = observationData
|
|
};
|
|
|
|
var obsToInsert = new List<PatientObservation>();
|
|
alarmToInsert.Sources?.ForEach(async void (c) =>
|
|
{
|
|
try
|
|
{
|
|
var obs = new PatientObservation
|
|
{
|
|
Code = c.Code,
|
|
Name = c.OriginalName,
|
|
CodingSystem = c.CodeSystem,
|
|
Units = c.Units,
|
|
Value = c.Value?.ToString() ?? "No value",
|
|
Time = alarmToInsert.Time,
|
|
Result = c.Result
|
|
};
|
|
var obs2 = await _calculatedObservationsService.Value
|
|
.MapSourceAlarm(obs, alarmToInsert);
|
|
obsToInsert.Add(obs2);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogError(
|
|
"Error processing source observation {source} for alarm {alarm}. Exception: {ex}",
|
|
c, alarmToInsert, e);
|
|
}
|
|
});
|
|
|
|
apiRequestObs.Observations = obsToInsert;
|
|
apiRequestObs.Location = patient.Location;
|
|
apiRequestObs.Patient = patient.Person;
|
|
apiRequestObs.PatientNumber = patient.PatientNumber;
|
|
await _observationService.Value.SaveRequestAsync(apiRequestObs);
|
|
}
|
|
|
|
|
|
await InsertObservation(alarmToInsert);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inserts a patient observation alarm, optionally mapping it first. If mapping returns null, the observation is ignored. Persistence is controlled by the <paramref name="persistObs"/> flag but is overridden to false when the observation's own <c>Persist</c> property is false. Any exception raised during processing is caught and logged without being rethrown.
|
|
/// </summary>
|
|
/// <param name="obs">The patient observation alarm to insert.</param>
|
|
/// <param name="persistObs">Indicates whether the observation should be persisted to the repository. Defaults to true and is forced to false if the observation's <c>Persist</c> property is false.</param>
|
|
/// <param name="mapObs">Indicates whether the observation should be mapped before being processed. Set to false only when the observation originates from the inner refactor job.</param>
|
|
private async Task InsertObservation(PatientObservationAlarm obs, bool persistObs = true, bool mapObs = true)
|
|
{
|
|
try
|
|
{
|
|
var obs2 = obs;
|
|
|
|
//only will be false if the obs comes from the inner refactor job
|
|
if (mapObs) obs2 = await MapObservation(obs2, onlyByName: true);
|
|
|
|
if (obs2 == null)
|
|
{
|
|
_logger.LogDebug("Mapped observation returns null. Ignored {obs}", obs);
|
|
}
|
|
else
|
|
{
|
|
if (obs2.Persist.HasValue && !obs2.Persist.Value) persistObs = false;
|
|
if (persistObs)
|
|
{
|
|
_logger.LogDebug("Mapped {obs2}", obs2);
|
|
await _alarmRepository.InsertOneAsync(obs2);
|
|
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, null, obs2);
|
|
}
|
|
|
|
_logger.LogDebug("Inserted {obs2}", obs2);
|
|
await SendObsBroadcast(obs2);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("Error Inserting observation {obs}. Excepcion; {ex} ", obs, ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Broadcasts a patient observation to all subscribers whose configured locations match the patient's point of care.
|
|
/// Skips observations without a name and returns silently when the associated patient cannot be resolved from the observation or the patient service.
|
|
/// </summary>
|
|
/// <param name="obs">The patient observation to be sent to matching subscribers as an alarm operation.</param>
|
|
private async Task SendObsBroadcast(BasePatientObservation obs)
|
|
{
|
|
if (obs.Name == null) return;
|
|
|
|
const OperationType type = OperationType.Alarm;
|
|
|
|
var patient = obs.Patient ?? await _patientService.FindById(obs.PatientId);
|
|
if (patient == null)
|
|
{
|
|
_logger.LogDebug("Not patient on bd to sendOnBroadcastObs: {obspatientid}", obs.PatientId);
|
|
return;
|
|
}
|
|
|
|
_logger.LogTrace(
|
|
"sending obs name: {obsname} to patient id: {patientid}, PointOfCare: {patientpointOfCare} {patientbed}",
|
|
obs.Name, patient.Id, patient.UnitId, patient.Bed);
|
|
|
|
var subscribers = _subscribersService.GetSubscribers().Where(s =>
|
|
!s.LocationIds.IsNullOrEmpty() && s.LocationIds.Any(c =>
|
|
c == patient.PointOfCareId
|
|
)).ToList();
|
|
|
|
foreach (var subscriber in subscribers)
|
|
{
|
|
_logger.LogTrace("sending obs name: {obsname} to subscriber id: {subscriberId}", obs.Name,
|
|
subscriber.Id);
|
|
await _clientMessageService.SendAsync(subscriber.Id, type, obs);
|
|
}
|
|
}
|
|
|
|
|
|
#region activación de alarmas con balizas, relé y grabaciones
|
|
|
|
/// <summary>
|
|
/// Evaluates alarm configuration rules for a patient observation and, when matching conditions and preconditions are satisfied, generates a new alert observation, logs it, and triggers the corresponding alarm notification.
|
|
/// </summary>
|
|
/// <param name="obs">The patient observation whose value is evaluated against configured alert rules and preconditions.</param>
|
|
public async Task CheckObservationAlarm(PatientObservation obs)
|
|
{
|
|
//ConfigObservations
|
|
var configs = await _configObservationService.Get(new PatientObservation
|
|
{
|
|
Name = obs.Name,
|
|
PatientId = obs.PatientId
|
|
}
|
|
);
|
|
|
|
|
|
if (configs?.CreateObservation == null)
|
|
return;
|
|
|
|
var obsValue = obs.Value.ToString() ?? string.Empty;
|
|
|
|
var observationsToCreate = configs.CreateObservation
|
|
.Where(c => obsValue.ToUpper().Contains(c.RequiredValue?.ToString()?.ToUpper() ?? string.Empty))
|
|
.ToList();
|
|
|
|
foreach (var obsConfig in observationsToCreate)
|
|
{
|
|
var create = false;
|
|
|
|
if (obsConfig.Preconditions == null)
|
|
create = true;
|
|
else
|
|
foreach (var preCondition in obsConfig.Preconditions)
|
|
{
|
|
if (preCondition.Name == null)
|
|
continue;
|
|
|
|
var obsWithConditions =
|
|
await _observationService.Value.FindLastObservations(obs.PatientId, 1,
|
|
[preCondition.Name]);
|
|
if (!obsWithConditions.Any())
|
|
continue;
|
|
|
|
var foundObs = obsWithConditions.FirstOrDefault();
|
|
|
|
//Descartamos la observación si ha expirado
|
|
if (foundObs == null || (obsConfig.Expires.HasValue &&
|
|
foundObs.Time.AddSeconds(obsConfig.Expires.Value) < DateTime.UtcNow))
|
|
continue;
|
|
|
|
var foundObsStr = foundObs.Value.ToString();
|
|
var requiredValueStr = preCondition.RequiredValue?.ToString();
|
|
|
|
if (string.IsNullOrEmpty(requiredValueStr) ||
|
|
(foundObsStr != null && foundObsStr.Contains(requiredValueStr)))
|
|
{
|
|
create = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
if (create)
|
|
{
|
|
var newObservation = CreateNewObservation(obs, obsConfig, StatusEnum.Type.Alert);
|
|
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, null, newObservation);
|
|
newObservation = await CheckAlarmConfig(newObservation);
|
|
|
|
var obsName = newObservation.Name ?? string.Empty;
|
|
|
|
_ = SendAlarm(newObservation, obsName, null, AlarmEnum.Severity.None, AlarmEnum.Type.Auto);
|
|
_ = _observationService.Value.InsertObservation(newObservation);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new <see cref="PatientObservation"/> by combining observation values from the provided source observation and configuration, applying the specified status type.
|
|
/// </summary>
|
|
/// <param name="obs">The source patient observation providing the value, patient identifier, and time.</param>
|
|
/// <param name="config">The configuration observation providing the coding system, code, name, and alarm settings.</param>
|
|
/// <param name="type">The status type to assign to the new observation.</param>
|
|
/// <returns>A new <see cref="PatientObservation"/> populated with the merged values from the source observation and configuration.</returns>
|
|
private static PatientObservation CreateNewObservation(PatientObservation obs, ConfigObservation config,
|
|
StatusEnum.Type type)
|
|
{
|
|
return new PatientObservation
|
|
{
|
|
CodingSystem = config.CodingSystem,
|
|
Code = config.Code,
|
|
Name = config.Name,
|
|
Value = obs.Value,
|
|
PatientId = obs.PatientId,
|
|
Time = obs.Time,
|
|
Alarm = config.Alarm,
|
|
Status = type
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves the alarm configuration for the specified patient observation and applies it to the observation. If no matching configuration is found, the alarm is set to null.
|
|
/// </summary>
|
|
/// <param name="pobs">The patient observation whose alarm configuration will be checked and updated.</param>
|
|
/// <returns>The patient observation with the configured alarm applied, or with a null alarm if no configuration was found.</returns>
|
|
private async Task<PatientObservation> CheckAlarmConfig(PatientObservation pobs)
|
|
{
|
|
var configObs = await _configObservationService.Get(new PatientObservation
|
|
{
|
|
Name = pobs.Name,
|
|
PatientId = pobs.PatientId
|
|
});
|
|
|
|
pobs.Alarm = configObs?.Alarm ?? null;
|
|
|
|
return pobs;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Sends a new alarm
|
|
/// </summary>
|
|
/// <param name="obs">Observation to generate the alarm</param>
|
|
/// <param name="name">Name of the alarm</param>
|
|
/// <param name="code">Code of the alarm for the recording</param>
|
|
/// <param name="severity">Severity of the alarm for the recording</param>
|
|
/// <param name="type"></param>
|
|
/// <exception cref="ArgumentOutOfRangeException"></exception>
|
|
/// <returns>New alarm created</returns>
|
|
public async Task SendAlarm(PatientObservation obs, string name, AlarmEnum.Name? code, AlarmEnum.Severity severity,
|
|
AlarmEnum.Type type)
|
|
{
|
|
try
|
|
{
|
|
var poc = await _pocService.FindPoCByPatientId(obs.PatientId);
|
|
switch (obs.Time.Kind)
|
|
{
|
|
// Convert obs.Time to UTC if it's not already
|
|
case DateTimeKind.Local:
|
|
obs.Time = obs.Time.ToUniversalTime();
|
|
break;
|
|
case DateTimeKind.Unspecified:
|
|
_logger.LogWarning("obs.Time has unspecified kind. Assuming it to be UTC.");
|
|
obs.Time = DateTime.SpecifyKind(obs.Time, DateTimeKind.Utc);
|
|
break;
|
|
case DateTimeKind.Utc:
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException();
|
|
}
|
|
|
|
//ConfigObservations
|
|
var configObs = await _configObservationService.Get(new PatientObservation
|
|
{
|
|
Name = obs.Name,
|
|
PatientId = obs.PatientId
|
|
}
|
|
);
|
|
|
|
if (configObs == null)
|
|
return;
|
|
|
|
if (configObs is { Alarm.Enabled: true })
|
|
{
|
|
obs.Alarm = configObs.Alarm;
|
|
var now = DateTime.UtcNow;
|
|
|
|
if (obs.Expired || (obs.Expires.HasValue && obs.Time.AddSeconds(obs.Expires.Value) < now))
|
|
return;
|
|
|
|
//En la prioridad de las alarmas 1 máxima prioridad
|
|
if (configObs.Alarm.Beacon is { Enabled: true })
|
|
try
|
|
{
|
|
if (obs.Time.AddSeconds(configObs.Alarm.Beacon.EndAfter) >= now)
|
|
{
|
|
var patient = obs.Patient ?? await _patientService.FindById(obs.PatientId);
|
|
if (patient != null)
|
|
{
|
|
_logger.LogDebug("PatientId: {nObsPatientid}. Send Beacon alarmName {beaconColor}",
|
|
obs.PatientId, configObs.Alarm.Beacon.BeaconColor);
|
|
if (!_beaconAlarmList.Any(o =>
|
|
o.PatientId == obs.PatientId && o.Alarm?.Priority < obs.Alarm.Priority))
|
|
{
|
|
_ = SendBeaconCode(configObs.Alarm.Beacon.BeaconColor, patient);
|
|
|
|
lock (_beaconAlarmList)
|
|
{
|
|
_beaconAlarmList.Add(obs);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug(
|
|
"PatientId: {nObsPatientid}.Beacon is Expired. EndAfter {endAfter} Time: {obsTime}",
|
|
obs.PatientId, configObs.Alarm.Beacon.EndAfter, obs.Time);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug("Exception sending beacon code for patient {patientId}. Exception: {ex}",
|
|
obs.PatientId, ex);
|
|
|
|
|
|
throw;
|
|
}
|
|
|
|
if (configObs.Alarm.Recording is { Enabled: true })
|
|
try
|
|
{
|
|
if (obs.Time.AddSeconds(configObs.Alarm.Recording.EndAfter) >= now)
|
|
{
|
|
//if(severity == AlarmSeverity.NONE)
|
|
severity = configObs.Alarm.Recording.Severity;
|
|
|
|
var strValue = obs.Value.ToString();
|
|
if (strValue == null)
|
|
{
|
|
_logger.LogError("Observation value to string is null observation:{nObs}", obs);
|
|
return;
|
|
}
|
|
|
|
_logger.LogDebug("PatientId: {nObsPatientid}. Send Recording {nObsName}", obs.PatientId,
|
|
obs.Name);
|
|
|
|
if (code == null && Enum.TryParse<AlarmEnum.Name>(configObs.Alarm.Name, out var result))
|
|
code = result;
|
|
|
|
|
|
_ = StartRecording(obs.PatientId, obs.Time, configObs.Alarm.Recording, code, severity,
|
|
strValue, configObs.Alarm.Recording.EndAfter, type);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug(
|
|
"PatientId: {nObsPatientid}.Recording is Expired. EndAfter {endAfter} Time: {obsTime}",
|
|
obs.PatientId, configObs.Alarm.Recording.EndAfter, obs.Time);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(
|
|
"Exception sending Alarm Recording for patient {patientId}. Exception: {ex}",
|
|
obs.PatientId, ex);
|
|
|
|
throw;
|
|
}
|
|
|
|
if (configObs.Alarm.OpenDoor is { Enabled: true })
|
|
try
|
|
{
|
|
if (obs.Time.AddSeconds(configObs.Alarm.OpenDoor.EndAfter) >= now)
|
|
{
|
|
_logger.LogDebug("PatientId: {nObsPatientid}. Open door observation {obsName}",
|
|
obs.PatientId, obs.Name);
|
|
|
|
lock (_relayAlarmList)
|
|
{
|
|
if (!_relayAlarmList.Any(o =>
|
|
o.PatientId == obs.PatientId && o.Alarm?.Priority < obs.Alarm.Priority))
|
|
{
|
|
PointOfCareConfiguration? poCSettings = null;
|
|
|
|
if (poc is { Configuration.RelayIdList: not null })
|
|
poCSettings = poc.Configuration;
|
|
|
|
var status = _relayService.Value.GetRelayByTypeInList(poCSettings?.RelayIdList,
|
|
RelayEnum.Type.Door).FirstOrDefault()?.ManualRelayStatus;
|
|
|
|
if (status is RelayEnum.Status.Off)
|
|
_ = RelayPowerOn(obs.PatientId, RelayEnum.Type.Door);
|
|
|
|
lock (_relayAlarmList)
|
|
{
|
|
_relayAlarmList.Add(obs);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug(
|
|
"PatientId: {nObsPatientid}.Open door is Expired. EndAfter {endAfter} Time: {obsTime}",
|
|
obs.PatientId, configObs.Alarm.OpenDoor.EndAfter, obs.Time);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug("Exception opening door for patient {patientId}. Exception: {ex}",
|
|
obs.PatientId, ex);
|
|
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error("Exception sending alarm: {exMessage}", ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates and dispatches test alarms for a patient observation based on its configuration. Supports beacon alerts, recording, and door control when the corresponding alarm components are enabled; if the source is not a <see cref="PatientObservation"/>, the method returns without action, and recordings fall back to a default <see cref="AlarmEnum.Severity.Yellow"/> severity when no severity string is provided.
|
|
/// </summary>
|
|
/// <param name="source">The patient observation value used to look up the alarm configuration and identify the target patient.</param>
|
|
/// <param name="name">The name associated with the observation, used for logging and alarm context.</param>
|
|
public async Task CalculateAlarmTest(BasePatientObservationValue source, string name)
|
|
{
|
|
if (source is not PatientObservation obs) return;
|
|
|
|
//ConfigObservations
|
|
var configObs = await _configObservationService.Get(source, true);
|
|
|
|
if (configObs is { Alarm.Enabled: true })
|
|
{
|
|
obs.Alarm = configObs.Alarm;
|
|
string? alarmSeverityStr = null;
|
|
if (configObs.Alarm.Beacon is { Enabled: true })
|
|
{
|
|
var patient = await _patientService.FindById(obs.PatientId);
|
|
if (patient == null)
|
|
return;
|
|
|
|
_logger.LogDebug("PatientId: {nObsPatientid}. Send Beacon alarmName {beaconColor}",
|
|
obs.PatientId, configObs.Alarm.Beacon.BeaconColor);
|
|
_ = SendBeaconCode(configObs.Alarm.Beacon.BeaconColor, patient);
|
|
}
|
|
|
|
if (configObs.Alarm.Recording is { Enabled: true })
|
|
{
|
|
_logger.LogDebug("PatientId: {nObsPatientid}. Send Recording {nObsName}", obs.PatientId,
|
|
obs.Name);
|
|
|
|
if (!string.IsNullOrEmpty(alarmSeverityStr) &&
|
|
Enum.TryParse(alarmSeverityStr, out AlarmEnum.Severity alarmSeverity))
|
|
_ = StartRecording(obs.PatientId, obs.Time, configObs.Alarm.Recording, AlarmEnum.Name.Test,
|
|
alarmSeverity, "test description", configObs.Alarm.Recording.EndAfter);
|
|
else
|
|
_ = StartRecording(obs.PatientId, obs.Time, configObs.Alarm.Recording, AlarmEnum.Name.Test,
|
|
AlarmEnum.Severity.Yellow, "test description", configObs.Alarm.Recording.EndAfter);
|
|
}
|
|
|
|
if (configObs.Alarm.OpenDoor is { Enabled: true })
|
|
{
|
|
_logger.LogDebug("PatientId: {nObsPatientid}. Open door", obs.PatientId);
|
|
_ = RelayPowerOn(obs.PatientId, RelayEnum.Type.Door);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a beacon color signal to the light beacon service associated with the patient's point of care. If the patient has no PointOfCareId, the operation is skipped and an error is logged. Maps <see cref="AlarmEnum.BeaconColor"/> values (Blue, Yellow, Red, None) to the corresponding <see cref="LightBeaconColor"/> signals (Blue, Yellow, Red, Off).
|
|
/// </summary>
|
|
/// <param name="color">The beacon color to transmit to the light beacon service.</param>
|
|
/// <param name="patient">The patient whose associated point of care device should display the beacon color.</param>
|
|
private Task SendBeaconCode(AlarmEnum.BeaconColor color, Patient patient)
|
|
{
|
|
if (!patient.PointOfCareId.HasValue)
|
|
{
|
|
_logger.LogError("Try to sen beacon code, but no PointOfCareId id is present in the Patient {Patient}",
|
|
patient.ToString());
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
switch (color)
|
|
{
|
|
case AlarmEnum.BeaconColor.Blue:
|
|
_lightBeaconService.Value.SendColor(patient.PointOfCareId.Value, LightBeaconColor.Blue);
|
|
break;
|
|
case AlarmEnum.BeaconColor.Yellow:
|
|
_lightBeaconService.Value.SendColor(patient.PointOfCareId.Value, LightBeaconColor.Yellow);
|
|
break;
|
|
case AlarmEnum.BeaconColor.Red:
|
|
_lightBeaconService.Value.SendColor(patient.PointOfCareId.Value, LightBeaconColor.Red);
|
|
break;
|
|
case AlarmEnum.BeaconColor.None:
|
|
_lightBeaconService.Value.SendColor(patient.PointOfCareId.Value, LightBeaconColor.Off);
|
|
break;
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// </summary>
|
|
/// <param name="patientId"></param>
|
|
/// <param name="eventTime">Hora de la observación</param>
|
|
/// <param name="recording"></param>
|
|
/// <param name="alarmName"></param>
|
|
/// <param name="alarmDescription"></param>
|
|
/// <param name="endAfter"></param>
|
|
/// <param name="severity"></param>
|
|
/// <param name="type"></param>
|
|
private async Task StartRecording(ObjectId patientId, DateTime eventTime, AlarmItem? recording,
|
|
AlarmEnum.Name? alarmName, AlarmEnum.Severity severity, string? alarmDescription, int? endAfter,
|
|
AlarmEnum.Type type = AlarmEnum.Type.Manual)
|
|
{
|
|
try
|
|
{
|
|
var patient = await _patientService.FindById(patientId);
|
|
if (patient is not { PointOfCareId: not null }) return;
|
|
var poc = await _pocService.FindByIdAllConfig(patient.PointOfCareId.Value);
|
|
if (poc == null)
|
|
return;
|
|
|
|
|
|
//30 minutos antes y después de la fecha de la observación
|
|
var startTime = recording != null ? eventTime.AddSeconds(-recording.StartBefore) : eventTime;
|
|
var endDate = endAfter.HasValue ? eventTime.AddSeconds(endAfter.Value) : (DateTime?)null;
|
|
|
|
await _recordingService.Value.SendRecordingDataToQueue(patient, poc, startTime, endDate,
|
|
eventTime, alarmName, severity, alarmDescription, true, type);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(
|
|
"Error Starting recording from patientId: {patientId}. eventTime: {eventTime}. AlarmItem: {recording}. alarmSeverity: {alarmSeverity} Error: {ex}",
|
|
patientId, eventTime, recording, severity, ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Powers on the relay associated with a patient's point of care configuration matching the specified relay type.
|
|
/// Exits silently when the patient, point of care, or matching relay configuration cannot be found, and logs any errors encountered during execution.
|
|
/// </summary>
|
|
/// <param name="patientId">The identifier of the patient whose relay should be powered on.</param>
|
|
/// <param name="type">The type of relay to power on, used to locate the matching configuration in the point of care's relay list.</param>
|
|
private async Task RelayPowerOn(ObjectId patientId, RelayEnum.Type type)
|
|
{
|
|
try
|
|
{
|
|
var patient = await _patientService.FindById(patientId);
|
|
if (patient is not { PointOfCareId: not null })
|
|
{
|
|
Log.Error("can not power on relay because patient: {patientId} not found", patientId);
|
|
return;
|
|
}
|
|
|
|
var poc = await _pocService.FindById(patient.PointOfCareId.Value);
|
|
if (poc?.Configuration == null) return;
|
|
var relayConfig = _relayService.Value.GetRelayByTypeInList(poc.Configuration.RelayIdList, type).FirstOrDefault();
|
|
if (relayConfig != null) await _relayService.Value.PowerOn(relayConfig);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("Error Relay Power On from patientId: {patientId}. Error: {ex}", patientId, ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts a periodic background timer that retrieves the list of locations and periodically checks for expired alarms, using a semaphore to ensure thread-safe execution.
|
|
/// Logs any errors encountered during timer initialization or execution without rethrowing them.
|
|
/// </summary>
|
|
private async void StartTimer()
|
|
{
|
|
try
|
|
{
|
|
var pocList = await _pocService.GetAllLocationInfo();
|
|
_interval = TimeSpan.FromSeconds(_apiSettings.Value.ExpireAlertIntervalSeconds);
|
|
_ = new Timer(async void (_) =>
|
|
{
|
|
try
|
|
{
|
|
await _semaphore.WaitAsync(); // Esperar a adquirir el semáforo antes de ejecutar el temporizador
|
|
await CheckExpiredAlarms(pocList);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogError("Error in timer execution: {message}", e.Message);
|
|
//throw new Exception("Error in timer execution", e);
|
|
}
|
|
finally
|
|
{
|
|
_semaphore.Release(); // Liberar el semáforo después de ejecutar el temporizador
|
|
}
|
|
}, null, TimeSpan.Zero, _interval);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogError("Error starting timer: {message}", e.Message);
|
|
//throw new Exception("Error starting timer", e);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asynchronously checks for expired alarms by running the beacon and relay expiration checks in parallel.
|
|
/// </summary>
|
|
/// <param name="pocList">The list of points of care to be evaluated for expired beacon alarms.</param>
|
|
private async Task CheckExpiredAlarms(List<PointOfCare> pocList)
|
|
{
|
|
_logger.LogTrace("Checking Expired Alarms Started");
|
|
|
|
// Iniciar ambas tareas de forma asincrónica
|
|
var checkBeaconsTask = CheckExpiredBeaconsAsync(pocList);
|
|
var checkRelayTask = CheckExpiredRelayAsync();
|
|
|
|
// Esperar a que ambas tareas completen
|
|
await Task.WhenAll(checkBeaconsTask, checkRelayTask);
|
|
|
|
_logger.LogTrace("Checking Expired Alarms Finished");
|
|
}
|
|
|
|
|
|
private readonly SemaphoreSlim _beaconListSemaphore = new(1, 1);
|
|
|
|
/// <summary>
|
|
/// Asynchronously checks for expired beacon alarms and turns off the corresponding lights.
|
|
/// If no alarms are active, all beacons in the supplied list are turned off; otherwise, beacons
|
|
/// whose alarm end time has passed are turned off and the alarm list is pruned to retain only
|
|
/// the still-valid observations.
|
|
/// </summary>
|
|
/// <param name="pocList">The list of points of care whose beacons should be turned off when no alarms are active.</param>
|
|
private async Task CheckExpiredBeaconsAsync(List<PointOfCare> pocList)
|
|
{
|
|
await _beaconListSemaphore.WaitAsync();
|
|
try
|
|
{
|
|
if (!_beaconAlarmList.Any())
|
|
//var pocList = await _pocService.GetAllLocationInfo();
|
|
if (pocList.Any())
|
|
{
|
|
var tasks = pocList.Select(async poc =>
|
|
{
|
|
await _lightBeaconService.Value.SendColor(poc, LightBeaconColor.Off);
|
|
});
|
|
|
|
await Task.WhenAll(tasks);
|
|
|
|
return;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_beaconListSemaphore.Release();
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
List<PatientObservation> updatedList = [];
|
|
List<Task> ledTasks = [];
|
|
|
|
await _beaconListSemaphore.WaitAsync();
|
|
try
|
|
{
|
|
foreach (var obsGroup in _beaconAlarmList.GroupBy(o => o.PatientId))
|
|
{
|
|
var nonExpiredObs = obsGroup.Where(obs =>
|
|
obs.Alarm is { Beacon: not null } &&
|
|
(obs.Time.Kind == DateTimeKind.Utc ? obs.Time : obs.Time.ToUniversalTime())
|
|
.AddSeconds(obs.Alarm.Beacon.EndAfter) >= now
|
|
).ToList();
|
|
|
|
if (!nonExpiredObs.Any())
|
|
{
|
|
var patient = obsGroup.FirstOrDefault()?.Patient;
|
|
if (patient is { PointOfCareId: not null })
|
|
ledTasks.Add(_lightBeaconService.Value.SendColor(patient.PointOfCareId.Value,
|
|
LightBeaconColor.Off));
|
|
}
|
|
else
|
|
{
|
|
updatedList.AddRange(nonExpiredObs);
|
|
}
|
|
}
|
|
|
|
_beaconAlarmList = updatedList;
|
|
}
|
|
finally
|
|
{
|
|
_beaconListSemaphore.Release();
|
|
}
|
|
|
|
await Task.WhenAll(ledTasks);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asynchronously checks for expired relay alarms grouped by patient, removes observations whose
|
|
/// <c>OpenDoor.EndAfter</c> duration has elapsed, and powers off the corresponding relays when no
|
|
/// non-expired observations remain, provided the manual relay status is not forced On. Only
|
|
/// patients without an assigned <c>PointOfCareId</c> are processed, and the relay alarm list is
|
|
/// synchronized under a lock with the filtered non-expired observations.
|
|
/// </summary>
|
|
private async Task CheckExpiredRelayAsync()
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
|
|
IEnumerable<IGrouping<ObjectId, PatientObservation>> groupedByPatientId;
|
|
|
|
lock (_relayAlarmList)
|
|
{
|
|
groupedByPatientId = _relayAlarmList.GroupBy(o => o.PatientId);
|
|
}
|
|
|
|
foreach (var obsGroup in groupedByPatientId)
|
|
{
|
|
var patient = await _patientService.FindById(obsGroup.Key);
|
|
if (patient is not { PointOfCareId: null })
|
|
continue;
|
|
|
|
|
|
var nonExpiredObs = obsGroup.Where(obs =>
|
|
obs.Alarm is { OpenDoor: not null } &&
|
|
(obs.Time.Kind == DateTimeKind.Utc ? obs.Time : obs.Time.ToUniversalTime()).AddSeconds(
|
|
obs.Alarm.OpenDoor.EndAfter) >= now
|
|
).ToList();
|
|
|
|
// Apagar el LED si no hay observaciones no expiradas
|
|
if (!nonExpiredObs.Any())
|
|
{
|
|
//buscamos en PoCSettings si está activado de forma manual
|
|
var pocSettings = await _pocService.FindById(patient.PointOfCareId!.Value);
|
|
var relays = _relayService.Value.GetRelayInList(pocSettings?.Configuration?.RelayIdList);
|
|
foreach (var relay in relays)
|
|
{
|
|
// Verificamos si NO tiene un estado manual activo (On)
|
|
// Si el estado es null o es diferente de On, lo apagamos
|
|
if (relay.ManualRelayStatus != null && relay.ManualRelayStatus != RelayEnum.Status.On)
|
|
{
|
|
await _relayService.Value.PowerOff(relay);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reemplazar la lista original con las observaciones no expiradas
|
|
lock (_relayAlarmList)
|
|
{
|
|
_relayAlarmList.RemoveAll(obs => obs.PatientId == obsGroup.Key);
|
|
_relayAlarmList.AddRange(nonExpiredObs);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion |