578 lines
22 KiB
C#
578 lines
22 KiB
C#
using GreenHome.Application;
|
|
using GreenHome.Domain;
|
|
using GreenHome.Sms.Ippanel;
|
|
using GreenHome.VoiceCall.Avanak;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
using static GreenHome.Sms.Ippanel.IppanelSmsService;
|
|
|
|
namespace GreenHome.Infrastructure;
|
|
|
|
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;
|
|
|
|
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<string?> CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken)
|
|
{
|
|
// Get device with all users who should receive alerts
|
|
var device = await dbContext.Devices
|
|
.Include(d => d.User)
|
|
.Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts))
|
|
.ThenInclude(du => du.User)
|
|
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
|
|
|
|
if (device == null)
|
|
{
|
|
logger.LogWarning("Device not found: DeviceId={DeviceId}", deviceId);
|
|
return null;
|
|
}
|
|
|
|
// Get device settings for location
|
|
var settings = await deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
|
|
|
|
|
|
var recievers = device.User.Mobile;
|
|
recievers += "," + (settings?.SmsRecievers ?? "");
|
|
|
|
recievers = string.Join(",",
|
|
recievers
|
|
.Split(',')
|
|
.Select(x => x.Trim())
|
|
.Where(x => x.Length > 0)
|
|
);
|
|
// Get all users who should receive alerts
|
|
// var usersToAlert = device.DeviceUsers
|
|
// .Where(du => du.ReceiveAlerts)
|
|
// .Select(du => du.User)
|
|
// .ToList();
|
|
|
|
if (recievers.Length == 0)
|
|
{
|
|
logger.LogInformation("No users with ReceiveAlerts enabled for device: DeviceId={DeviceId}", deviceId);
|
|
return null;
|
|
}
|
|
|
|
// 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())
|
|
{
|
|
logger.LogDebug("No enabled alert conditions for device: DeviceId={DeviceId}", deviceId);
|
|
//return null;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
List<string> messages = [];
|
|
// 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, usersToAlert, telemetry, cancellationToken);
|
|
string? message = await GetAlertMessage(condition, device, device.User, telemetry, cancellationToken);
|
|
if(message != null && message.Length > 0)
|
|
messages.Add(message);
|
|
// return message;
|
|
}
|
|
}
|
|
|
|
if(telemetry.OldPower == 0 && telemetry.Power == 1)
|
|
{
|
|
messages.Add("برق دستگاه متصل شد");
|
|
}
|
|
else if(telemetry.OldPower == 1 && telemetry.Power == 0)
|
|
{
|
|
messages.Add("برق دستگاه قطع شد");
|
|
}
|
|
|
|
if(messages.Any())
|
|
{
|
|
return $"tt{recievers}#{string.Join("@", messages)}";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private bool CheckRule(Domain.AlertRule rule, TelemetryDto telemetry)
|
|
{
|
|
// 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
|
|
};
|
|
|
|
// 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 async Task<string?> GetAlertMessage(
|
|
Domain.AlertCondition condition,
|
|
Domain.Device device,
|
|
User user,
|
|
TelemetryDto telemetry,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Determine cooldown based on notification type
|
|
var cooldownMinutes = condition.NotificationType == Domain.AlertNotificationType.Call
|
|
? condition.CallCooldownMinutes
|
|
: condition.SmsCooldownMinutes;
|
|
|
|
// Build alert message once
|
|
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,
|
|
//MessageOutboxIds = messageOutboxIds,
|
|
//ErrorMessage = errorMessage,
|
|
SentAt = sentAt,
|
|
IsSent = true
|
|
};
|
|
|
|
dbContext.AlertNotifications.Add(notification);
|
|
|
|
return message;
|
|
}
|
|
|
|
private async Task SendAlertForConditionAsync(
|
|
Domain.AlertCondition condition,
|
|
Domain.Device device,
|
|
List<User> usersToAlert,
|
|
TelemetryDto telemetry,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Determine cooldown based on notification type
|
|
var cooldownMinutes = condition.NotificationType == Domain.AlertNotificationType.Call
|
|
? condition.CallCooldownMinutes
|
|
: condition.SmsCooldownMinutes;
|
|
|
|
// Build alert message once
|
|
var message = BuildAlertMessage(condition, device.DeviceName, telemetry);
|
|
var sentAt = DateTime.UtcNow;
|
|
|
|
// Send alert to each user
|
|
foreach (var user in usersToAlert)
|
|
{
|
|
// Check if alert was sent recently to this user
|
|
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;
|
|
}
|
|
|
|
// Send notification
|
|
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 // Call
|
|
{
|
|
(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;
|
|
|
|
// Save notification to database (old table for backwards compatibility)
|
|
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);
|
|
|
|
// Log the alert
|
|
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
|
|
{
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
public async Task SendPowerOutageAlertAsync(int deviceId, CancellationToken cancellationToken)
|
|
{
|
|
// Get device with all users who should receive alerts
|
|
var device = await dbContext.Devices
|
|
.Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts))
|
|
.ThenInclude(du => du.User)
|
|
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
|
|
|
|
if (device == null)
|
|
{
|
|
logger.LogWarning("Device not found for power outage alert: DeviceId={DeviceId}", deviceId);
|
|
throw new InvalidOperationException($"دستگاه با شناسه {deviceId} یافت نشد");
|
|
}
|
|
|
|
// Get all users who should receive alerts
|
|
var usersToAlert = device.DeviceUsers
|
|
.Where(du => du.ReceiveAlerts)
|
|
.Select(du => du.User)
|
|
.ToList();
|
|
|
|
if (usersToAlert.Count == 0)
|
|
{
|
|
logger.LogInformation("No users with ReceiveAlerts enabled for power outage: DeviceId={DeviceId}", deviceId);
|
|
return;
|
|
}
|
|
|
|
var message = $"⚠️ هشدار قطع برق! دستگاه {device.DeviceName} از برق قطع شده است.";
|
|
var sentAt = DateTime.UtcNow;
|
|
|
|
// Send to all users (both SMS and Call for power outage - it's critical!)
|
|
foreach (var user in usersToAlert)
|
|
{
|
|
// Send SMS
|
|
await SendPowerOutageNotificationAsync(
|
|
device, user, message, sentAt,
|
|
Domain.AlertNotificationType.SMS,
|
|
cancellationToken);
|
|
|
|
// Send Call (important alert)
|
|
await SendPowerOutageNotificationAsync(
|
|
device, user, message, sentAt,
|
|
Domain.AlertNotificationType.Call,
|
|
cancellationToken);
|
|
}
|
|
|
|
await dbContext.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)
|
|
{
|
|
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;
|
|
|
|
// Save notification (old table)
|
|
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);
|
|
|
|
// Log the alert
|
|
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);
|
|
}
|
|
}
|
|
|