feat: add device relay and telemetry support

This commit is contained in:
2026-09-03 07:49:10 +03:30
parent 9eaa5b70d1
commit 6297df7dfd
18 changed files with 2077 additions and 20 deletions

View File

@@ -20,9 +20,9 @@ public class TelemetryController : ControllerBase
}
[HttpGet]
public async Task<ActionResult<PagedResult<TelemetryDto>>> List([FromQuery] int? deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
public async Task<ActionResult<PagedResult<TelemetryDto>>> List([FromQuery] int? deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] DateTime? sinceUtc, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
{
var filter = new TelemetryFilter { DeviceId = deviceId, StartDateUtc = startUtc, EndDateUtc = endUtc, Page = page, PageSize = pageSize };
var filter = new TelemetryFilter { DeviceId = deviceId, StartDateUtc = startUtc, EndDateUtc = endUtc, SinceUtc = sinceUtc, Page = page, PageSize = pageSize };
var result = await telemetryService.ListAsync(filter, cancellationToken);
return Ok(result);
}
@@ -135,4 +135,11 @@ public class TelemetryController : ControllerBase
var result = await telemetryService.GetActiveMonthsAsync(deviceId, year, cancellationToken);
return Ok(result);
}
[HttpGet("aggregate")]
public async Task<ActionResult<IReadOnlyList<TelemetryBucketDto>>> Aggregate([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] int bucketMinutes = 15, CancellationToken cancellationToken = default)
{
var result = await telemetryService.GetAggregatedAsync(deviceId, startUtc, endUtc, bucketMinutes, cancellationToken);
return Ok(result);
}
}

View File

@@ -7,7 +7,17 @@ public sealed class RelayConditionDto
{
public int Enabled { get; set; }
public int TimeType { get; set; }
/// <summary>
/// شناسه رله برای سازگاری با فریم‌ور قبلی (مثلاً R1)
/// </summary>
public string Relay { get; set; } = string.Empty;
/// <summary>
/// نام نمایشی رله (فن، هیتر و ...)
/// </summary>
public string Name { get; set; } = string.Empty;
public List<RelayRuleDto> Rules { get; set; } = new();
}
@@ -20,4 +30,14 @@ public sealed class RelayRuleDto
public string Op { get; set; } = string.Empty;
public decimal V1 { get; set; }
public decimal? V2 { get; set; }
/// <summary>
/// اقدام: 0 = روشن، 1 = خاموش
/// </summary>
public int Action { get; set; }
/// <summary>
/// زمان اعمال: 0 = روز، 1 = شب، 2 = همیشه
/// </summary>
public int TimeType { get; set; }
}

View File

@@ -0,0 +1,35 @@
using Domain = GreenHome.Domain;
namespace GreenHome.Application;
/// <summary>
/// تنظیمات یک رله دستگاه
/// </summary>
public sealed class DeviceRelayDto
{
public int Id { get; set; }
public int DeviceSettingsId { get; set; }
public int RelayIndex { get; set; }
public string Name { get; set; } = string.Empty;
public bool IsEnabled { get; set; } = true;
public Domain.RelayKind RelayKind { get; set; } = Domain.RelayKind.Generic;
public Domain.RelayMode Mode { get; set; } = Domain.RelayMode.Auto;
public int MinOnMinutes { get; set; } = 0;
public int MinOffMinutes { get; set; } = 0;
public List<DeviceRelayRuleDto> Rules { get; set; } = new();
}
/// <summary>
/// قانون روشن/خاموش شدن رله
/// </summary>
public sealed class DeviceRelayRuleDto
{
public int Id { get; set; }
public Domain.SensorType SensorType { get; set; }
public Domain.ComparisonType ComparisonType { get; set; }
public decimal Value1 { get; set; }
public decimal? Value2 { get; set; }
public Domain.RelayAction Action { get; set; } = Domain.RelayAction.On;
public Domain.AlertTimeType TimeType { get; set; } = Domain.AlertTimeType.Always;
public int Order { get; set; }
}

View File

@@ -25,6 +25,8 @@ public sealed class DeviceSettingsDto
public string? VerificationCode { get; set; }
public DateTime? TokenExpiresAt { get; set; }
public List<DeviceRelayDto> Relays { get; set; } = new();
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace GreenHome.Application;
public sealed class TelemetryBucketDto
{
public DateTime BucketStart { get; set; }
public decimal MinTemperatureC { get; set; }
public decimal MaxTemperatureC { get; set; }
public decimal AvgTemperatureC { get; set; }
public decimal MinHumidityPercent { get; set; }
public decimal MaxHumidityPercent { get; set; }
public decimal AvgHumidityPercent { get; set; }
public decimal MinSoilPercent { get; set; }
public decimal MaxSoilPercent { get; set; }
public decimal AvgSoilPercent { get; set; }
public int MinGasPPM { get; set; }
public int MaxGasPPM { get; set; }
public decimal AvgGasPPM { get; set; }
public decimal MinLux { get; set; }
public decimal MaxLux { get; set; }
public decimal AvgLux { get; set; }
public int Count { get; set; }
}

View File

@@ -5,6 +5,7 @@ public sealed class TelemetryFilter
public int? DeviceId { get; set; }
public DateTime? StartDateUtc { get; set; }
public DateTime? EndDateUtc { get; set; }
public DateTime? SinceUtc { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}

View File

@@ -4,12 +4,18 @@ public sealed class TelemetryMinMax
{
public decimal MinTemperatureC { get; set; }
public decimal MaxTemperatureC { get; set; }
public decimal AvgTemperatureC { get; set; }
public decimal MinHumidityPercent { get; set; }
public decimal MaxHumidityPercent { get; set; }
public decimal AvgHumidityPercent { get; set; }
public decimal MinSoilPercent { get; set; }
public decimal MaxSoilPercent { get; set; }
public decimal AvgSoilPercent { get; set; }
public int MinGasPPM { get; set; }
public int MaxGasPPM { get; set; }
public decimal AvgGasPPM { get; set; }
public decimal MinLux { get; set; }
public decimal MaxLux { get; set; }
public decimal AvgLux { get; set; }
public int Count { get; set; }
}

View File

@@ -7,4 +7,5 @@ public interface ITelemetryService
Task<TelemetryMinMax> GetMinMaxAsync(int deviceId, DateTime? startUtc, DateTime? endUtc, CancellationToken cancellationToken);
Task<IReadOnlyList<DayCount>> GetMonthDaysAsync(int deviceId, int persianYear, int persianMonth, CancellationToken cancellationToken);
Task<IReadOnlyList<int>> GetActiveMonthsAsync(int deviceId, int persianYear, CancellationToken cancellationToken);
Task<IReadOnlyList<TelemetryBucketDto>> GetAggregatedAsync(int deviceId, DateTime? startUtc, DateTime? endUtc, int bucketMinutes, CancellationToken cancellationToken);
}

View File

@@ -19,7 +19,17 @@ public sealed class MappingProfile : Profile
CreateMap<Domain.DeviceSettings, DeviceSettingsDto>()
.ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
.ReverseMap()
.ForMember(dest => dest.Device, opt => opt.Ignore());
.ForMember(dest => dest.Device, opt => opt.Ignore())
.ForMember(dest => dest.Relays, opt => opt.Ignore());
CreateMap<Domain.DeviceRelay, DeviceRelayDto>()
.ReverseMap()
.ForMember(dest => dest.DeviceSettings, opt => opt.Ignore())
.ForMember(dest => dest.Rules, opt => opt.Ignore());
CreateMap<Domain.RelayRule, DeviceRelayRuleDto>()
.ReverseMap()
.ForMember(dest => dest.DeviceRelay, opt => opt.Ignore());
CreateMap<Domain.AlertCondition, AlertConditionDto>()
.ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))

View File

@@ -0,0 +1,120 @@
namespace GreenHome.Domain;
/// <summary>
/// نوع مصرف‌کننده متصل به رله
/// </summary>
public enum RelayKind
{
/// <summary>سایر / نامشخص</summary>
Generic = 0,
/// <summary>فن</summary>
Fan = 1,
/// <summary>هیتر / بخاری</summary>
Heater = 2,
/// <summary>پمپ آب</summary>
WaterPump = 3,
/// <summary>مه‌پاش / رطوبت‌ساز</summary>
Humidifier = 4,
/// <summary>روشنایی</summary>
Light = 5,
/// <summary>شیر برقی</summary>
Valve = 6
}
/// <summary>
/// حالت عملکرد رله
/// </summary>
public enum RelayMode
{
/// <summary>خودکار (بر اساس قوانین)</summary>
Auto = 0,
/// <summary>دستی (قوانین خودکار نادیده گرفته می‌شوند)</summary>
Manual = 1
}
/// <summary>
/// نوع اقدام یک قانون رله
/// </summary>
public enum RelayAction
{
/// <summary>روشن کردن</summary>
On = 0,
/// <summary>خاموش کردن</summary>
Off = 1
}
/// <summary>
/// رله دستگاه به همراه تنظیمات آن
/// </summary>
public sealed class DeviceRelay
{
public int Id { get; set; }
public int DeviceSettingsId { get; set; }
public DeviceSettings DeviceSettings { get; set; } = null!;
/// <summary>شماره رله (۱ تا RelayCount)</summary>
public int RelayIndex { get; set; }
/// <summary>نام نمایشی رله (مثلاً فن، هیتر)</summary>
public string Name { get; set; } = string.Empty;
/// <summary>آیا این رله فعال است؟</summary>
public bool IsEnabled { get; set; } = true;
/// <summary>نوع مصرف‌کننده متصل به رله</summary>
public RelayKind RelayKind { get; set; } = RelayKind.Generic;
/// <summary>حالت عملکرد: خودکار یا دستی</summary>
public RelayMode Mode { get; set; } = RelayMode.Auto;
/// <summary>حداقل زمان روشن ماندن (دقیقه) - برای جلوگیری از قطع و وصل مکرر</summary>
public int MinOnMinutes { get; set; } = 0;
/// <summary>حداقل زمان خاموش ماندن (دقیقه) - برای محافظت از تجهیزات</summary>
public int MinOffMinutes { get; set; } = 0;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
/// <summary>قوانین روشن/خاموش شدن رله</summary>
public ICollection<RelayRule> Rules { get; set; } = new List<RelayRule>();
}
/// <summary>
/// یک قانون برای روشن یا خاموش کردن رله بر اساس سنسورها
/// </summary>
public sealed class RelayRule
{
public int Id { get; set; }
public int DeviceRelayId { get; set; }
public DeviceRelay DeviceRelay { get; set; } = null!;
/// <summary>نوع سنسور: دما، رطوبت، خاک، گاز، نور</summary>
public SensorType SensorType { get; set; }
/// <summary>نوع مقایسه: بزرگتر، کوچکتر، بین، خارج از محدوده</summary>
public ComparisonType ComparisonType { get; set; }
/// <summary>مقدار عددی اول</summary>
public decimal Value1 { get; set; }
/// <summary>مقدار عددی دوم (برای بین و خارج از محدوده)</summary>
public decimal? Value2 { get; set; }
/// <summary>اقدام: روشن یا خاموش کردن رله</summary>
public RelayAction Action { get; set; } = RelayAction.On;
/// <summary>زمان اعمال: روز، شب، همیشه</summary>
public AlertTimeType TimeType { get; set; } = AlertTimeType.Always;
/// <summary>ترتیب قوانین</summary>
public int Order { get; set; }
}

View File

@@ -109,4 +109,9 @@ public sealed class DeviceSettings
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
/// <summary>
/// تنظیمات هر رله دستگاه
/// </summary>
public ICollection<DeviceRelay> Relays { get; set; } = new List<DeviceRelay>();
}

View File

@@ -11,6 +11,8 @@ public sealed class GreenHomeDbContext : DbContext
public DbSet<Domain.DeviceSettings> DeviceSettings => Set<Domain.DeviceSettings>();
public DbSet<Domain.AlertCondition> AlertConditions => Set<Domain.AlertCondition>();
public DbSet<Domain.AlertRule> AlertRules => Set<Domain.AlertRule>();
public DbSet<Domain.DeviceRelay> DeviceRelays => Set<Domain.DeviceRelay>();
public DbSet<Domain.RelayRule> RelayRules => Set<Domain.RelayRule>();
public DbSet<Domain.User> Users => Set<Domain.User>();
public DbSet<Domain.VerificationCode> VerificationCodes => Set<Domain.VerificationCode>();
public DbSet<Domain.DeviceUser> DeviceUsers => Set<Domain.DeviceUser>();
@@ -82,6 +84,39 @@ public sealed class GreenHomeDbContext : DbContext
.HasForeignKey(x => x.DeviceId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => x.DeviceId).IsUnique();
b.HasMany(x => x.Relays)
.WithOne(r => r.DeviceSettings)
.HasForeignKey(r => r.DeviceSettingsId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Domain.DeviceRelay>(b =>
{
b.ToTable("DeviceRelays");
b.HasKey(x => x.Id);
b.Property(x => x.RelayIndex).IsRequired();
b.Property(x => x.Name).HasMaxLength(100);
b.Property(x => x.RelayKind).IsRequired().HasConversion<int>();
b.Property(x => x.Mode).IsRequired().HasConversion<int>();
b.HasIndex(x => new { x.DeviceSettingsId, x.RelayIndex }).IsUnique();
});
modelBuilder.Entity<Domain.RelayRule>(b =>
{
b.ToTable("RelayRules");
b.HasKey(x => x.Id);
b.Property(x => x.SensorType).IsRequired().HasConversion<int>();
b.Property(x => x.ComparisonType).IsRequired().HasConversion<int>();
b.Property(x => x.Action).IsRequired().HasConversion<int>();
b.Property(x => x.TimeType).IsRequired().HasConversion<int>();
b.Property(x => x.Value1).IsRequired().HasColumnType("decimal(18,2)");
b.Property(x => x.Value2).HasColumnType("decimal(18,2)");
b.Property(x => x.Order).IsRequired();
b.HasOne(x => x.DeviceRelay)
.WithMany(r => r.Rules)
.HasForeignKey(x => x.DeviceRelayId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => x.DeviceRelayId);
});
modelBuilder.Entity<Domain.AlertCondition>(b =>

View File

@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
{
/// <inheritdoc />
public partial class AddDeviceRelays : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DeviceRelays",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DeviceSettingsId = table.Column<int>(type: "int", nullable: false),
RelayIndex = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
IsEnabled = table.Column<bool>(type: "bit", nullable: false),
RelayKind = table.Column<int>(type: "int", nullable: false),
Mode = table.Column<int>(type: "int", nullable: false),
MinOnMinutes = table.Column<int>(type: "int", nullable: false),
MinOffMinutes = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeviceRelays", x => x.Id);
table.ForeignKey(
name: "FK_DeviceRelays_DeviceSettings_DeviceSettingsId",
column: x => x.DeviceSettingsId,
principalTable: "DeviceSettings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RelayRules",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DeviceRelayId = table.Column<int>(type: "int", nullable: false),
SensorType = table.Column<int>(type: "int", nullable: false),
ComparisonType = table.Column<int>(type: "int", nullable: false),
Value1 = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Value2 = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
Action = table.Column<int>(type: "int", nullable: false),
TimeType = table.Column<int>(type: "int", nullable: false),
Order = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RelayRules", x => x.Id);
table.ForeignKey(
name: "FK_RelayRules_DeviceRelays_DeviceRelayId",
column: x => x.DeviceRelayId,
principalTable: "DeviceRelays",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_DeviceRelays_DeviceSettingsId_RelayIndex",
table: "DeviceRelays",
columns: new[] { "DeviceSettingsId", "RelayIndex" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RelayRules_DeviceRelayId",
table: "RelayRules",
column: "DeviceRelayId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RelayRules");
migrationBuilder.DropTable(
name: "DeviceRelays");
}
}
}

View File

@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using GreenHome.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -568,6 +568,54 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.ToTable("DevicePostImages", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int>("DeviceSettingsId")
.HasColumnType("int");
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<int>("MinOffMinutes")
.HasColumnType("int");
b.Property<int>("MinOnMinutes")
.HasColumnType("int");
b.Property<int>("Mode")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("RelayIndex")
.HasColumnType("int");
b.Property<int>("RelayKind")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("DeviceSettingsId", "RelayIndex")
.IsUnique();
b.ToTable("DeviceRelays", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
{
b.Property<int>("Id")
@@ -729,6 +777,45 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.ToTable("PowerOutageLogs", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.RelayRule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("Action")
.HasColumnType("int");
b.Property<int>("ComparisonType")
.HasColumnType("int");
b.Property<int>("DeviceRelayId")
.HasColumnType("int");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("SensorType")
.HasColumnType("int");
b.Property<int>("TimeType")
.HasColumnType("int");
b.Property<decimal>("Value1")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Value2")
.HasColumnType("decimal(18,2)");
b.HasKey("Id");
b.HasIndex("DeviceRelayId");
b.ToTable("RelayRules", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.ReportImage", b =>
{
b.Property<int>("Id")
@@ -1188,6 +1275,17 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.Navigation("DevicePost");
});
modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b =>
{
b.HasOne("GreenHome.Domain.DeviceSettings", "DeviceSettings")
.WithMany("Relays")
.HasForeignKey("DeviceSettingsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DeviceSettings");
});
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
{
b.HasOne("GreenHome.Domain.Device", "Device")
@@ -1233,6 +1331,17 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.Navigation("Device");
});
modelBuilder.Entity("GreenHome.Domain.RelayRule", b =>
{
b.HasOne("GreenHome.Domain.DeviceRelay", "DeviceRelay")
.WithMany("Rules")
.HasForeignKey("DeviceRelayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DeviceRelay");
});
modelBuilder.Entity("GreenHome.Domain.ReportImage", b =>
{
b.HasOne("GreenHome.Domain.UserDailyReport", "UserDailyReport")
@@ -1292,6 +1401,16 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.Navigation("Images");
});
modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b =>
{
b.Navigation("Rules");
});
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
{
b.Navigation("Relays");
});
modelBuilder.Entity("GreenHome.Domain.User", b =>
{
b.Navigation("DeviceUsers");

View File

@@ -123,24 +123,29 @@ public sealed class AlertConditionService : IAlertConditionService
public async Task<IReadOnlyList<RelayConditionDto>> GetRelayConditionsAsync(int deviceId, CancellationToken cancellationToken)
{
var conditions = await _dbContext.AlertConditions
var relays = await _dbContext.DeviceRelays
.Include(x => x.Rules)
.Where(x => x.DeviceId == deviceId && (int)x.NotificationType > 0)
.OrderByDescending(x => x.CreatedAt)
.Where(x => x.DeviceSettings.DeviceId == deviceId && x.IsEnabled)
.OrderBy(x => x.RelayIndex)
.AsNoTracking()
.ToListAsync(cancellationToken);
var result = conditions.Select(c => new RelayConditionDto
var result = relays.Select(relay => new RelayConditionDto
{
Enabled = c.IsEnabled ? 1 : 0,
TimeType = (int)c.TimeType,
Relay = "R" + (int)c.NotificationType,
Rules = c.Rules.OrderBy(r => r.Order).Select(r => new RelayRuleDto
Enabled = relay.IsEnabled ? 1 : 0,
TimeType = relay.Rules.Any()
? (int)relay.Rules.OrderBy(r => r.Order).First().TimeType
: (int)Domain.AlertTimeType.Always,
Relay = "R" + relay.RelayIndex,
Name = relay.Name,
Rules = relay.Rules.OrderBy(r => r.Order).Select(r => new RelayRuleDto
{
Sensor = ((int)r.SensorType).ToString(),
Op = ((int)r.ComparisonType).ToString(),
V1 = r.Value1,
V2 = r.Value2
V2 = r.Value2,
Action = (int)r.Action,
TimeType = (int)r.TimeType
}).ToList()
}).ToList();
@@ -152,8 +157,8 @@ public sealed class AlertConditionService : IAlertConditionService
if (lastUpdateDate == null)
return await GetRelayConditionsAsync(deviceId, cancellationToken);
var hasUpdates = await _dbContext.AlertConditions
.AnyAsync(x => x.DeviceId == deviceId && (int)x.NotificationType > 0 && x.UpdatedAt > lastUpdateDate, cancellationToken);
var hasUpdates = await _dbContext.DeviceRelays
.AnyAsync(x => x.DeviceSettings.DeviceId == deviceId && x.UpdatedAt > lastUpdateDate, cancellationToken);
if (!hasUpdates)
return Array.Empty<RelayConditionDto>();
@@ -165,7 +170,7 @@ public sealed class AlertConditionService : IAlertConditionService
{
if (lastUpdateDate == null) return true;
return await _dbContext.AlertConditions
.AnyAsync(x => x.DeviceId == deviceId && (int)x.NotificationType > 0 && x.UpdatedAt > lastUpdateDate, cancellationToken);
return await _dbContext.DeviceRelays
.AnyAsync(x => x.DeviceSettings.DeviceId == deviceId && x.UpdatedAt > lastUpdateDate, cancellationToken);
}
}

View File

@@ -20,10 +20,17 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
{
var settings = await _dbContext.DeviceSettings
.Include(x => x.Device)
.Include(x => x.Relays)
.ThenInclude(r => r.Rules)
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeviceId == deviceId, cancellationToken);
return settings != null ? _mapper.Map<DeviceSettingsDto>(settings) : null;
if (settings == null)
return null;
var dto = _mapper.Map<DeviceSettingsDto>(settings);
OrderRelays(dto);
return dto;
}
public async Task<int> CreateAsync(DeviceSettingsDto dto, CancellationToken cancellationToken)
@@ -32,6 +39,8 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
entity.CreatedAt = DateTime.UtcNow;
entity.UpdatedAt = DateTime.UtcNow;
SyncRelays(entity, dto.Relays ?? new List<DeviceRelayDto>());
_dbContext.DeviceSettings.Add(entity);
await _dbContext.SaveChangesAsync(cancellationToken);
return entity.Id;
@@ -40,6 +49,8 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
public async Task UpdateAsync(DeviceSettingsDto dto, CancellationToken cancellationToken)
{
var entity = await _dbContext.DeviceSettings
.Include(x => x.Relays)
.ThenInclude(r => r.Rules)
.FirstOrDefaultAsync(x => x.DeviceId == dto.DeviceId, cancellationToken);
if (entity == null)
@@ -48,6 +59,80 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
_mapper.Map(dto, entity);
entity.UpdatedAt = DateTime.UtcNow;
SyncRelays(entity, dto.Relays ?? new List<DeviceRelayDto>());
await _dbContext.SaveChangesAsync(cancellationToken);
}
private static void OrderRelays(DeviceSettingsDto dto)
{
if (dto.Relays == null)
return;
dto.Relays = dto.Relays
.OrderBy(r => r.RelayIndex)
.ToList();
foreach (var relay in dto.Relays)
{
relay.Rules = relay.Rules?
.OrderBy(r => r.Order)
.ToList() ?? new List<DeviceRelayRuleDto>();
}
}
private static void SyncRelays(Domain.DeviceSettings entity, List<DeviceRelayDto> relayDtos)
{
var now = DateTime.UtcNow;
var dtoIndexes = relayDtos.Select(r => r.RelayIndex).ToHashSet();
// حذف رله‌هایی که دیگر در لیست نیستند
foreach (var relay in entity.Relays.Where(r => !dtoIndexes.Contains(r.RelayIndex)).ToList())
{
entity.Relays.Remove(relay);
}
foreach (var dto in relayDtos)
{
var relay = entity.Relays.FirstOrDefault(r => r.RelayIndex == dto.RelayIndex);
if (relay == null)
{
relay = new Domain.DeviceRelay
{
RelayIndex = dto.RelayIndex,
CreatedAt = now
};
entity.Relays.Add(relay);
}
relay.Name = dto.Name ?? string.Empty;
relay.IsEnabled = dto.IsEnabled;
relay.RelayKind = dto.RelayKind;
relay.Mode = dto.Mode;
relay.MinOnMinutes = dto.MinOnMinutes;
relay.MinOffMinutes = dto.MinOffMinutes;
relay.UpdatedAt = now;
SyncRules(relay, dto.Rules ?? new List<DeviceRelayRuleDto>());
}
}
private static void SyncRules(Domain.DeviceRelay relay, List<DeviceRelayRuleDto> ruleDtos)
{
relay.Rules.Clear();
foreach (var dto in ruleDtos.OrderBy(r => r.Order))
{
relay.Rules.Add(new Domain.RelayRule
{
SensorType = dto.SensorType,
ComparisonType = dto.ComparisonType,
Value1 = dto.Value1,
Value2 = dto.Value2,
Action = dto.Action,
TimeType = dto.TimeType,
Order = dto.Order
});
}
}
}

View File

@@ -76,8 +76,12 @@ public sealed class TelemetryService : ITelemetryService
if (filter.EndDateUtc.HasValue)
query = query.Where(x => x.ServerTimestampUtc < filter.EndDateUtc.Value.ToLocalTime());
if (filter.SinceUtc.HasValue)
query = query.Where(x => x.ServerTimestampUtc > filter.SinceUtc.Value.ToLocalTime());
if (filter.Page <= 0) filter.Page = 1;
if (filter.PageSize <= 0) filter.PageSize = 1000000;
if (filter.PageSize <= 0) filter.PageSize = 1000;
if (filter.PageSize > 50000) filter.PageSize = 50000;
var total = await query.CountAsync(cancellationToken);
var skip = (filter.Page - 1) * filter.PageSize;
@@ -128,14 +132,20 @@ public sealed class TelemetryService : ITelemetryService
{
MinTemperatureC = result.Min(x => x.TemperatureC),
MaxTemperatureC = result.Max(x => x.TemperatureC),
AvgTemperatureC = result.Average(x => x.TemperatureC),
MinHumidityPercent = result.Min(x => x.HumidityPercent),
MaxHumidityPercent = result.Max(x => x.HumidityPercent),
AvgHumidityPercent = result.Average(x => x.HumidityPercent),
MinSoilPercent = result.Min(x => x.SoilPercent),
MaxSoilPercent = result.Max(x => x.SoilPercent),
AvgSoilPercent = result.Average(x => x.SoilPercent),
MinGasPPM = result.Min(x => x.GasPPM),
MaxGasPPM = result.Max(x => x.GasPPM),
AvgGasPPM = (decimal)result.Average(x => x.GasPPM),
MinLux = result.Min(x => x.Lux),
MaxLux = result.Max(x => x.Lux)
MaxLux = result.Max(x => x.Lux),
AvgLux = result.Average(x => x.Lux),
Count = result.Count
};
}
@@ -160,4 +170,59 @@ public sealed class TelemetryService : ITelemetryService
.ToListAsync(cancellationToken);
return months;
}
public async Task<IReadOnlyList<TelemetryBucketDto>> GetAggregatedAsync(int deviceId, DateTime? startUtc, DateTime? endUtc, int bucketMinutes, CancellationToken cancellationToken)
{
if (bucketMinutes <= 0) bucketMinutes = 15;
var query = _dbContext.TelemetryRecords.AsNoTracking()
.Where(x => x.DeviceId == deviceId);
if (startUtc.HasValue)
query = query.Where(x => x.ServerTimestampUtc >= startUtc.Value.ToLocalTime());
if (endUtc.HasValue)
query = query.Where(x => x.ServerTimestampUtc < endUtc.Value.ToLocalTime());
var rows = await query
.OrderBy(x => x.ServerTimestampUtc)
.Select(x => new
{
x.ServerTimestampUtc,
x.TemperatureC,
x.HumidityPercent,
x.SoilPercent,
x.GasPPM,
x.Lux
})
.ToListAsync(cancellationToken);
if (rows.Count == 0)
return Array.Empty<TelemetryBucketDto>();
var bucketTicks = TimeSpan.FromMinutes(bucketMinutes).Ticks;
return rows
.GroupBy(r => new DateTime(r.ServerTimestampUtc.Ticks - (r.ServerTimestampUtc.Ticks % bucketTicks)))
.Select(g => new TelemetryBucketDto
{
BucketStart = g.Key,
MinTemperatureC = g.Min(x => x.TemperatureC),
MaxTemperatureC = g.Max(x => x.TemperatureC),
AvgTemperatureC = g.Average(x => x.TemperatureC),
MinHumidityPercent = g.Min(x => x.HumidityPercent),
MaxHumidityPercent = g.Max(x => x.HumidityPercent),
AvgHumidityPercent = g.Average(x => x.HumidityPercent),
MinSoilPercent = g.Min(x => x.SoilPercent),
MaxSoilPercent = g.Max(x => x.SoilPercent),
AvgSoilPercent = g.Average(x => x.SoilPercent),
MinGasPPM = g.Min(x => x.GasPPM),
MaxGasPPM = g.Max(x => x.GasPPM),
AvgGasPPM = (decimal)g.Average(x => x.GasPPM),
MinLux = g.Min(x => x.Lux),
MaxLux = g.Max(x => x.Lux),
AvgLux = g.Average(x => x.Lux),
Count = g.Count()
})
.ToList();
}
}