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 = "شناسه دستگاه نامعتبر است" });
}
// Get all outage events within the date range
var filter = new PowerOutageFilter
{
StartDateUtc = startUtc,
EndDateUtc = endUtc,
PageSize = int.MaxValue // Get all records for summary
PageSize = int.MaxValue
};
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 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
long totalDowntimeSeconds = 0;
var pairedOutages = new List<PowerOutagePairDto>();
@@ -167,7 +188,7 @@ public class PowerOutageController : ControllerBase
}
else
{
// Still disconnected (no subsequent connect)
// Still disconnected (no subsequent connect found anywhere)
pairedOutages.Add(new PowerOutagePairDto
{
DisconnectedAt = outage.TimestampUtc,

View File

@@ -38,6 +38,10 @@ public static class DependencyInjection
services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IAlertConditionService, AlertConditionService>();
services.AddScoped<IAlertLogService, AlertLogService>();
services.AddScoped<AlertConditionEvaluator>();
services.AddScoped<AlertMessageBuilder>();
services.AddScoped<AlertNotificationSender>();
services.AddScoped<AlertPersistenceService>();
// Device
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.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>
/// Orchestrates alert checking, evaluation, sending, and persistence.
/// Delegates specialized work to smaller focused services.
/// </summary>
public sealed class AlertService : IAlertService
{
private readonly GreenHomeDbContext _dbContext;
private readonly IDeviceSettingsService _deviceSettingsService;
private readonly ISmsService _smsService;
private readonly IVoiceCallService _voiceCallService;
private readonly ISunCalculatorService _sunCalculatorService;
private readonly AlertConditionEvaluator _evaluator;
private readonly AlertMessageBuilder _messageBuilder;
private readonly AlertNotificationSender _notificationSender;
private readonly AlertPersistenceService _persistence;
private readonly IPowerOutageLogService _powerOutageLogService;
private readonly ILogger<AlertService> _logger;
public AlertService(
GreenHomeDbContext dbContext,
IDeviceSettingsService deviceSettingsService,
ISmsService smsService,
IVoiceCallService voiceCallService,
ISunCalculatorService sunCalculatorService,
AlertConditionEvaluator evaluator,
AlertMessageBuilder messageBuilder,
AlertNotificationSender notificationSender,
AlertPersistenceService persistence,
IPowerOutageLogService powerOutageLogService,
ILogger<AlertService> logger)
{
_dbContext = dbContext;
_deviceSettingsService = deviceSettingsService;
_smsService = smsService;
_voiceCallService = voiceCallService;
_sunCalculatorService = sunCalculatorService;
_evaluator = evaluator;
_messageBuilder = messageBuilder;
_notificationSender = notificationSender;
_persistence = persistence;
_powerOutageLogService = powerOutageLogService;
_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
.Include(d => d.User)
.Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts))
@@ -51,22 +60,18 @@ public sealed class AlertService : IAlertService
return null;
}
// 2. Load settings
var settings = await _deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
var recievers = string.Join(",",
(device.User.Mobile + "," + (settings?.SmsRecievers ?? ""))
.Split(',')
.Select(x => x.Trim())
.Where(x => x.Length > 0)
.Distinct()
);
if (recievers.Length == 0)
// 3. Build recipient list
var recipients = BuildRecipients(device, settings);
if (recipients.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;
}
// 4. Load enabled conditions
var conditions = await _dbContext.AlertConditions
.Include(c => c.Rules)
.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);
}
bool? isDaytime = null;
if (settings?.Latitude != null && settings.Longitude != null)
// 5. Evaluate conditions and send alerts
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 = [];
foreach (var condition in conditions)
{
if (condition.TimeType == Domain.AlertTimeType.Day && isDaytime == false)
continue;
if (condition.TimeType == Domain.AlertTimeType.Night && isDaytime == true)
continue;
// Send to device owner
var ownerResult = await _notificationSender.TrySendConditionAlertAsync(
condition, device, device.User, message, cancellationToken);
var allRulesMatch = condition.Rules.All(rule => CheckRule(rule, telemetry));
if (allRulesMatch && condition.Rules.Count != 0)
if (ownerResult != null)
{
AlertNotification notif = await GetAlertMessage(condition, device, device.User, telemetry, cancellationToken);
if (notif != null && !string.IsNullOrEmpty(notif.Message))
await _persistence.SaveConditionAlertAsync(
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 _dbContext.AlertNotifications.AddAsync(notif, cancellationToken);
await _persistence.SaveConditionAlertAsync(
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();
if (telemetry.OldPower == 0 && telemetry.Power == 1)
@@ -112,28 +135,23 @@ public sealed class AlertService : IAlertService
result.PowerStatus = "1";
messages.Add($"برق دستگاه {device.DeviceName} متصل شد");
await _powerOutageLogService.LogPowerChangeAsync(
deviceId,
telemetry.OldPower.Value,
telemetry.Power.Value,
telemetry.TimestampUtc ?? DateTime.UtcNow,
cancellationToken);
deviceId, telemetry.OldPower.Value, telemetry.Power.Value,
telemetry.TimestampUtc ?? DateTime.UtcNow, cancellationToken);
}
else if (telemetry.OldPower == 1 && telemetry.Power == 0)
{
result.PowerStatus = "0";
messages.Add($"برق دستگاه {device.DeviceName} قطع شد");
await _powerOutageLogService.LogPowerChangeAsync(
deviceId,
telemetry.OldPower.Value,
telemetry.Power.Value,
telemetry.TimestampUtc ?? DateTime.UtcNow,
cancellationToken);
deviceId, telemetry.OldPower.Value, telemetry.Power.Value,
telemetry.TimestampUtc ?? DateTime.UtcNow, cancellationToken);
}
// 7. Build result
if (messages.Any())
{
result.Messages = messages;
result.Recipients = recievers;
result.Recipients = recipients;
return result;
}
@@ -143,287 +161,6 @@ public sealed class AlertService : IAlertService
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)
{
var device = await _dbContext.Devices
@@ -449,82 +186,46 @@ public sealed class AlertService : IAlertService
}
var message = $"⚠️ هشدار قطع برق! دستگاه {device.DeviceName} از برق قطع شده است.";
var sentAt = DateTime.UtcNow;
foreach (var user in usersToAlert)
{
await SendPowerOutageNotificationAsync(device, user, message, sentAt, Domain.AlertNotificationType.SMS, cancellationToken);
await SendPowerOutageNotificationAsync(device, user, message, sentAt, Domain.AlertNotificationType.Call, cancellationToken);
// Send SMS
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}",
usersToAlert.Count, deviceId);
}
private async Task SendPowerOutageNotificationAsync(
Domain.Device device,
Domain.User user,
string message,
DateTime sentAt,
Domain.AlertNotificationType notificationType,
CancellationToken cancellationToken)
private static string BuildRecipients(DomainDevice device, DeviceSettingsDto? settings)
{
var startTime = DateTime.UtcNow;
string? messageOutboxIds = null;
string? errorMessage = null;
bool isSent = false;
try
{
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);
return string.Join(",",
(device.User.Mobile + "," + (settings?.SmsRecievers ?? ""))
.Split(',')
.Select(x => x.Trim())
.Where(x => x.Length > 0)
.Distinct()
);
}
}