feat: add PowerOutageLog entity, service, migration and API endpoints

This commit is contained in:
2026-06-01 18:16:13 +03:30
parent 9fe575ae07
commit 97a946ebea
5 changed files with 1778 additions and 3 deletions

View File

@@ -8,13 +8,16 @@ namespace GreenHome.Api.Controllers;
public class PowerOutageController : ControllerBase
{
private readonly IAlertService _alertService;
private readonly IPowerOutageLogService _powerOutageLogService;
private readonly ILogger<PowerOutageController> _logger;
public PowerOutageController(
IAlertService alertService,
IPowerOutageLogService powerOutageLogService,
ILogger<PowerOutageController> 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 = "خطا در ارسال هشدار قطع برق" });
}
}
/// <summary>
/// دریافت لیست رویدادهای قطع و وصل برق یک دستگاه
/// </summary>
/// <param name="deviceId">شناسه دستگاه</param>
/// <param name="startUtc">شروع بازه (UTC)</param>
/// <param name="endUtc">پایان بازه (UTC)</param>
/// <param name="eventType">نوع رویداد: 1 = وصل, 2 = قطع (اختیاری)</param>
/// <param name="page">شماره صفحه</param>
/// <param name="pageSize">تعداد در هر صفحه</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>لیست رویدادها</returns>
[HttpGet("logs")]
public async Task<ActionResult<PagedResult<PowerOutageLogDto>>> 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 = "خطا در دریافت لیست رویدادهای برق" });
}
}
/// <summary>
/// دریافت خلاصه قطعی‌های برق یک دستگاه در یک بازه زمانی
/// </summary>
/// <param name="deviceId">شناسه دستگاه</param>
/// <param name="startUtc">شروع بازه (UTC)</param>
/// <param name="endUtc">پایان بازه (UTC)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>خلاصه قطعی‌ها شامل تعداد قطعی‌ها و مجموع مدت زمان قطعی</returns>
[HttpGet("summary")]
public async Task<ActionResult<PowerOutageSummaryDto>> 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<PowerOutagePairDto>();
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 = "خطا در دریافت خلاصه قطعی‌های برق" });
}
}
/// <summary>
/// مهاجرت داده‌های قدیمی از جدول Telemetry به PowerOutageLogs
/// این اندپوینت یکبار مصرف است - بعد از اجرا می‌توانید آن را غیرفعال کنید
/// </summary>
/// <param name="deviceId">اختیاری - اگر داده شود فقط برای همان دستگاه مهاجرت می‌کند</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>تعداد رکوردهای مهاجرت داده شده</returns>
[HttpPost("migrate")]
public async Task<ActionResult> 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}" });
}
}
}

View File

@@ -0,0 +1,60 @@
namespace GreenHome.Domain;
/// <summary>
/// لاگ قطع و وصل برق دستگاه
/// </summary>
public sealed class PowerOutageLog
{
public int Id { get; set; }
/// <summary>
/// شناسه دستگاه
/// </summary>
public int DeviceId { get; set; }
public Device Device { get; set; } = null!;
/// <summary>
/// وضعیت قبلی برق (0 = قطع, 1 = وصل)
/// </summary>
public byte PreviousPower { get; set; }
/// <summary>
/// وضعیت جدید برق (0 = قطع, 1 = وصل)
/// </summary>
public byte CurrentPower { get; set; }
/// <summary>
/// نوع رویداد: قطع یا وصل
/// </summary>
public PowerEventType EventType { get; set; }
/// <summary>
/// زمان رویداد (از دستگاه)
/// </summary>
public DateTime TimestampUtc { get; set; }
/// <summary>
/// زمان ثبت در سرور
/// </summary>
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
}
/// <summary>
/// نوع رویداد برق
/// </summary>
public enum PowerEventType
{
/// <summary>
/// برق وصل شده
/// </summary>
Connected = 1,
/// <summary>
/// برق قطع شده
/// </summary>
Disconnected = 2
}

View File

@@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GreenHome.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPowerOutageLogs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PowerOutageLogs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DeviceId = table.Column<int>(type: "int", nullable: false),
PreviousPower = table.Column<byte>(type: "tinyint", nullable: false),
CurrentPower = table.Column<byte>(type: "tinyint", nullable: false),
EventType = table.Column<int>(type: "int", nullable: false),
TimestampUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
ServerTimestampUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
PersianYear = table.Column<int>(type: "int", nullable: false),
PersianMonth = table.Column<int>(type: "int", nullable: false),
PersianDate = table.Column<string>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PowerOutageLogs");
}
}
}

View File

@@ -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<PagedResult<PowerOutageLogDto>> 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<PowerOutageLogDto>
{
Items = items,
TotalCount = total,
Page = filter.Page,
PageSize = filter.PageSize
};
}
public async Task<int> 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;
}
}