fix PowerOutageSummary and optimize ALertService structure

This commit is contained in:
2026-06-01 19:26:59 +03:30
parent 40fe6e897a
commit 3f7815874a
7 changed files with 612 additions and 402 deletions

View File

@@ -129,11 +129,12 @@ public class PowerOutageController : ControllerBase
return BadRequest(new { error = "شناسه دستگاه نامعتبر است" }); return BadRequest(new { error = "شناسه دستگاه نامعتبر است" });
} }
// Get all outage events within the date range
var filter = new PowerOutageFilter var filter = new PowerOutageFilter
{ {
StartDateUtc = startUtc, StartDateUtc = startUtc,
EndDateUtc = endUtc, EndDateUtc = endUtc,
PageSize = int.MaxValue // Get all records for summary PageSize = int.MaxValue
}; };
var result = await _powerOutageLogService.ListAsync(deviceId, filter, cancellationToken); var result = await _powerOutageLogService.ListAsync(deviceId, filter, cancellationToken);
@@ -141,6 +142,26 @@ public class PowerOutageController : ControllerBase
var outages = result.Items.Where(x => x.CurrentPower == 0).ToList(); var outages = result.Items.Where(x => x.CurrentPower == 0).ToList();
var connections = result.Items.Where(x => x.CurrentPower == 1).ToList(); var connections = result.Items.Where(x => x.CurrentPower == 1).ToList();
// For outages that don't have a matching connect within the range,
// look beyond the end date to find when power was restored
var unmatchedOutages = outages
.Where(o => !connections.Any(c => c.TimestampUtc > o.TimestampUtc))
.ToList();
if (unmatchedOutages.Count > 0 && endUtc.HasValue)
{
// Fetch any connect events after the range end for unmatched outages
var extendedFilter = new PowerOutageFilter
{
StartDateUtc = endUtc.Value,
PageSize = int.MaxValue
};
var extendedResult = await _powerOutageLogService.ListAsync(deviceId, extendedFilter, cancellationToken);
var futureConnections = extendedResult.Items.Where(x => x.CurrentPower == 1).ToList();
connections.AddRange(futureConnections);
}
// Calculate total downtime by pairing disconnects with subsequent connects // Calculate total downtime by pairing disconnects with subsequent connects
long totalDowntimeSeconds = 0; long totalDowntimeSeconds = 0;
var pairedOutages = new List<PowerOutagePairDto>(); var pairedOutages = new List<PowerOutagePairDto>();
@@ -167,7 +188,7 @@ public class PowerOutageController : ControllerBase
} }
else else
{ {
// Still disconnected (no subsequent connect) // Still disconnected (no subsequent connect found anywhere)
pairedOutages.Add(new PowerOutagePairDto pairedOutages.Add(new PowerOutagePairDto
{ {
DisconnectedAt = outage.TimestampUtc, DisconnectedAt = outage.TimestampUtc,

View File

@@ -38,6 +38,10 @@ public static class DependencyInjection
services.AddScoped<IAlertService, AlertService>(); services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IAlertConditionService, AlertConditionService>(); services.AddScoped<IAlertConditionService, AlertConditionService>();
services.AddScoped<IAlertLogService, AlertLogService>(); services.AddScoped<IAlertLogService, AlertLogService>();
services.AddScoped<AlertConditionEvaluator>();
services.AddScoped<AlertMessageBuilder>();
services.AddScoped<AlertNotificationSender>();
services.AddScoped<AlertPersistenceService>();
// Device // Device
services.AddScoped<IDeviceService, DeviceService>(); services.AddScoped<IDeviceService, DeviceService>();

View File

@@ -0,0 +1,81 @@
using GreenHome.Application;
using GreenHome.Domain;
namespace GreenHome.Infrastructure.Services.Alert;
/// <summary>
/// Evaluates alert conditions against telemetry data.
/// Handles time-type filtering (Day/Night/Always) and rule matching.
/// </summary>
public sealed class AlertConditionEvaluator
{
private readonly ISunCalculatorService _sunCalculatorService;
public AlertConditionEvaluator(ISunCalculatorService sunCalculatorService)
{
_sunCalculatorService = sunCalculatorService;
}
/// <summary>
/// Returns conditions whose time-type and all rules match the given telemetry.
/// </summary>
public IReadOnlyList<AlertCondition> GetMatchingConditions(
IEnumerable<AlertCondition> conditions,
TelemetryDto telemetry,
DeviceSettingsDto? settings)
{
bool? isDaytime = null;
if (settings?.Latitude != null && settings.Longitude != null)
{
isDaytime = _sunCalculatorService.IsDaytime(
DateTime.UtcNow, settings.Latitude.Value, settings.Longitude.Value);
}
var matched = new List<AlertCondition>();
foreach (var condition in conditions)
{
if (!condition.IsEnabled)
continue;
// Filter by time of day
if (condition.TimeType == AlertTimeType.Day && isDaytime == false)
continue;
if (condition.TimeType == AlertTimeType.Night && isDaytime == true)
continue;
// All rules must match (AND logic)
if (condition.Rules.Count > 0 && condition.Rules.All(rule => CheckRule(rule, telemetry)))
{
matched.Add(condition);
}
}
return matched;
}
/// <summary>
/// Checks whether a single rule is satisfied by the telemetry data.
/// </summary>
public static bool CheckRule(AlertRule rule, TelemetryDto telemetry)
{
var sensorValue = rule.SensorType switch
{
SensorType.Temperature => telemetry.TemperatureC,
SensorType.Humidity => telemetry.HumidityPercent,
SensorType.Soil => telemetry.SoilPercent,
SensorType.Gas => telemetry.GasPPM,
SensorType.Lux => telemetry.Lux,
_ => 0m
};
return rule.ComparisonType switch
{
ComparisonType.GreaterThan => sensorValue > rule.Value1,
ComparisonType.LessThan => sensorValue < rule.Value1,
ComparisonType.Between => rule.Value2 != null && sensorValue >= rule.Value1 && sensorValue <= rule.Value2.Value,
ComparisonType.OutOfRange => rule.Value2 != null && (sensorValue < rule.Value1 || sensorValue > rule.Value2.Value),
_ => false
};
}
}

View File

@@ -0,0 +1,67 @@
using GreenHome.Application;
using GreenHome.Domain;
namespace GreenHome.Infrastructure.Services.Alert;
/// <summary>
/// Builds human-readable (Persian) alert messages from alert conditions and telemetry data.
/// </summary>
public sealed class AlertMessageBuilder
{
/// <summary>
/// Builds a descriptive Persian alert message for the given condition and telemetry.
/// </summary>
public string BuildMessage(AlertCondition condition, string deviceName, TelemetryDto telemetry)
{
var parts = new List<string>
{
$"هشدار گلخانه {deviceName}:"
};
foreach (var rule in condition.Rules.OrderBy(r => r.Order))
{
var sensorName = rule.SensorType switch
{
SensorType.Temperature => "دما",
SensorType.Humidity => "رطوبت",
SensorType.Soil => "رطوبت خاک",
SensorType.Gas => "گاز",
SensorType.Lux => "نور",
_ => "سنسور"
};
var sensorValue = rule.SensorType switch
{
SensorType.Temperature => telemetry.TemperatureC,
SensorType.Humidity => telemetry.HumidityPercent,
SensorType.Soil => telemetry.SoilPercent,
SensorType.Gas => telemetry.GasPPM,
SensorType.Lux => telemetry.Lux,
_ => 0m
};
var unit = rule.SensorType switch
{
SensorType.Temperature => "°C",
SensorType.Humidity => "%",
SensorType.Soil => "%",
SensorType.Gas => "PPM",
SensorType.Lux => "لوکس",
_ => ""
};
var conditionText = rule.ComparisonType switch
{
ComparisonType.GreaterThan => $"{sensorName} ({sensorValue:F1}{unit}) بیشتر از {rule.Value1}{unit}",
ComparisonType.LessThan => $"{sensorName} ({sensorValue:F1}{unit}) کمتر از {rule.Value1}{unit}",
ComparisonType.Between => $"{sensorName} ({sensorValue:F1}{unit}) بین {rule.Value1} و {rule.Value2}{unit}",
ComparisonType.OutOfRange => $"{sensorName} ({sensorValue:F1}{unit}) خارج از محدوده {rule.Value1} تا {rule.Value2}{unit}",
_ => $"{sensorName}: {sensorValue:F1}{unit}"
};
parts.Add(conditionText);
}
return string.Join(" و ", parts);
}
}

View File

@@ -0,0 +1,200 @@
using GreenHome.Application;
using GreenHome.Domain;
using GreenHome.Infrastructure.Data;
using GreenHome.Sms.Ippanel;
using GreenHome.VoiceCall.Avanak;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace GreenHome.Infrastructure.Services.Alert;
// Alias to resolve ambiguity between GreenHome.Domain.Device and the Alert namespace
using DomainDevice = GreenHome.Domain.Device;
/// <summary>
/// Handles sending alert notifications via SMS or Voice Call,
/// including cooldown checks and error handling.
/// </summary>
public sealed class AlertNotificationSender
{
private readonly GreenHomeDbContext _dbContext;
private readonly ISmsService _smsService;
private readonly IVoiceCallService _voiceCallService;
private readonly ILogger<AlertNotificationSender> _logger;
public AlertNotificationSender(
GreenHomeDbContext dbContext,
ISmsService smsService,
IVoiceCallService voiceCallService,
ILogger<AlertNotificationSender> logger)
{
_dbContext = dbContext;
_smsService = smsService;
_voiceCallService = voiceCallService;
_logger = logger;
}
/// <summary>
/// Result of sending a single notification.
/// </summary>
public sealed record SendResult(
bool IsSent,
string? MessageOutboxIds,
string? ErrorMessage,
long ProcessingTimeMs);
/// <summary>
/// Checks cooldown and sends an alert notification for a condition to a user.
/// Returns null if skipped due to cooldown.
/// </summary>
public async Task<SendResult?> TrySendConditionAlertAsync(
AlertCondition condition,
DomainDevice device,
User user,
string message,
CancellationToken cancellationToken)
{
// Check cooldown
var cooldownMinutes = condition.NotificationType == AlertNotificationType.Call
? condition.CallCooldownMinutes
: condition.SmsCooldownMinutes;
var sentAt = DateTime.UtcNow;
var cooldownTime = sentAt.AddMinutes(-cooldownMinutes);
var recentAlert = await _dbContext.AlertNotifications
.Where(a => a.DeviceId == device.Id &&
a.UserId == user.Id &&
a.AlertConditionId == condition.Id &&
a.SentAt >= cooldownTime)
.FirstOrDefaultAsync(cancellationToken);
if (recentAlert != null)
{
_logger.LogInformation(
"Alert skipped due to cooldown: DeviceId={DeviceId}, UserId={UserId}, ConditionId={ConditionId}",
device.Id, user.Id, condition.Id);
return null;
}
// Send
return await SendAsync(
user.Mobile, device.DeviceName, message,
condition.NotificationType, cancellationToken);
}
/// <summary>
/// Sends a power-outage notification (no cooldown check).
/// </summary>
public async Task<SendResult> SendPowerOutageAlertAsync(
string mobile,
string deviceName,
string message,
AlertNotificationType notificationType,
CancellationToken cancellationToken)
{
return await SendAsync(mobile, deviceName, message, notificationType, cancellationToken);
}
private async Task<SendResult> SendAsync(
string mobile,
string deviceName,
string message,
AlertNotificationType notificationType,
CancellationToken cancellationToken)
{
var startTime = DateTime.UtcNow;
try
{
if (notificationType == AlertNotificationType.SMS)
{
return await SendSmsAsync(mobile, deviceName, message, startTime, cancellationToken);
}
else
{
return await SendCallAsync(mobile, deviceName, message, startTime, cancellationToken);
}
}
catch (Exception ex)
{
var errorMessage = $"Exception: {ex.Message}";
if (ex.InnerException != null)
errorMessage += $" | InnerException: {ex.InnerException.Message}";
var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
_logger.LogError(ex, "Failed to send alert: Mobile={Mobile}, Type={Type}", mobile, notificationType);
return new SendResult(false, null, errorMessage, processingTime);
}
}
private async Task<SendResult> SendSmsAsync(
string mobile,
string deviceName,
string message,
DateTime startTime,
CancellationToken cancellationToken)
{
var smsResponse = await _smsService.SendPatternSmsAsync(new PatternSmsRequest
{
Recipients = [mobile],
PatternCode = "64di3w9kb0fxvif",
Variables = new Dictionary<string, string>
{
{ "name", deviceName },
{ "parameter", "شرایط" },
{ "value", message },
{ "status", "هشدار" }
}
}, cancellationToken);
var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
if (smsResponse != null && smsResponse.Meta.Status &&
smsResponse.Data?.MessageOutboxIds != null &&
smsResponse.Data.MessageOutboxIds.Count > 0)
{
var outboxIds = JsonSerializer.Serialize(smsResponse.Data.MessageOutboxIds);
_logger.LogInformation("Alert SMS sent: Mobile={Mobile}, OutboxIds={OutboxIds}", mobile, outboxIds);
return new SendResult(true, outboxIds, null, processingTime);
}
// Build error message from response
var errors = new List<string>();
if (!string.IsNullOrWhiteSpace(smsResponse?.Meta.Message))
errors.Add(smsResponse.Meta.Message);
if (smsResponse?.Meta.Errors != null)
{
foreach (var error in smsResponse.Meta.Errors)
errors.Add($"{error.Key}: {string.Join(", ", error.Value)}");
}
var errorMsg = errors.Count > 0 ? string.Join(" | ", errors) : "Unknown SMS error";
_logger.LogWarning("Alert SMS failed: Mobile={Mobile}, Error={Error}", mobile, errorMsg);
return new SendResult(false, null, errorMsg, processingTime);
}
private async Task<SendResult> SendCallAsync(
string mobile,
string deviceName,
string message,
DateTime startTime,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Voice call alert requested: Mobile={Mobile}, Message={Message}", mobile, message);
var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
return new SendResult(true, null, "Voice call not yet implemented", processingTime);
}
catch (Exception ex)
{
var errorMsg = $"Exception: {ex.Message}";
var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
_logger.LogError(ex, "Exception sending call alert: Mobile={Mobile}", mobile);
return new SendResult(false, null, errorMsg, processingTime);
}
}
}

View File

@@ -0,0 +1,136 @@
using GreenHome.Domain;
using GreenHome.Infrastructure.Data;
namespace GreenHome.Infrastructure.Services.Alert;
// Alias to resolve ambiguity between GreenHome.Domain.Device and the Alert namespace
using DomainDevice = GreenHome.Domain.Device;
/// <summary>
/// Persists alert notifications and logs to the database.
/// </summary>
public sealed class AlertPersistenceService
{
private readonly GreenHomeDbContext _dbContext;
public AlertPersistenceService(GreenHomeDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// Saves an AlertNotification and an AlertLog entry for a condition-based alert.
/// </summary>
public async Task SaveConditionAlertAsync(
DomainDevice device,
User user,
AlertCondition condition,
string message,
bool isSent,
string? messageOutboxIds,
string? errorMessage,
long processingTimeMs,
CancellationToken cancellationToken)
{
var sentAt = DateTime.UtcNow;
var notification = new AlertNotification
{
DeviceId = device.Id,
UserId = user.Id,
AlertConditionId = condition.Id,
NotificationType = condition.NotificationType,
Message = message,
MessageOutboxIds = messageOutboxIds,
ErrorMessage = errorMessage,
SentAt = sentAt,
IsSent = isSent
};
_dbContext.AlertNotifications.Add(notification);
var alertLog = new AlertLog
{
DeviceId = device.Id,
UserId = user.Id,
AlertConditionId = condition.Id,
AlertType = AlertType.Condition,
NotificationType = condition.NotificationType,
Message = message,
Status = isSent ? AlertStatus.Success : AlertStatus.Failed,
ErrorMessage = errorMessage,
PhoneNumber = user.Mobile,
SentAt = sentAt,
ProcessingTimeMs = processingTimeMs
};
_dbContext.AlertLogs.Add(alertLog);
}
/// <summary>
/// Saves an AlertNotification and an AlertLog entry for a power-outage alert.
/// </summary>
public async Task SavePowerOutageAlertAsync(
DomainDevice device,
User user,
string message,
AlertNotificationType notificationType,
bool isSent,
string? messageOutboxIds,
string? errorMessage,
long processingTimeMs,
CancellationToken cancellationToken)
{
var sentAt = DateTime.UtcNow;
var notification = new AlertNotification
{
DeviceId = device.Id,
UserId = user.Id,
AlertConditionId = null,
NotificationType = notificationType,
Message = message,
MessageOutboxIds = messageOutboxIds,
ErrorMessage = errorMessage,
SentAt = sentAt,
IsSent = isSent
};
_dbContext.AlertNotifications.Add(notification);
var alertLog = new AlertLog
{
DeviceId = device.Id,
UserId = user.Id,
AlertConditionId = null,
AlertType = AlertType.PowerOutage,
NotificationType = notificationType,
Message = message,
Status = isSent ? AlertStatus.Success : AlertStatus.Failed,
ErrorMessage = errorMessage,
PhoneNumber = user.Mobile,
SentAt = sentAt,
ProcessingTimeMs = processingTimeMs
};
_dbContext.AlertLogs.Add(alertLog);
}
/// <summary>
/// Saves a simple AlertNotification (used in the old GetAlertMessage flow for non-sent notifications).
/// </summary>
public async Task SaveNotificationAsync(
AlertNotification notification,
CancellationToken cancellationToken)
{
_dbContext.AlertNotifications.Add(notification);
}
/// <summary>
/// Persists all pending changes to the database.
/// </summary>
public async Task SaveChangesAsync(CancellationToken cancellationToken)
{
await _dbContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,44 +1,53 @@
using GreenHome.Application; using GreenHome.Application;
using GreenHome.Domain; using GreenHome.Domain;
using GreenHome.Infrastructure.Data; using GreenHome.Infrastructure.Data;
using GreenHome.Sms.Ippanel;
using GreenHome.VoiceCall.Avanak;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace GreenHome.Infrastructure.Services.Alert; namespace GreenHome.Infrastructure.Services.Alert;
// Alias to resolve ambiguity between GreenHome.Domain.Device and the Alert namespace
using DomainDevice = GreenHome.Domain.Device;
/// <summary>
/// Orchestrates alert checking, evaluation, sending, and persistence.
/// Delegates specialized work to smaller focused services.
/// </summary>
public sealed class AlertService : IAlertService public sealed class AlertService : IAlertService
{ {
private readonly GreenHomeDbContext _dbContext; private readonly GreenHomeDbContext _dbContext;
private readonly IDeviceSettingsService _deviceSettingsService; private readonly IDeviceSettingsService _deviceSettingsService;
private readonly ISmsService _smsService; private readonly AlertConditionEvaluator _evaluator;
private readonly IVoiceCallService _voiceCallService; private readonly AlertMessageBuilder _messageBuilder;
private readonly ISunCalculatorService _sunCalculatorService; private readonly AlertNotificationSender _notificationSender;
private readonly AlertPersistenceService _persistence;
private readonly IPowerOutageLogService _powerOutageLogService; private readonly IPowerOutageLogService _powerOutageLogService;
private readonly ILogger<AlertService> _logger; private readonly ILogger<AlertService> _logger;
public AlertService( public AlertService(
GreenHomeDbContext dbContext, GreenHomeDbContext dbContext,
IDeviceSettingsService deviceSettingsService, IDeviceSettingsService deviceSettingsService,
ISmsService smsService, AlertConditionEvaluator evaluator,
IVoiceCallService voiceCallService, AlertMessageBuilder messageBuilder,
ISunCalculatorService sunCalculatorService, AlertNotificationSender notificationSender,
AlertPersistenceService persistence,
IPowerOutageLogService powerOutageLogService, IPowerOutageLogService powerOutageLogService,
ILogger<AlertService> logger) ILogger<AlertService> logger)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_deviceSettingsService = deviceSettingsService; _deviceSettingsService = deviceSettingsService;
_smsService = smsService; _evaluator = evaluator;
_voiceCallService = voiceCallService; _messageBuilder = messageBuilder;
_sunCalculatorService = sunCalculatorService; _notificationSender = notificationSender;
_persistence = persistence;
_powerOutageLogService = powerOutageLogService; _powerOutageLogService = powerOutageLogService;
_logger = logger; _logger = logger;
} }
public async Task<AlertCheckResult?> CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken) public async Task<AlertCheckResult?> CheckAndSendAlertsAsync(
int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken)
{ {
// 1. Load device with users
var device = await _dbContext.Devices var device = await _dbContext.Devices
.Include(d => d.User) .Include(d => d.User)
.Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts)) .Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts))
@@ -51,22 +60,18 @@ public sealed class AlertService : IAlertService
return null; return null;
} }
// 2. Load settings
var settings = await _deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken); var settings = await _deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
var recievers = string.Join(",", // 3. Build recipient list
(device.User.Mobile + "," + (settings?.SmsRecievers ?? "")) var recipients = BuildRecipients(device, settings);
.Split(',') if (recipients.Length == 0)
.Select(x => x.Trim())
.Where(x => x.Length > 0)
.Distinct()
);
if (recievers.Length == 0)
{ {
_logger.LogInformation("No users with ReceiveAlerts enabled for device: DeviceId={DeviceId}", deviceId); _logger.LogInformation("No recipients for device: DeviceId={DeviceId}", deviceId);
return null; return null;
} }
// 4. Load enabled conditions
var conditions = await _dbContext.AlertConditions var conditions = await _dbContext.AlertConditions
.Include(c => c.Rules) .Include(c => c.Rules)
.Where(c => c.DeviceId == deviceId && c.IsEnabled) .Where(c => c.DeviceId == deviceId && c.IsEnabled)
@@ -77,34 +82,52 @@ public sealed class AlertService : IAlertService
_logger.LogDebug("No enabled alert conditions for device: DeviceId={DeviceId}", deviceId); _logger.LogDebug("No enabled alert conditions for device: DeviceId={DeviceId}", deviceId);
} }
bool? isDaytime = null; // 5. Evaluate conditions and send alerts
if (settings?.Latitude != null && settings.Longitude != null) var matchedConditions = _evaluator.GetMatchingConditions(conditions, telemetry, settings);
var messages = new List<string>();
foreach (var condition in matchedConditions)
{ {
isDaytime = _sunCalculatorService.IsDaytime(DateTime.UtcNow, settings.Latitude.Value, settings.Longitude.Value); var message = _messageBuilder.BuildMessage(condition, device.DeviceName, telemetry);
}
List<string> messages = []; // Send to device owner
foreach (var condition in conditions) var ownerResult = await _notificationSender.TrySendConditionAlertAsync(
{ condition, device, device.User, message, cancellationToken);
if (condition.TimeType == Domain.AlertTimeType.Day && isDaytime == false)
continue;
if (condition.TimeType == Domain.AlertTimeType.Night && isDaytime == true)
continue;
var allRulesMatch = condition.Rules.All(rule => CheckRule(rule, telemetry)); if (ownerResult != null)
if (allRulesMatch && condition.Rules.Count != 0)
{ {
AlertNotification notif = await GetAlertMessage(condition, device, device.User, telemetry, cancellationToken); await _persistence.SaveConditionAlertAsync(
if (notif != null && !string.IsNullOrEmpty(notif.Message)) device, device.User, condition, message,
ownerResult.IsSent, ownerResult.MessageOutboxIds,
ownerResult.ErrorMessage, ownerResult.ProcessingTimeMs,
cancellationToken);
messages.Add(message);
}
// Send to additional users who receive alerts
foreach (var deviceUser in device.DeviceUsers)
{
var userResult = await _notificationSender.TrySendConditionAlertAsync(
condition, device, deviceUser.User, message, cancellationToken);
if (userResult != null)
{ {
messages.Add(notif.Message); await _persistence.SaveConditionAlertAsync(
await _dbContext.AlertNotifications.AddAsync(notif, cancellationToken); device, deviceUser.User, condition, message,
userResult.IsSent, userResult.MessageOutboxIds,
userResult.ErrorMessage, userResult.ProcessingTimeMs,
cancellationToken);
messages.Add(message);
} }
} }
} }
await _dbContext.SaveChangesAsync(cancellationToken);
await _persistence.SaveChangesAsync(cancellationToken);
// 6. Handle power status changes
var result = new AlertCheckResult(); var result = new AlertCheckResult();
if (telemetry.OldPower == 0 && telemetry.Power == 1) if (telemetry.OldPower == 0 && telemetry.Power == 1)
@@ -112,28 +135,23 @@ public sealed class AlertService : IAlertService
result.PowerStatus = "1"; result.PowerStatus = "1";
messages.Add($"برق دستگاه {device.DeviceName} متصل شد"); messages.Add($"برق دستگاه {device.DeviceName} متصل شد");
await _powerOutageLogService.LogPowerChangeAsync( await _powerOutageLogService.LogPowerChangeAsync(
deviceId, deviceId, telemetry.OldPower.Value, telemetry.Power.Value,
telemetry.OldPower.Value, telemetry.TimestampUtc ?? DateTime.UtcNow, cancellationToken);
telemetry.Power.Value,
telemetry.TimestampUtc ?? DateTime.UtcNow,
cancellationToken);
} }
else if (telemetry.OldPower == 1 && telemetry.Power == 0) else if (telemetry.OldPower == 1 && telemetry.Power == 0)
{ {
result.PowerStatus = "0"; result.PowerStatus = "0";
messages.Add($"برق دستگاه {device.DeviceName} قطع شد"); messages.Add($"برق دستگاه {device.DeviceName} قطع شد");
await _powerOutageLogService.LogPowerChangeAsync( await _powerOutageLogService.LogPowerChangeAsync(
deviceId, deviceId, telemetry.OldPower.Value, telemetry.Power.Value,
telemetry.OldPower.Value, telemetry.TimestampUtc ?? DateTime.UtcNow, cancellationToken);
telemetry.Power.Value,
telemetry.TimestampUtc ?? DateTime.UtcNow,
cancellationToken);
} }
// 7. Build result
if (messages.Any()) if (messages.Any())
{ {
result.Messages = messages; result.Messages = messages;
result.Recipients = recievers; result.Recipients = recipients;
return result; return result;
} }
@@ -143,287 +161,6 @@ public sealed class AlertService : IAlertService
return null; return null;
} }
private bool CheckRule(Domain.AlertRule rule, TelemetryDto telemetry)
{
var sensorValue = rule.SensorType switch
{
Domain.SensorType.Temperature => telemetry.TemperatureC,
Domain.SensorType.Humidity => telemetry.HumidityPercent,
Domain.SensorType.Soil => telemetry.SoilPercent,
Domain.SensorType.Gas => telemetry.GasPPM,
Domain.SensorType.Lux => telemetry.Lux,
_ => 0m
};
return rule.ComparisonType switch
{
Domain.ComparisonType.GreaterThan => sensorValue > rule.Value1,
Domain.ComparisonType.LessThan => sensorValue < rule.Value1,
Domain.ComparisonType.Between => rule.Value2 != null && sensorValue >= rule.Value1 && sensorValue <= rule.Value2.Value,
Domain.ComparisonType.OutOfRange => rule.Value2 != null && (sensorValue < rule.Value1 || sensorValue > rule.Value2.Value),
_ => false
};
}
private async Task<AlertNotification> GetAlertMessage(
Domain.AlertCondition condition,
Domain.Device device,
User user,
TelemetryDto telemetry,
CancellationToken cancellationToken)
{
var cooldownMinutes = condition.NotificationType == Domain.AlertNotificationType.Call
? condition.CallCooldownMinutes
: condition.SmsCooldownMinutes;
var message = BuildAlertMessage(condition, device.DeviceName, telemetry);
var sentAt = DateTime.UtcNow;
var cooldownTime = sentAt.AddMinutes(-cooldownMinutes);
var recentAlert = await _dbContext.AlertNotifications
.Where(a => a.DeviceId == device.Id &&
a.UserId == user.Id &&
a.AlertConditionId == condition.Id &&
a.SentAt >= cooldownTime)
.FirstOrDefaultAsync(cancellationToken);
if (recentAlert != null)
{
_logger.LogInformation("Alert skipped due to cooldown: DeviceId={DeviceId}, UserId={UserId}, ConditionId={ConditionId}",
device.Id, user.Id, condition.Id);
return null;
}
var notification = new Domain.AlertNotification
{
DeviceId = device.Id,
UserId = user.Id,
AlertConditionId = condition.Id,
NotificationType = condition.NotificationType,
Message = message,
SentAt = sentAt,
};
return notification;
}
private async Task SendAlertForConditionAsync(
Domain.AlertCondition condition,
Domain.Device device,
List<User> usersToAlert,
TelemetryDto telemetry,
CancellationToken cancellationToken)
{
var cooldownMinutes = condition.NotificationType == Domain.AlertNotificationType.Call
? condition.CallCooldownMinutes
: condition.SmsCooldownMinutes;
var message = BuildAlertMessage(condition, device.DeviceName, telemetry);
var sentAt = DateTime.UtcNow;
foreach (var user in usersToAlert)
{
var cooldownTime = sentAt.AddMinutes(-cooldownMinutes);
var recentAlert = await _dbContext.AlertNotifications
.Where(a => a.DeviceId == device.Id &&
a.UserId == user.Id &&
a.AlertConditionId == condition.Id &&
a.SentAt >= cooldownTime)
.FirstOrDefaultAsync(cancellationToken);
if (recentAlert != null)
{
_logger.LogInformation("Alert skipped due to cooldown: DeviceId={DeviceId}, UserId={UserId}, ConditionId={ConditionId}",
device.Id, user.Id, condition.Id);
continue;
}
var startTime = DateTime.UtcNow;
string? messageOutboxIds = null;
string? errorMessage = null;
bool isSent = false;
try
{
if (condition.NotificationType == Domain.AlertNotificationType.SMS)
{
(isSent, messageOutboxIds, errorMessage) = await SendSmsAlertAsync(user.Mobile, device.DeviceName, message, cancellationToken);
}
else
{
(isSent, messageOutboxIds, errorMessage) = await SendCallAlertAsync(user.Mobile, device.DeviceName, message, cancellationToken);
}
}
catch (Exception ex)
{
errorMessage = $"Exception: {ex.Message}";
if (ex.InnerException != null)
errorMessage += $" | InnerException: {ex.InnerException.Message}";
isSent = false;
_logger.LogError(ex, "Failed to send alert: DeviceId={DeviceId}, UserId={UserId}, ConditionId={ConditionId}",
device.Id, user.Id, condition.Id);
}
var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
var notification = new Domain.AlertNotification
{
DeviceId = device.Id,
UserId = user.Id,
AlertConditionId = condition.Id,
NotificationType = condition.NotificationType,
Message = message,
MessageOutboxIds = messageOutboxIds,
ErrorMessage = errorMessage,
SentAt = sentAt,
IsSent = isSent
};
_dbContext.AlertNotifications.Add(notification);
var alertLog = new Domain.AlertLog
{
DeviceId = device.Id,
UserId = user.Id,
AlertConditionId = condition.Id,
AlertType = Domain.AlertType.Condition,
NotificationType = condition.NotificationType,
Message = message,
Status = isSent ? Domain.AlertStatus.Success : Domain.AlertStatus.Failed,
ErrorMessage = errorMessage,
PhoneNumber = user.Mobile,
SentAt = sentAt,
ProcessingTimeMs = processingTime
};
_dbContext.AlertLogs.Add(alertLog);
}
await _dbContext.SaveChangesAsync(cancellationToken);
}
private string BuildAlertMessage(Domain.AlertCondition condition, string deviceName, TelemetryDto telemetry)
{
var parts = new List<string>();
parts.Add($"هشدار گلخانه {deviceName}:");
foreach (var rule in condition.Rules.OrderBy(r => r.Order))
{
var sensorName = rule.SensorType switch
{
Domain.SensorType.Temperature => "دما",
Domain.SensorType.Humidity => "رطوبت",
Domain.SensorType.Soil => "رطوبت خاک",
Domain.SensorType.Gas => "گاز",
Domain.SensorType.Lux => "نور",
_ => "سنسور"
};
var sensorValue = rule.SensorType switch
{
Domain.SensorType.Temperature => telemetry.TemperatureC,
Domain.SensorType.Humidity => telemetry.HumidityPercent,
Domain.SensorType.Soil => telemetry.SoilPercent,
Domain.SensorType.Gas => telemetry.GasPPM,
Domain.SensorType.Lux => telemetry.Lux,
_ => 0m
};
var unit = rule.SensorType switch
{
Domain.SensorType.Temperature => "°C",
Domain.SensorType.Humidity => "%",
Domain.SensorType.Soil => "%",
Domain.SensorType.Gas => "PPM",
Domain.SensorType.Lux => "لوکس",
_ => ""
};
var conditionText = rule.ComparisonType switch
{
Domain.ComparisonType.GreaterThan => $"{sensorName} ({sensorValue:F1}{unit}) بیشتر از {rule.Value1}{unit}",
Domain.ComparisonType.LessThan => $"{sensorName} ({sensorValue:F1}{unit}) کمتر از {rule.Value1}{unit}",
Domain.ComparisonType.Between => $"{sensorName} ({sensorValue:F1}{unit}) بین {rule.Value1} و {rule.Value2}{unit}",
Domain.ComparisonType.OutOfRange => $"{sensorName} ({sensorValue:F1}{unit}) خارج از محدوده {rule.Value1} تا {rule.Value2}{unit}",
_ => $"{sensorName}: {sensorValue:F1}{unit}"
};
parts.Add(conditionText);
}
return string.Join(" و ", parts);
}
private async Task<(bool isSent, string? messageOutboxIds, string? errorMessage)> SendSmsAlertAsync(
string mobile,
string deviceName,
string message,
CancellationToken cancellationToken)
{
try
{
var smsResponse = await _smsService.SendPatternSmsAsync(new PatternSmsRequest
{
Recipients = [mobile],
PatternCode = "64di3w9kb0fxvif",
Variables = new Dictionary<string, string> {
{ "name", deviceName },
{ "parameter", "شرایط" },
{ "value", message },
{ "status", "هشدار" }
}
}, cancellationToken);
if (smsResponse != null && smsResponse.Meta.Status &&
smsResponse.Data?.MessageOutboxIds != null &&
smsResponse.Data.MessageOutboxIds.Count > 0)
{
var outboxIds = JsonSerializer.Serialize(smsResponse.Data.MessageOutboxIds);
_logger.LogInformation("Alert SMS sent: Mobile={Mobile}, OutboxIds={OutboxIds}", mobile, outboxIds);
return (true, outboxIds, null);
}
else
{
var errors = new List<string>();
if (!string.IsNullOrWhiteSpace(smsResponse?.Meta.Message))
errors.Add(smsResponse.Meta.Message);
if (smsResponse?.Meta.Errors != null)
{
foreach (var error in smsResponse.Meta.Errors)
errors.Add($"{error.Key}: {string.Join(", ", error.Value)}");
}
var errorMsg = errors.Count > 0 ? string.Join(" | ", errors) : "Unknown SMS error";
_logger.LogWarning("Alert SMS failed: Mobile={Mobile}, Error={Error}", mobile, errorMsg);
return (false, null, errorMsg);
}
}
catch (Exception ex)
{
var errorMsg = $"Exception: {ex.Message}";
_logger.LogError(ex, "Exception sending SMS alert: Mobile={Mobile}", mobile);
return (false, null, errorMsg);
}
}
private async Task<(bool isSent, string? callId, string? errorMessage)> SendCallAlertAsync(
string mobile,
string deviceName,
string message,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Voice call alert requested: Mobile={Mobile}, Message={Message}", mobile, message);
return (true, null, "Voice call not yet implemented");
}
catch (Exception ex)
{
var errorMsg = $"Exception: {ex.Message}";
_logger.LogError(ex, "Exception sending call alert: Mobile={Mobile}", mobile);
return (false, null, errorMsg);
}
}
public async Task SendPowerOutageAlertAsync(int deviceId, CancellationToken cancellationToken) public async Task SendPowerOutageAlertAsync(int deviceId, CancellationToken cancellationToken)
{ {
var device = await _dbContext.Devices var device = await _dbContext.Devices
@@ -449,82 +186,46 @@ public sealed class AlertService : IAlertService
} }
var message = $"⚠️ هشدار قطع برق! دستگاه {device.DeviceName} از برق قطع شده است."; var message = $"⚠️ هشدار قطع برق! دستگاه {device.DeviceName} از برق قطع شده است.";
var sentAt = DateTime.UtcNow;
foreach (var user in usersToAlert) foreach (var user in usersToAlert)
{ {
await SendPowerOutageNotificationAsync(device, user, message, sentAt, Domain.AlertNotificationType.SMS, cancellationToken); // Send SMS
await SendPowerOutageNotificationAsync(device, user, message, sentAt, Domain.AlertNotificationType.Call, cancellationToken); var smsResult = await _notificationSender.SendPowerOutageAlertAsync(
user.Mobile, device.DeviceName, message,
AlertNotificationType.SMS, cancellationToken);
await _persistence.SavePowerOutageAlertAsync(
device, user, message, AlertNotificationType.SMS,
smsResult.IsSent, smsResult.MessageOutboxIds,
smsResult.ErrorMessage, smsResult.ProcessingTimeMs,
cancellationToken);
// Send Voice Call
var callResult = await _notificationSender.SendPowerOutageAlertAsync(
user.Mobile, device.DeviceName, message,
AlertNotificationType.Call, cancellationToken);
await _persistence.SavePowerOutageAlertAsync(
device, user, message, AlertNotificationType.Call,
callResult.IsSent, callResult.MessageOutboxIds,
callResult.ErrorMessage, callResult.ProcessingTimeMs,
cancellationToken);
} }
await _dbContext.SaveChangesAsync(cancellationToken); await _persistence.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Power outage alerts sent to {Count} users for device {DeviceId}", _logger.LogInformation("Power outage alerts sent to {Count} users for device {DeviceId}",
usersToAlert.Count, deviceId); usersToAlert.Count, deviceId);
} }
private async Task SendPowerOutageNotificationAsync( private static string BuildRecipients(DomainDevice device, DeviceSettingsDto? settings)
Domain.Device device,
Domain.User user,
string message,
DateTime sentAt,
Domain.AlertNotificationType notificationType,
CancellationToken cancellationToken)
{ {
var startTime = DateTime.UtcNow; return string.Join(",",
string? messageOutboxIds = null; (device.User.Mobile + "," + (settings?.SmsRecievers ?? ""))
string? errorMessage = null; .Split(',')
bool isSent = false; .Select(x => x.Trim())
.Where(x => x.Length > 0)
try .Distinct()
{ );
if (notificationType == Domain.AlertNotificationType.SMS)
(isSent, messageOutboxIds, errorMessage) = await SendSmsAlertAsync(user.Mobile, device.DeviceName, message, cancellationToken);
else
(isSent, messageOutboxIds, errorMessage) = await SendCallAlertAsync(user.Mobile, device.DeviceName, message, cancellationToken);
}
catch (Exception ex)
{
errorMessage = $"Exception: {ex.Message}";
if (ex.InnerException != null)
errorMessage += $" | InnerException: {ex.InnerException.Message}";
isSent = false;
_logger.LogError(ex, "Failed to send power outage alert: DeviceId={DeviceId}, UserId={UserId}, Type={Type}",
device.Id, user.Id, notificationType);
}
var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
var notification = new Domain.AlertNotification
{
DeviceId = device.Id,
UserId = user.Id,
AlertConditionId = null,
NotificationType = notificationType,
Message = message,
MessageOutboxIds = messageOutboxIds,
ErrorMessage = errorMessage,
SentAt = sentAt,
IsSent = isSent
};
_dbContext.AlertNotifications.Add(notification);
var alertLog = new Domain.AlertLog
{
DeviceId = device.Id,
UserId = user.Id,
AlertConditionId = null,
AlertType = Domain.AlertType.PowerOutage,
NotificationType = notificationType,
Message = message,
Status = isSent ? Domain.AlertStatus.Success : Domain.AlertStatus.Failed,
ErrorMessage = errorMessage,
PhoneNumber = user.Mobile,
SentAt = sentAt,
ProcessingTimeMs = processingTime
};
_dbContext.AlertLogs.Add(alertLog);
} }
} }