diff --git a/src/GreenHome.Api/Controllers/PowerOutageController.cs b/src/GreenHome.Api/Controllers/PowerOutageController.cs index b98fa67..5a83b80 100644 --- a/src/GreenHome.Api/Controllers/PowerOutageController.cs +++ b/src/GreenHome.Api/Controllers/PowerOutageController.cs @@ -8,13 +8,16 @@ namespace GreenHome.Api.Controllers; public class PowerOutageController : ControllerBase { private readonly IAlertService _alertService; + private readonly IPowerOutageLogService _powerOutageLogService; private readonly ILogger _logger; public PowerOutageController( IAlertService alertService, + IPowerOutageLogService powerOutageLogService, ILogger logger) { _alertService = alertService; + _powerOutageLogService = powerOutageLogService; _logger = logger; } @@ -40,9 +43,9 @@ public class PowerOutageController : ControllerBase _logger.LogInformation("Power outage alert processed for device {DeviceId}", deviceId); - return Ok(new { - success = true, - message = "هشدار قطع برق با موفقیت ارسال شد" + return Ok(new { + success = true, + message = "هشدار قطع برق با موفقیت ارسال شد" }); } catch (InvalidOperationException ex) @@ -56,5 +59,175 @@ public class PowerOutageController : ControllerBase return StatusCode(500, new { error = "خطا در ارسال هشدار قطع برق" }); } } + + /// + /// دریافت لیست رویدادهای قطع و وصل برق یک دستگاه + /// + /// شناسه دستگاه + /// شروع بازه (UTC) + /// پایان بازه (UTC) + /// نوع رویداد: 1 = وصل, 2 = قطع (اختیاری) + /// شماره صفحه + /// تعداد در هر صفحه + /// Cancellation token + /// لیست رویدادها + [HttpGet("logs")] + public async Task>> GetLogs( + [FromQuery] int deviceId, + [FromQuery] DateTime? startUtc, + [FromQuery] DateTime? endUtc, + [FromQuery] int? eventType, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + try + { + if (deviceId <= 0) + { + return BadRequest(new { error = "شناسه دستگاه نامعتبر است" }); + } + + var filter = new PowerOutageFilter + { + EventType = eventType, + StartDateUtc = startUtc, + EndDateUtc = endUtc, + Page = page, + PageSize = pageSize + }; + + var result = await _powerOutageLogService.ListAsync(deviceId, filter, cancellationToken); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting power outage logs: DeviceId={DeviceId}", deviceId); + return StatusCode(500, new { error = "خطا در دریافت لیست رویدادهای برق" }); + } + } + + /// + /// دریافت خلاصه قطعی‌های برق یک دستگاه در یک بازه زمانی + /// + /// شناسه دستگاه + /// شروع بازه (UTC) + /// پایان بازه (UTC) + /// Cancellation token + /// خلاصه قطعی‌ها شامل تعداد قطعی‌ها و مجموع مدت زمان قطعی + [HttpGet("summary")] + public async Task> GetSummary( + [FromQuery] int deviceId, + [FromQuery] DateTime? startUtc, + [FromQuery] DateTime? endUtc, + CancellationToken cancellationToken = default) + { + try + { + if (deviceId <= 0) + { + return BadRequest(new { error = "شناسه دستگاه نامعتبر است" }); + } + + var filter = new PowerOutageFilter + { + StartDateUtc = startUtc, + EndDateUtc = endUtc, + PageSize = int.MaxValue // Get all records for summary + }; + + var result = await _powerOutageLogService.ListAsync(deviceId, filter, cancellationToken); + + var outages = result.Items.Where(x => x.CurrentPower == 0).ToList(); + var connections = result.Items.Where(x => x.CurrentPower == 1).ToList(); + + // Calculate total downtime by pairing disconnects with subsequent connects + long totalDowntimeSeconds = 0; + var pairedOutages = new List(); + + foreach (var outage in outages) + { + // Find the next connect event after this outage + var nextConnect = connections + .Where(c => c.TimestampUtc > outage.TimestampUtc) + .OrderBy(c => c.TimestampUtc) + .FirstOrDefault(); + + if (nextConnect != null) + { + var duration = (nextConnect.TimestampUtc - outage.TimestampUtc).TotalSeconds; + totalDowntimeSeconds += (long)duration; + + pairedOutages.Add(new PowerOutagePairDto + { + DisconnectedAt = outage.TimestampUtc, + ConnectedAt = nextConnect.TimestampUtc, + DurationSeconds = (long)duration + }); + } + else + { + // Still disconnected (no subsequent connect) + pairedOutages.Add(new PowerOutagePairDto + { + DisconnectedAt = outage.TimestampUtc, + ConnectedAt = null, + DurationSeconds = null + }); + } + } + + var summary = new PowerOutageSummaryDto + { + DeviceId = deviceId, + TotalOutageCount = outages.Count, + TotalDowntimeSeconds = totalDowntimeSeconds, + StartDateUtc = startUtc, + EndDateUtc = endUtc, + Outages = pairedOutages + }; + + return Ok(summary); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting power outage summary: DeviceId={DeviceId}", deviceId); + return StatusCode(500, new { error = "خطا در دریافت خلاصه قطعی‌های برق" }); + } + } + + /// + /// مهاجرت داده‌های قدیمی از جدول Telemetry به PowerOutageLogs + /// این اندپوینت یکبار مصرف است - بعد از اجرا می‌توانید آن را غیرفعال کنید + /// + /// اختیاری - اگر داده شود فقط برای همان دستگاه مهاجرت می‌کند + /// Cancellation token + /// تعداد رکوردهای مهاجرت داده شده + [HttpPost("migrate")] + public async Task MigrateFromTelemetry( + [FromQuery] int? deviceId, + CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Starting migration from Telemetry to PowerOutageLogs..."); + + var count = await _powerOutageLogService.MigrateFromTelemetryAsync(deviceId, cancellationToken); + + _logger.LogInformation("Migration completed: {Count} records inserted", count); + + return Ok(new + { + success = true, + message = $"مهاجرت با موفقیت انجام شد. {count} رکورد به جدول PowerOutageLogs اضافه شد.", + recordsInserted = count + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during migration from Telemetry to PowerOutageLogs"); + return StatusCode(500, new { error = $"خطا در مهاجرت داده‌ها: {ex.Message}" }); + } + } } diff --git a/src/GreenHome.Domain/PowerOutageLog.cs b/src/GreenHome.Domain/PowerOutageLog.cs new file mode 100644 index 0000000..122fda2 --- /dev/null +++ b/src/GreenHome.Domain/PowerOutageLog.cs @@ -0,0 +1,60 @@ +namespace GreenHome.Domain; + +/// +/// لاگ قطع و وصل برق دستگاه +/// +public sealed class PowerOutageLog +{ + public int Id { get; set; } + + /// + /// شناسه دستگاه + /// + public int DeviceId { get; set; } + public Device Device { get; set; } = null!; + + /// + /// وضعیت قبلی برق (0 = قطع, 1 = وصل) + /// + public byte PreviousPower { get; set; } + + /// + /// وضعیت جدید برق (0 = قطع, 1 = وصل) + /// + public byte CurrentPower { get; set; } + + /// + /// نوع رویداد: قطع یا وصل + /// + public PowerEventType EventType { get; set; } + + /// + /// زمان رویداد (از دستگاه) + /// + public DateTime TimestampUtc { get; set; } + + /// + /// زمان ثبت در سرور + /// + public DateTime ServerTimestampUtc { get; set; } + + public int PersianYear { get; set; } + public int PersianMonth { get; set; } + public string PersianDate { get; set; } = string.Empty; // yyyy/MM/dd +} + +/// +/// نوع رویداد برق +/// +public enum PowerEventType +{ + /// + /// برق وصل شده + /// + Connected = 1, + + /// + /// برق قطع شده + /// + Disconnected = 2 +} diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260601132156_AddPowerOutageLogs.Designer.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260601132156_AddPowerOutageLogs.Designer.cs new file mode 100644 index 0000000..42c4a2a --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260601132156_AddPowerOutageLogs.Designer.cs @@ -0,0 +1,1310 @@ +// +using System; +using GreenHome.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GreenHome.Infrastructure.Data.Migrations +{ + [DbContext(typeof(GreenHomeDbContext))] + [Migration("20260601132156_AddPowerOutageLogs")] + partial class AddPowerOutageLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("GreenHome.Domain.AIQuery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompletionTokens") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("Question") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseTimeMs") + .HasColumnType("bigint"); + + b.Property("Temperature") + .HasColumnType("float"); + + b.Property("TotalTokens") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AIQueries", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CallCooldownMinutes") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("SmsCooldownMinutes") + .HasColumnType("int"); + + b.Property("TimeType") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.ToTable("AlertConditions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertConditionId") + .HasColumnType("int"); + + b.Property("AlertType") + .HasColumnType("int"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProcessingTimeMs") + .HasColumnType("bigint"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AlertConditionId"); + + b.HasIndex("AlertType"); + + b.HasIndex("Status"); + + b.HasIndex("DeviceId", "SentAt"); + + b.HasIndex("UserId", "SentAt"); + + b.ToTable("AlertLogs", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertConditionId") + .HasColumnType("int"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsSent") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MessageOutboxIds") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AlertConditionId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "AlertConditionId", "SentAt"); + + b.ToTable("AlertNotifications", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertConditionId") + .HasColumnType("int"); + + b.Property("ComparisonType") + .HasColumnType("int"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SensorType") + .HasColumnType("int"); + + b.Property("Value1") + .HasColumnType("decimal(18,2)"); + + b.Property("Value2") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("AlertConditionId"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.Checklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedByUserId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("DeviceId", "IsActive"); + + b.ToTable("Checklists", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChecklistId") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedByUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.HasKey("Id"); + + b.HasIndex("CompletedByUserId"); + + b.HasIndex("ChecklistId", "PersianDate"); + + b.ToTable("ChecklistCompletions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChecklistId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsRequired") + .HasColumnType("bit"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("ChecklistId", "Order"); + + b.ToTable("ChecklistItems", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItemCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChecklistCompletionId") + .HasColumnType("int"); + + b.Property("ChecklistItemId") + .HasColumnType("int"); + + b.Property("IsChecked") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("ChecklistCompletionId"); + + b.HasIndex("ChecklistItemId"); + + b.ToTable("ChecklistItemCompletions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DailyReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Analysis") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompletionTokens") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianDay") + .HasColumnType("int"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("RecordCount") + .HasColumnType("int"); + + b.Property("ResponseTimeMs") + .HasColumnType("bigint"); + + b.Property("SampledRecordCount") + .HasColumnType("int"); + + b.Property("TotalTokens") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DeviceId", "PersianDate") + .IsUnique(); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("DailyReports", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("NeshanLocation") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Devices", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("DeviceId", "CreatedAt"); + + b.ToTable("DevicePosts", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePostImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DevicePostId") + .HasColumnType("int"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("UploadedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DevicePostId"); + + b.ToTable("DevicePostImages", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaSquareMeters") + .HasColumnType("decimal(18,2)"); + + b.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("DeviceId1") + .HasColumnType("int"); + + b.Property("DevicePhoneNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Latitude") + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasColumnType("decimal(9,6)"); + + b.Property("MinimumCallIntervalMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(60); + + b.Property("MinimumSmsIntervalMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(15); + + b.Property("ProductType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Province") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RelayCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(4); + + b.Property("SimCardType") + .HasColumnType("int"); + + b.Property("SmsRecievers") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("TokenCode") + .HasColumnType("nvarchar(max)"); + + b.Property("TokenExpiresAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UploadIntervalMin") + .HasColumnType("int"); + + b.Property("VerificationCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.HasIndex("DeviceId1"); + + b.ToTable("DeviceSettings", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("ReceiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.HasKey("DeviceId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceUsers", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.PowerOutageLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CurrentPower") + .HasColumnType("tinyint"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("PreviousPower") + .HasColumnType("tinyint"); + + b.Property("ServerTimestampUtc") + .HasColumnType("datetime2"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("PowerOutageLogs", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.ReportImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("UploadedAt") + .HasColumnType("datetime2"); + + b.Property("UserDailyReportId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserDailyReportId"); + + b.ToTable("ReportImages", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BatteryPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("BatteryVoltage") + .HasColumnType("decimal(18,2)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("GasPPM") + .HasColumnType("int"); + + b.Property("HumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Lux") + .HasColumnType("decimal(18,2)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("Power") + .HasColumnType("tinyint"); + + b.Property("ServerTimestampUtc") + .HasColumnType("datetime2"); + + b.Property("SoilPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("TemperatureC") + .HasColumnType("decimal(18,2)"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.Property("Voltage") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId", "ServerTimestampUtc"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("Telemetry", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Mobile") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Observations") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Operations") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianDay") + .HasColumnType("int"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "PersianDate"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("UserDailyReports", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Mobile", "Code", "IsUsed"); + + b.ToTable("VerificationCodes", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AIQuery", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertLog", b => + { + b.HasOne("GreenHome.Domain.AlertCondition", "AlertCondition") + .WithMany() + .HasForeignKey("AlertConditionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AlertCondition"); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.HasOne("GreenHome.Domain.AlertCondition", "AlertCondition") + .WithMany() + .HasForeignKey("AlertConditionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AlertCondition"); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertRule", b => + { + b.HasOne("GreenHome.Domain.AlertCondition", "AlertCondition") + .WithMany("Rules") + .HasForeignKey("AlertConditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AlertCondition"); + }); + + modelBuilder.Entity("GreenHome.Domain.Checklist", b => + { + b.HasOne("GreenHome.Domain.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b => + { + b.HasOne("GreenHome.Domain.Checklist", "Checklist") + .WithMany("Completions") + .HasForeignKey("ChecklistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "CompletedByUser") + .WithMany() + .HasForeignKey("CompletedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Checklist"); + + b.Navigation("CompletedByUser"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItem", b => + { + b.HasOne("GreenHome.Domain.Checklist", "Checklist") + .WithMany("Items") + .HasForeignKey("ChecklistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Checklist"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItemCompletion", b => + { + b.HasOne("GreenHome.Domain.ChecklistCompletion", "ChecklistCompletion") + .WithMany("ItemCompletions") + .HasForeignKey("ChecklistCompletionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.ChecklistItem", "ChecklistItem") + .WithMany() + .HasForeignKey("ChecklistItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ChecklistCompletion"); + + b.Navigation("ChecklistItem"); + }); + + modelBuilder.Entity("GreenHome.Domain.DailyReport", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePost", b => + { + b.HasOne("GreenHome.Domain.User", "AuthorUser") + .WithMany() + .HasForeignKey("AuthorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AuthorUser"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePostImage", b => + { + b.HasOne("GreenHome.Domain.DevicePost", "DevicePost") + .WithMany("Images") + .HasForeignKey("DevicePostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DevicePost"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.Device", null) + .WithMany("DeviceSettings") + .HasForeignKey("DeviceId1"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany("DeviceUsers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany("DeviceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.PowerOutageLog", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.ReportImage", b => + { + b.HasOne("GreenHome.Domain.UserDailyReport", "UserDailyReport") + .WithMany("Images") + .HasForeignKey("UserDailyReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDailyReport"); + }); + + modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => + { + b.Navigation("Rules"); + }); + + modelBuilder.Entity("GreenHome.Domain.Checklist", b => + { + b.Navigation("Completions"); + + b.Navigation("Items"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b => + { + b.Navigation("ItemCompletions"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Navigation("DeviceSettings"); + + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePost", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b => + { + b.Navigation("Images"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260601132156_AddPowerOutageLogs.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260601132156_AddPowerOutageLogs.cs new file mode 100644 index 0000000..731ca48 --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260601132156_AddPowerOutageLogs.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GreenHome.Infrastructure.Data.Migrations +{ + /// + public partial class AddPowerOutageLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PowerOutageLogs", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DeviceId = table.Column(type: "int", nullable: false), + PreviousPower = table.Column(type: "tinyint", nullable: false), + CurrentPower = table.Column(type: "tinyint", nullable: false), + EventType = table.Column(type: "int", nullable: false), + TimestampUtc = table.Column(type: "datetime2", nullable: false), + ServerTimestampUtc = table.Column(type: "datetime2", nullable: false), + PersianYear = table.Column(type: "int", nullable: false), + PersianMonth = table.Column(type: "int", nullable: false), + PersianDate = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PowerOutageLogs", x => x.Id); + table.ForeignKey( + name: "FK_PowerOutageLogs_Devices_DeviceId", + column: x => x.DeviceId, + principalTable: "Devices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PowerOutageLogs_DeviceId_PersianYear_PersianMonth", + table: "PowerOutageLogs", + columns: new[] { "DeviceId", "PersianYear", "PersianMonth" }); + + migrationBuilder.CreateIndex( + name: "IX_PowerOutageLogs_DeviceId_TimestampUtc", + table: "PowerOutageLogs", + columns: new[] { "DeviceId", "TimestampUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_PowerOutageLogs_EventType", + table: "PowerOutageLogs", + column: "EventType"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PowerOutageLogs"); + } + } +} diff --git a/src/GreenHome.Infrastructure/Services/PowerOutage/PowerOutageLogService.cs b/src/GreenHome.Infrastructure/Services/PowerOutage/PowerOutageLogService.cs new file mode 100644 index 0000000..95c46a1 --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/PowerOutage/PowerOutageLogService.cs @@ -0,0 +1,168 @@ +using GreenHome.Application; +using GreenHome.Infrastructure.Data; +using GreenHome.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace GreenHome.Infrastructure.Services.PowerOutage; + +public sealed class PowerOutageLogService : IPowerOutageLogService +{ + private readonly GreenHomeDbContext _dbContext; + + public PowerOutageLogService(GreenHomeDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task LogPowerChangeAsync(int deviceId, byte previousPower, byte currentPower, DateTime timestampUtc, CancellationToken cancellationToken) + { + var (year, month, day, persianDate) = timestampUtc.ToPersianDate(); + + var log = new Domain.PowerOutageLog + { + DeviceId = deviceId, + PreviousPower = previousPower, + CurrentPower = currentPower, + EventType = currentPower == 1 ? Domain.PowerEventType.Connected : Domain.PowerEventType.Disconnected, + TimestampUtc = timestampUtc, + ServerTimestampUtc = DateTime.UtcNow, + PersianYear = year, + PersianMonth = month, + PersianDate = persianDate + }; + + _dbContext.PowerOutageLogs.Add(log); + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task> ListAsync(int deviceId, PowerOutageFilter filter, CancellationToken cancellationToken) + { + var query = _dbContext.PowerOutageLogs + .AsNoTracking() + .Where(x => x.DeviceId == deviceId); + + if (filter.EventType.HasValue) + { + var eventType = (Domain.PowerEventType)filter.EventType.Value; + query = query.Where(x => x.EventType == eventType); + } + + if (filter.StartDateUtc.HasValue) + { + var start = filter.StartDateUtc.Value.ToLocalTime(); + query = query.Where(x => x.ServerTimestampUtc >= start); + } + + if (filter.EndDateUtc.HasValue) + { + var end = filter.EndDateUtc.Value.ToLocalTime(); + query = query.Where(x => x.ServerTimestampUtc < end); + } + + if (filter.PersianYear.HasValue) + { + query = query.Where(x => x.PersianYear == filter.PersianYear.Value); + } + + if (filter.PersianMonth.HasValue) + { + query = query.Where(x => x.PersianMonth == filter.PersianMonth.Value); + } + + if (filter.Page <= 0) filter.Page = 1; + if (filter.PageSize <= 0) filter.PageSize = 20; + + var total = await query.CountAsync(cancellationToken); + var skip = (filter.Page - 1) * filter.PageSize; + + var items = await query + .OrderByDescending(x => x.TimestampUtc) + .Skip(skip) + .Take(filter.PageSize) + .Select(x => new PowerOutageLogDto + { + Id = x.Id, + DeviceId = x.DeviceId, + DeviceName = x.Device.DeviceName, + PreviousPower = x.PreviousPower, + CurrentPower = x.CurrentPower, + EventType = x.EventType.ToString(), + EventTypePersian = x.EventType == Domain.PowerEventType.Connected ? "وصل" : "قطع", + TimestampUtc = x.TimestampUtc, + ServerTimestampUtc = x.ServerTimestampUtc, + PersianDate = x.PersianDate + }) + .ToListAsync(cancellationToken); + + return new PagedResult + { + Items = items, + TotalCount = total, + Page = filter.Page, + PageSize = filter.PageSize + }; + } + + public async Task MigrateFromTelemetryAsync(int? deviceId, CancellationToken cancellationToken) + { + var query = _dbContext.TelemetryRecords + .Where(x => x.Power != null); + + if (deviceId.HasValue) + { + query = query.Where(x => x.DeviceId == deviceId.Value); + } + + // Get all relevant telemetry records grouped by device, ordered by timestamp + var telemetryGroups = await query + .OrderBy(x => x.DeviceId) + .ThenBy(x => x.TimestampUtc) + .Select(x => new { x.Id, x.DeviceId, x.Power, x.TimestampUtc }) + .ToListAsync(cancellationToken); + + // Group by device and detect power changes + var groups = telemetryGroups.GroupBy(x => x.DeviceId); + var totalInserted = 0; + + foreach (var group in groups) + { + Domain.PowerOutageLog? previousLog = null; + + foreach (var record in group) + { + if (previousLog != null && previousLog.CurrentPower != record.Power) + { + // Power status changed - insert a log + var (year, month, day, persianDate) = record.TimestampUtc.ToPersianDate(); + + var log = new Domain.PowerOutageLog + { + DeviceId = record.DeviceId, + PreviousPower = previousLog.CurrentPower, + CurrentPower = record.Power.Value, + EventType = record.Power == 1 ? Domain.PowerEventType.Connected : Domain.PowerEventType.Disconnected, + TimestampUtc = record.TimestampUtc, + ServerTimestampUtc = DateTime.UtcNow, + PersianYear = year, + PersianMonth = month, + PersianDate = persianDate + }; + + _dbContext.PowerOutageLogs.Add(log); + totalInserted++; + } + + // Track last known power state for this device + previousLog = new Domain.PowerOutageLog + { + DeviceId = record.DeviceId, + CurrentPower = record.Power.Value, + TimestampUtc = record.TimestampUtc + }; + } + } + + await _dbContext.SaveChangesAsync(cancellationToken); + return totalInserted; + } +}