version 2

This commit is contained in:
2025-12-16 16:52:40 +03:30
parent 61e86b1e96
commit 139924db94
52 changed files with 7350 additions and 321 deletions

View File

@@ -1,5 +1,6 @@
using GreenHome.Application;
using GreenHome.Sms.Ippanel;
using GreenHome.VoiceCall.Avanak;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Text.Json;
@@ -12,280 +13,153 @@ 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 ILogger<AlertService> logger;
private const int AlertCooldownMinutes = 10;
private sealed record AlertInfo(
string Type,
string Message,
string ParameterName,
decimal Value,
string Status
);
public AlertService(
GreenHomeDbContext dbContext,
IDeviceSettingsService deviceSettingsService,
ISmsService smsService,
IVoiceCallService voiceCallService,
ISunCalculatorService sunCalculatorService,
ILogger<AlertService> logger)
{
this.dbContext = dbContext;
this.deviceSettingsService = deviceSettingsService;
this.smsService = smsService;
this.voiceCallService = voiceCallService;
this.sunCalculatorService = sunCalculatorService;
this.logger = logger;
}
public async Task CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken)
{
var settings = await deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
if (settings == null)
{
return;
}
// Get device with settings and user
var device = await dbContext.Devices
.Include(d => d.User)
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
if (device == null || device.User == null)
{
logger.LogWarning("Device or user not found: DeviceId={DeviceId}", deviceId);
return;
}
var alerts = CollectAlerts(telemetry, settings, device.DeviceName);
// Get device settings for location
var settings = await deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
foreach (var alert in alerts)
// Get all enabled alert conditions for this device
var conditions = await dbContext.AlertConditions
.Include(c => c.Rules)
.Where(c => c.DeviceId == deviceId && c.IsEnabled)
.ToListAsync(cancellationToken);
if (!conditions.Any())
{
await SendAlertIfNeededAsync(deviceId, device.User.Id, device.DeviceName, alert, cancellationToken);
logger.LogDebug("No enabled alert conditions for device: DeviceId={DeviceId}", deviceId);
return;
}
// Determine if it's daytime or nighttime
bool? isDaytime = null;
if (settings?.Latitude != null && settings.Longitude != null)
{
isDaytime = sunCalculatorService.IsDaytime(DateTime.UtcNow, settings.Latitude.Value, settings.Longitude.Value);
}
// Check each condition
foreach (var condition in conditions)
{
// Check time type filter
if (condition.TimeType == Domain.AlertTimeType.Day && isDaytime == false)
{
continue; // This condition is for daytime only, but it's nighttime
}
if (condition.TimeType == Domain.AlertTimeType.Night && isDaytime == true)
{
continue; // This condition is for nighttime only, but it's daytime
}
// Check if all rules match (AND logic)
var allRulesMatch = condition.Rules.All(rule => CheckRule(rule, telemetry));
if (allRulesMatch && condition.Rules.Any())
{
// All rules passed, send alert if cooldown period has passed
await SendAlertForConditionAsync(condition, device, telemetry, cancellationToken);
}
}
}
private List<AlertInfo> CollectAlerts(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName)
private bool CheckRule(Domain.AlertRule rule, TelemetryDto telemetry)
{
var alerts = new List<AlertInfo>();
// Get sensor value
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
};
CheckTemperatureAlert(telemetry, settings, deviceName, alerts);
CheckHumidityAlert(telemetry, settings, deviceName, alerts);
CheckSoilAlert(telemetry, deviceName, alerts);
CheckGasAlert(telemetry, settings, deviceName, alerts);
CheckLuxAlert(telemetry, settings, deviceName, alerts);
return alerts;
// Check comparison
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 void CheckTemperatureAlert(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName, List<AlertInfo> alerts)
{
if (telemetry.TemperatureC > settings.MaxTemperature)
{
alerts.Add(new AlertInfo(
Type: "Temperature",
Message: $"هشدار: دمای گلخانه {deviceName} به {telemetry.TemperatureC} درجه رسیده که از حداکثر مجاز ({settings.MaxTemperature}) بیشتر است.",
ParameterName: "دما",
Value: telemetry.TemperatureC,
Status: "بالاتر"
));
}
else if (telemetry.TemperatureC < settings.MinTemperature)
{
alerts.Add(new AlertInfo(
Type: "Temperature",
Message: $"هشدار: دمای گلخانه {deviceName} به {telemetry.TemperatureC} درجه رسیده که از حداقل مجاز ({settings.MinTemperature}) کمتر است.",
ParameterName: "دما",
Value: telemetry.TemperatureC,
Status: "پایین‌تر"
));
}
}
private void CheckHumidityAlert(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName, List<AlertInfo> alerts)
{
if (telemetry.HumidityPercent > settings.MaxHumidityPercent)
{
alerts.Add(new AlertInfo(
Type: "Humidity",
Message: $"هشدار: رطوبت گلخانه {deviceName} به {telemetry.HumidityPercent}% رسیده که از حداکثر مجاز ({settings.MaxHumidityPercent}%) بیشتر است.",
ParameterName: "رطوبت",
Value: telemetry.HumidityPercent,
Status: "بالاتر"
));
}
else if (telemetry.HumidityPercent < settings.MinHumidityPercent)
{
alerts.Add(new AlertInfo(
Type: "Humidity",
Message: $"هشدار: رطوبت گلخانه {deviceName} به {telemetry.HumidityPercent}% رسیده که از حداقل مجاز ({settings.MinHumidityPercent}%) کمتر است.",
ParameterName: "رطوبت",
Value: telemetry.HumidityPercent,
Status: "پایین‌تر"
));
}
}
private void CheckSoilAlert(TelemetryDto telemetry, string deviceName, List<AlertInfo> alerts)
{
if (telemetry.SoilPercent > 100)
{
alerts.Add(new AlertInfo(
Type: "Soil",
Message: $"هشدار: رطوبت خاک گلخانه {deviceName} مقدار نامعتبر ({telemetry.SoilPercent}%) دارد.",
ParameterName: "رطوبت خاک",
Value: telemetry.SoilPercent,
Status: "بالاتر"
));
}
else if (telemetry.SoilPercent < 0)
{
alerts.Add(new AlertInfo(
Type: "Soil",
Message: $"هشدار: رطوبت خاک گلخانه {deviceName} مقدار نامعتبر ({telemetry.SoilPercent}%) دارد.",
ParameterName: "رطوبت خاک",
Value: telemetry.SoilPercent,
Status: "پایین‌تر"
));
}
}
private void CheckGasAlert(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName, List<AlertInfo> alerts)
{
if (telemetry.GasPPM > settings.MaxGasPPM)
{
alerts.Add(new AlertInfo(
Type: "Gas",
Message: $"هشدار: گاز گلخانه {deviceName} به {telemetry.GasPPM} PPM رسیده که از حداکثر مجاز ({settings.MaxGasPPM}) بیشتر است.",
ParameterName: "گاز Co",
Value: telemetry.GasPPM,
Status: "بالاتر"
));
}
else if (telemetry.GasPPM < settings.MinGasPPM)
{
alerts.Add(new AlertInfo(
Type: "Gas",
Message: $"هشدار: گاز گلخانه {deviceName} به {telemetry.GasPPM} PPM رسیده که از حداقل مجاز ({settings.MinGasPPM}) کمتر است.",
ParameterName: "گاز Co",
Value: telemetry.GasPPM,
Status: "پایین‌تر"
));
}
}
private void CheckLuxAlert(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName, List<AlertInfo> alerts)
{
if (telemetry.Lux > settings.MaxLux)
{
alerts.Add(new AlertInfo(
Type: "Lux",
Message: $"هشدار: نور گلخانه {deviceName} به {telemetry.Lux} لوکس رسیده که از حداکثر مجاز ({settings.MaxLux}) بیشتر است.",
ParameterName: "نور",
Value: telemetry.Lux,
Status: "بالاتر"
));
}
else if (telemetry.Lux < settings.MinLux)
{
alerts.Add(new AlertInfo(
Type: "Lux",
Message: $"هشدار: نور گلخانه {deviceName} به {telemetry.Lux} لوکس رسیده که از حداقل مجاز ({settings.MinLux}) کمتر است.",
ParameterName: "نور",
Value: telemetry.Lux,
Status: "پایین‌تر"
));
}
}
private async Task SendAlertIfNeededAsync(
int deviceId,
int userId,
string deviceName,
AlertInfo alert,
private async Task SendAlertForConditionAsync(
Domain.AlertCondition condition,
Domain.Device device,
TelemetryDto telemetry,
CancellationToken cancellationToken)
{
// Check if alert was sent in the last 10 minutes
var cooldownTime = DateTime.UtcNow.AddMinutes(-AlertCooldownMinutes);
// Determine cooldown based on notification type
var cooldownMinutes = condition.NotificationType == Domain.AlertNotificationType.Call
? condition.CallCooldownMinutes
: condition.SmsCooldownMinutes;
// Check if alert was sent recently
var cooldownTime = DateTime.UtcNow.AddMinutes(-cooldownMinutes);
var recentAlert = await dbContext.AlertNotifications
.Where(a => a.DeviceId == deviceId &&
a.UserId == userId &&
a.AlertType == alert.Type &&
.Where(a => a.DeviceId == device.Id &&
a.UserId == device.User.Id &&
a.AlertConditionId == condition.Id &&
a.SentAt >= cooldownTime)
.FirstOrDefaultAsync(cancellationToken);
if (recentAlert != null)
{
logger.LogInformation("Alert skipped due to cooldown: DeviceId={DeviceId}, AlertType={AlertType}", deviceId, alert.Type);
logger.LogInformation("Alert skipped due to cooldown: DeviceId={DeviceId}, ConditionId={ConditionId}, Type={Type}",
device.Id, condition.Id, condition.NotificationType);
return;
}
// Get user to send SMS
var user = await dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
// Build alert message
var message = BuildAlertMessage(condition, device.DeviceName, telemetry);
if (user == null || string.IsNullOrWhiteSpace(user.Mobile))
{
logger.LogWarning("User not found or mobile is empty: UserId={UserId}", userId);
return;
}
// Send SMS and collect response/errors
string? messageOutboxIdsJson = null;
// Send notification
string? messageOutboxIds = null;
string? errorMessage = null;
bool isSent = false;
try
{
var smsResponse = await smsService.SendPatternSmsAsync(new PatternSmsRequest
if (condition.NotificationType == Domain.AlertNotificationType.SMS)
{
Recipients = [user.Mobile],
PatternCode = "64di3w9kb0fxvif",
Variables = new Dictionary<string, string> {
{ "name", deviceName },
{ "parameter", alert.ParameterName },
{ "value", alert.Value.ToString("F1") },
{ "status", alert.Status },
}
}, cancellationToken);
if (smsResponse != null)
{
// Check if SMS was sent successfully
if (smsResponse.Meta.Status && smsResponse.Data != null && smsResponse.Data.MessageOutboxIds != null && smsResponse.Data.MessageOutboxIds.Count > 0)
{
// Success - save message outbox IDs
messageOutboxIdsJson = JsonSerializer.Serialize(smsResponse.Data.MessageOutboxIds);
isSent = true;
logger.LogInformation("Alert SMS sent: DeviceId={DeviceId}, UserId={UserId}, AlertType={AlertType}, OutboxIds={OutboxIds}",
deviceId, userId, alert.Type, messageOutboxIdsJson);
}
else
{
// Failed - save error from meta
var errors = new List<string>();
if (!string.IsNullOrWhiteSpace(smsResponse.Meta.Message))
{
errors.Add(smsResponse.Meta.Message);
}
if (smsResponse.Meta.Errors != null && smsResponse.Meta.Errors.Count > 0)
{
foreach (var error in smsResponse.Meta.Errors)
{
errors.Add($"{error.Key}: {string.Join(", ", error.Value)}");
}
}
if (errors.Count == 0)
{
errors.Add("SMS sending failed with unknown error");
}
errorMessage = string.Join(" | ", errors);
isSent = false;
logger.LogWarning("Alert SMS failed: DeviceId={DeviceId}, UserId={UserId}, AlertType={AlertType}, Error={Error}",
deviceId, userId, alert.Type, errorMessage);
}
(isSent, messageOutboxIds, errorMessage) = await SendSmsAlertAsync(device.User.Mobile, device.DeviceName, message, cancellationToken);
}
else
else // Call
{
errorMessage = "SMS service returned null response";
isSent = false;
logger.LogWarning("Alert SMS returned null: DeviceId={DeviceId}, UserId={UserId}, AlertType={AlertType}",
deviceId, userId, alert.Type);
(isSent, messageOutboxIds, errorMessage) = await SendCallAlertAsync(device.User.Mobile, device.DeviceName, message, cancellationToken);
}
}
catch (Exception ex)
@@ -296,17 +170,19 @@ public sealed class AlertService : IAlertService
errorMessage += $" | InnerException: {ex.InnerException.Message}";
}
isSent = false;
logger.LogError(ex, "Failed to send alert SMS: DeviceId={DeviceId}, UserId={UserId}, AlertType={AlertType}", deviceId, userId, alert.Type);
logger.LogError(ex, "Failed to send alert: DeviceId={DeviceId}, ConditionId={ConditionId}, Type={Type}",
device.Id, condition.Id, condition.NotificationType);
}
// Save notification to database
var notification = new Domain.AlertNotification
{
DeviceId = deviceId,
UserId = userId,
AlertType = alert.Type,
Message = alert.Message,
MessageOutboxIds = messageOutboxIdsJson,
DeviceId = device.Id,
UserId = device.User.Id,
AlertConditionId = condition.Id,
NotificationType = condition.NotificationType,
Message = message,
MessageOutboxIds = messageOutboxIds,
ErrorMessage = errorMessage,
SentAt = DateTime.UtcNow,
IsSent = isSent
@@ -315,5 +191,137 @@ public sealed class AlertService : IAlertService
dbContext.AlertNotifications.Add(notification);
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
{
// TODO: Implement voice call integration
// For now, just log and return success
logger.LogInformation("Voice call alert requested: Mobile={Mobile}, Message={Message}", mobile, message);
// Placeholder: In real implementation, call voiceCallService here
// var callResponse = await voiceCallService.MakeCallAsync(mobile, message, cancellationToken);
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);
}
}
}