From a48deb95dab48fc154c3e48cd3c098cca0a001a0 Mon Sep 17 00:00:00 2001 From: alireza Date: Fri, 11 Sep 2026 23:52:11 +0330 Subject: [PATCH] fix conditions logic --- .../Controllers/TelemetryController.cs | 42 +- .../Dtos/Alert/RelayConditionDto.cs | 55 +- .../Dtos/Device/DeviceRelayDto.cs | 47 +- src/GreenHome.Application/MappingProfile.cs | 8 +- src/GreenHome.Domain/DeviceRelay.cs | 61 +- .../Data/GreenHomeDbContext.cs | 32 +- ...3_RelayRuleGroupsAndConditions.Designer.cs | 1498 +++++++++++++++++ ...0911195203_RelayRuleGroupsAndConditions.cs | 152 ++ .../GreenHomeDbContextModelSnapshot.cs | 87 +- .../Services/Alert/AlertConditionService.cs | 47 +- .../Services/Device/DeviceSettingsService.cs | 58 +- .../src/app/device-settings/page.tsx | 2 +- .../components/device-settings/RelayCard.tsx | 212 ++- .../device-settings/RelayGroupCard.tsx | 85 + .../device-settings/RelayRuleCard.tsx | 123 +- .../device-settings/RelayRuleModal.tsx | 374 ++-- .../device-settings/RelaySettingsTab.tsx | 2 +- .../device-settings/relay-constants.ts | 84 +- src/GreenHome.Web/src/lib/api/types.ts | 29 +- 19 files changed, 2545 insertions(+), 453 deletions(-) create mode 100644 src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260911195203_RelayRuleGroupsAndConditions.Designer.cs create mode 100644 src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260911195203_RelayRuleGroupsAndConditions.cs create mode 100644 src/GreenHome.Web/src/components/device-settings/RelayGroupCard.tsx diff --git a/src/GreenHome.Api/Controllers/TelemetryController.cs b/src/GreenHome.Api/Controllers/TelemetryController.cs index e354c90..4672181 100644 --- a/src/GreenHome.Api/Controllers/TelemetryController.cs +++ b/src/GreenHome.Api/Controllers/TelemetryController.cs @@ -77,24 +77,46 @@ public class TelemetryController : ControllerBase /// /// Encode relay conditions into a compact string format for STM32 parsing. - /// Format: E{enabled}T{timeType}R{relay}S{sensor}O{op}V{value1}[,{value2}] - /// Multiple conditions separated by |, multiple rules within a condition separated by & + /// Format (relays separated by |): + /// E{enabled}R{relayIndex}{groups} + /// Group (OR between groups): + /// G{action} where action = 0 (turn on) | 1 (turn off) + /// followed by its conditions joined by & (AND inside a group): + /// Sensor condition: S{sensor}O{op}V{value1}[,{value2}] + /// Time condition: T{start}-{end} (minutes from midnight) + /// Time ranges with start > end cross midnight (e.g. T1200-360 = 20:00..06:00); + /// the device must treat that as (now >= start || now < end). /// private static string EncodeRelayConditions(IReadOnlyList conditions) { return string.Join("|", conditions.Select(c => { - var rules = string.Join("&", c.Rules.Select(r => - { - var rule = $"S{r.Sensor}O{r.Op}V{r.V1}"; - if (r.V2.HasValue) - rule += $",{r.V2.Value}"; - return rule; - })); - return $"E{c.Enabled}T{c.TimeType}R{c.Relay}{rules}"; + var groups = new List(); + groups.AddRange(c.On.Select(g => EncodeGroup(g, 0))); + groups.AddRange(c.Off.Select(g => EncodeGroup(g, 1))); + return $"E{c.Enabled}R{c.RelayIndex}{string.Concat(groups)}"; })); } + private static string EncodeGroup(RelayGroupDto group, int action) + { + return $"G{action}{string.Join("&", group.Conditions.Select(EncodeCondition))}"; + } + + private static string EncodeCondition(RelayRuleDto condition) + { + if (condition.Kind == 1) + { + return $"T{condition.Start ?? 0}-{condition.End ?? 0}"; + } + + var encoded = $"S{condition.Sensor}O{condition.Op}V{condition.V1}"; + if (condition.V2.HasValue) + encoded += $",{condition.V2.Value}"; + + return encoded; + } + /// /// Collect relay states from query params r1, r2, ... (missing relay = off/0). /// diff --git a/src/GreenHome.Application/Dtos/Alert/RelayConditionDto.cs b/src/GreenHome.Application/Dtos/Alert/RelayConditionDto.cs index 27841e3..67749d2 100644 --- a/src/GreenHome.Application/Dtos/Alert/RelayConditionDto.cs +++ b/src/GreenHome.Application/Dtos/Alert/RelayConditionDto.cs @@ -1,43 +1,54 @@ namespace GreenHome.Application; /// -/// DTO for relay alert conditions (notificationType > 0) +/// DTO for relay conditions sent to the device firmware. /// public sealed class RelayConditionDto { public int Enabled { get; set; } - public int TimeType { get; set; } - /// - /// شناسه رله برای سازگاری با فریم‌ور قبلی (مثلاً R1) - /// + /// شناسه رله برای سازگاری با فریم‌ور قبلی (مثلاً R1) public string Relay { get; set; } = string.Empty; - /// - /// نام نمایشی رله (فن، هیتر و ...) - /// + /// شماره رله (۱ تا RelayCount) + public int RelayIndex { get; set; } + + /// نام نمایشی رله (فن، هیتر و ...) public string Name { get; set; } = string.Empty; - public List Rules { get; set; } = new(); + /// گروه‌های روشن شدن (OR بین گروه‌ها) + public List On { get; set; } = new(); + + /// گروه‌های خاموش شدن (OR بین گروه‌ها) + public List Off { get; set; } = new(); } /// -/// DTO for relay condition rules +/// یک گروه شرط (AND داخل گروه) +/// +public sealed class RelayGroupDto +{ + /// برچسب اختیاری گروه برای لاگ/اعلان + public string? Label { get; set; } + + public List Conditions { get; set; } = new(); +} + +/// +/// یک شرط واحد: سنسوری (Kind=0) یا زمانی (Kind=1) /// public sealed class RelayRuleDto { - public string Sensor { get; set; } = string.Empty; - public string Op { get; set; } = string.Empty; - public decimal V1 { get; set; } + /// نوع شرط: 0 = سنسوری، 1 = زمانی + public int Kind { get; set; } + + // سنسوری + public int? Sensor { get; set; } + public int? Op { get; set; } + public decimal? V1 { get; set; } public decimal? V2 { get; set; } - /// - /// اقدام: 0 = روشن، 1 = خاموش - /// - public int Action { get; set; } - - /// - /// زمان اعمال: 0 = روز، 1 = شب، 2 = همیشه - /// - public int TimeType { get; set; } + // زمانی (دقیقه از نیمه‌شب) + public int? Start { get; set; } + public int? End { get; set; } } diff --git a/src/GreenHome.Application/Dtos/Device/DeviceRelayDto.cs b/src/GreenHome.Application/Dtos/Device/DeviceRelayDto.cs index 70f9ac0..594ade9 100644 --- a/src/GreenHome.Application/Dtos/Device/DeviceRelayDto.cs +++ b/src/GreenHome.Application/Dtos/Device/DeviceRelayDto.cs @@ -13,20 +13,51 @@ public sealed class DeviceRelayDto public string Name { get; set; } = string.Empty; public bool IsEnabled { get; set; } = true; public Domain.RelayKind RelayKind { get; set; } = Domain.RelayKind.Generic; - public List Rules { get; set; } = new(); + + /// + /// گروه‌های شرط رله. گروه‌های هم‌عمل (روشن یا خاموش) با OR ترکیب می‌شوند + /// و شرایط داخل هر گروه با AND. + /// + public List RuleGroups { get; set; } = new(); } /// -/// قانون روشن/خاموش شدن رله +/// یک گروه شرط برای روشن یا خاموش شدن رله /// -public sealed class DeviceRelayRuleDto +public sealed class DeviceRelayRuleGroupDto { 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; } + + /// اقدام: روشن (On) یا خاموش (Off) public Domain.RelayAction Action { get; set; } = Domain.RelayAction.On; - public Domain.AlertTimeType TimeType { get; set; } = Domain.AlertTimeType.Always; + + /// برچسب اختیاری گروه برای لاگ/اعلان + public string? Label { get; set; } + + public int Order { get; set; } + + public List Conditions { get; set; } = new(); +} + +/// +/// یک شرط واحد داخل گروه (سنسوری یا زمانی) +/// +public sealed class DeviceRelayRuleConditionDto +{ + public int Id { get; set; } + + /// نوع شرط: سنسوری (0) یا زمانی (1) + public Domain.RelayConditionType ConditionType { get; set; } = Domain.RelayConditionType.Sensor; + + // ---- شرط سنسوری ---- + public Domain.SensorType? SensorType { get; set; } + public Domain.ComparisonType? ComparisonType { get; set; } + public decimal? Value1 { get; set; } + public decimal? Value2 { get; set; } + + // ---- شرط زمانی (دقیقه از نیمه‌شب) ---- + public int? StartMinutes { get; set; } + public int? EndMinutes { get; set; } + public int Order { get; set; } } diff --git a/src/GreenHome.Application/MappingProfile.cs b/src/GreenHome.Application/MappingProfile.cs index c292cac..8c2fe01 100644 --- a/src/GreenHome.Application/MappingProfile.cs +++ b/src/GreenHome.Application/MappingProfile.cs @@ -25,12 +25,16 @@ public sealed class MappingProfile : Profile CreateMap() .ReverseMap() .ForMember(dest => dest.DeviceSettings, opt => opt.Ignore()) - .ForMember(dest => dest.Rules, opt => opt.Ignore()); + .ForMember(dest => dest.RuleGroups, opt => opt.Ignore()); - CreateMap() + CreateMap() .ReverseMap() .ForMember(dest => dest.DeviceRelay, opt => opt.Ignore()); + CreateMap() + .ReverseMap() + .ForMember(dest => dest.RelayRuleGroup, opt => opt.Ignore()); + CreateMap() .ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName)) .ReverseMap() diff --git a/src/GreenHome.Domain/DeviceRelay.cs b/src/GreenHome.Domain/DeviceRelay.cs index c213bcb..9f0c2d3 100644 --- a/src/GreenHome.Domain/DeviceRelay.cs +++ b/src/GreenHome.Domain/DeviceRelay.cs @@ -63,37 +63,74 @@ public sealed class DeviceRelay public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } - /// قوانین روشن/خاموش شدن رله - public ICollection Rules { get; set; } = new List(); + /// گروه‌های شرط روشن/خاموش شدن رله (گروه‌ها با OR ترکیب می‌شوند) + public ICollection RuleGroups { get; set; } = new List(); } /// -/// یک قانون برای روشن یا خاموش کردن رله بر اساس سنسورها +/// یک گروه شرط برای روشن یا خاموش کردن رله. +/// داخل هر گروه شرایط با AND ترکیب می‌شوند؛ گروه‌های هم‌عمل با OR. /// -public sealed class RelayRule +public sealed class RelayRuleGroup { public int Id { get; set; } public int DeviceRelayId { get; set; } public DeviceRelay DeviceRelay { get; set; } = null!; + /// اقدام: روشن یا خاموش کردن رله + public RelayAction Action { get; set; } = RelayAction.On; + + /// برچسب اختیاری گروه (مثلاً «ایمنی»، «خنک‌سازی») برای استفاده در لاگ/اعلان + public string? Label { get; set; } + + /// ترتیب گروه‌ها + public int Order { get; set; } + + /// شرایط داخل این گروه (AND) + public ICollection Conditions { get; set; } = new List(); +} + +/// +/// نوع یک شرط رله: سنسوری یا زمانی +/// +public enum RelayConditionType +{ + Sensor = 0, + Time = 1 +} + +/// +/// یک شرط واحد داخل گروه (سنسوری یا بازه زمانی) +/// +public sealed class RelayRuleCondition +{ + public int Id { get; set; } + public int RelayRuleGroupId { get; set; } + public RelayRuleGroup RelayRuleGroup { get; set; } = null!; + + /// نوع شرط: سنسوری یا زمانی + public RelayConditionType ConditionType { get; set; } = RelayConditionType.Sensor; + + // ---- شرط سنسوری ---- /// نوع سنسور: دما، رطوبت، خاک، گاز، نور - public SensorType SensorType { get; set; } + public SensorType? SensorType { get; set; } /// نوع مقایسه: بزرگتر، کوچکتر، بین، خارج از محدوده - public ComparisonType ComparisonType { get; set; } + public ComparisonType? ComparisonType { get; set; } /// مقدار عددی اول - public decimal Value1 { get; set; } + public decimal? Value1 { get; set; } /// مقدار عددی دوم (برای بین و خارج از محدوده) public decimal? Value2 { get; set; } - /// اقدام: روشن یا خاموش کردن رله - public RelayAction Action { get; set; } = RelayAction.On; + // ---- شرط زمانی ---- + /// شروع بازه به دقیقه از نیمه‌شب (۰ تا ۱۴۳۹) + public int? StartMinutes { get; set; } - /// زمان اعمال: روز، شب، همیشه - public AlertTimeType TimeType { get; set; } = AlertTimeType.Always; + /// پایان بازه به دقیقه از نیمه‌شب (۰ تا ۱۴۳۹) — اگر کوچک‌تر از شروع باشد یعنی از نیمه‌شب عبور می‌کند + public int? EndMinutes { get; set; } - /// ترتیب قوانین + /// ترتیب شرایط داخل گروه public int Order { get; set; } } diff --git a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs index d40e435..f70c3af 100644 --- a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs +++ b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs @@ -12,7 +12,8 @@ public sealed class GreenHomeDbContext : DbContext public DbSet AlertConditions => Set(); public DbSet AlertRules => Set(); public DbSet DeviceRelays => Set(); - public DbSet RelayRules => Set(); + public DbSet RelayRuleGroups => Set(); + public DbSet RelayRuleConditions => Set(); public DbSet Users => Set(); public DbSet VerificationCodes => Set(); public DbSet DeviceUsers => Set(); @@ -101,24 +102,37 @@ public sealed class GreenHomeDbContext : DbContext b.HasIndex(x => new { x.DeviceSettingsId, x.RelayIndex }).IsUnique(); }); - modelBuilder.Entity(b => + modelBuilder.Entity(b => { - b.ToTable("RelayRules"); + b.ToTable("RelayRuleGroups"); b.HasKey(x => x.Id); - b.Property(x => x.SensorType).IsRequired().HasConversion(); - b.Property(x => x.ComparisonType).IsRequired().HasConversion(); b.Property(x => x.Action).IsRequired().HasConversion(); - b.Property(x => x.TimeType).IsRequired().HasConversion(); - b.Property(x => x.Value1).IsRequired().HasColumnType("decimal(18,2)"); - b.Property(x => x.Value2).HasColumnType("decimal(18,2)"); + b.Property(x => x.Label).HasMaxLength(200); b.Property(x => x.Order).IsRequired(); b.HasOne(x => x.DeviceRelay) - .WithMany(r => r.Rules) + .WithMany(r => r.RuleGroups) .HasForeignKey(x => x.DeviceRelayId) .OnDelete(DeleteBehavior.Cascade); b.HasIndex(x => x.DeviceRelayId); }); + modelBuilder.Entity(b => + { + b.ToTable("RelayRuleConditions"); + b.HasKey(x => x.Id); + b.Property(x => x.ConditionType).IsRequired().HasConversion(); + b.Property(x => x.SensorType).HasConversion(); + b.Property(x => x.ComparisonType).HasConversion(); + b.Property(x => x.Value1).HasColumnType("decimal(18,2)"); + b.Property(x => x.Value2).HasColumnType("decimal(18,2)"); + b.Property(x => x.Order).IsRequired(); + b.HasOne(x => x.RelayRuleGroup) + .WithMany(g => g.Conditions) + .HasForeignKey(x => x.RelayRuleGroupId) + .OnDelete(DeleteBehavior.Cascade); + b.HasIndex(x => x.RelayRuleGroupId); + }); + modelBuilder.Entity(b => { b.ToTable("AlertConditions"); diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260911195203_RelayRuleGroupsAndConditions.Designer.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260911195203_RelayRuleGroupsAndConditions.Designer.cs new file mode 100644 index 0000000..b1e8537 --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260911195203_RelayRuleGroupsAndConditions.Designer.cs @@ -0,0 +1,1498 @@ +// +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.Migrations +{ + [DbContext(typeof(GreenHomeDbContext))] + [Migration("20260911195203_RelayRuleGroupsAndConditions")] + partial class RelayRuleGroupsAndConditions + { + /// + 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("PostType") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VoiceDurationSeconds") + .HasColumnType("int"); + + b.Property("VoiceFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + 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.DeviceRelay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceSettingsId") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RelayIndex") + .HasColumnType("int"); + + b.Property("RelayKind") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceSettingsId", "RelayIndex") + .IsUnique(); + + b.ToTable("DeviceRelays", (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("AvatarPath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ReceiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Role") + .HasColumnType("int"); + + 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.RelayRuleCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComparisonType") + .HasColumnType("int"); + + b.Property("ConditionType") + .HasColumnType("int"); + + b.Property("EndMinutes") + .HasColumnType("int"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("RelayRuleGroupId") + .HasColumnType("int"); + + b.Property("SensorType") + .HasColumnType("int"); + + b.Property("StartMinutes") + .HasColumnType("int"); + + b.Property("Value1") + .HasColumnType("decimal(18,2)"); + + b.Property("Value2") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("RelayRuleGroupId"); + + b.ToTable("RelayRuleConditions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("DeviceRelayId") + .HasColumnType("int"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DeviceRelayId"); + + b.ToTable("RelayRuleGroups", (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("RelayStates") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + 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.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") + .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.RelayRuleCondition", b => + { + b.HasOne("GreenHome.Domain.RelayRuleGroup", "RelayRuleGroup") + .WithMany("Conditions") + .HasForeignKey("RelayRuleGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RelayRuleGroup"); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.HasOne("GreenHome.Domain.DeviceRelay", "DeviceRelay") + .WithMany("RuleGroups") + .HasForeignKey("DeviceRelayId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DeviceRelay"); + }); + + 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.DeviceRelay", b => + { + b.Navigation("RuleGroups"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Navigation("Relays"); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.Navigation("Conditions"); + }); + + 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/20260911195203_RelayRuleGroupsAndConditions.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260911195203_RelayRuleGroupsAndConditions.cs new file mode 100644 index 0000000..eeb49a7 --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260911195203_RelayRuleGroupsAndConditions.cs @@ -0,0 +1,152 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GreenHome.Infrastructure.Data.Migrations.Migrations +{ + /// + public partial class RelayRuleGroupsAndConditions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RelayRuleGroups", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DeviceRelayId = table.Column(type: "int", nullable: false), + Action = table.Column(type: "int", nullable: false), + Label = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Order = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RelayRuleGroups", x => x.Id); + table.ForeignKey( + name: "FK_RelayRuleGroups_DeviceRelays_DeviceRelayId", + column: x => x.DeviceRelayId, + principalTable: "DeviceRelays", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RelayRuleConditions", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RelayRuleGroupId = table.Column(type: "int", nullable: false), + ConditionType = table.Column(type: "int", nullable: false), + SensorType = table.Column(type: "int", nullable: true), + ComparisonType = table.Column(type: "int", nullable: true), + Value1 = table.Column(type: "decimal(18,2)", nullable: true), + Value2 = table.Column(type: "decimal(18,2)", nullable: true), + StartMinutes = table.Column(type: "int", nullable: true), + EndMinutes = table.Column(type: "int", nullable: true), + Order = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RelayRuleConditions", x => x.Id); + table.ForeignKey( + name: "FK_RelayRuleConditions_RelayRuleGroups_RelayRuleGroupId", + column: x => x.RelayRuleGroupId, + principalTable: "RelayRuleGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + // Migrate legacy flat RelayRules into RelayRuleGroups (one group per old rule). + // ponytail: legacy RelayRules.TimeType (Day/Night/Always) has no direct equivalent + // in the new model (explicit clock time ranges), so it is intentionally dropped here. + migrationBuilder.Sql( + @"INSERT INTO [RelayRuleGroups] ([DeviceRelayId], [Action], [Order]) +SELECT [DeviceRelayId], [Action], [Order] FROM [RelayRules]; + +WITH old AS ( + SELECT [Id], [DeviceRelayId], [Action], [Order], [SensorType], [ComparisonType], [Value1], [Value2], + ROW_NUMBER() OVER (PARTITION BY [DeviceRelayId], [Action], [Order] ORDER BY [Id]) AS rn + FROM [RelayRules] +), +grp AS ( + SELECT [Id], [DeviceRelayId], [Action], [Order], + ROW_NUMBER() OVER (PARTITION BY [DeviceRelayId], [Action], [Order] ORDER BY [Id]) AS rn + FROM [RelayRuleGroups] +) +INSERT INTO [RelayRuleConditions] ([RelayRuleGroupId], [ConditionType], [SensorType], [ComparisonType], [Value1], [Value2], [Order]) +SELECT g.[Id], 0, o.[SensorType], o.[ComparisonType], o.[Value1], o.[Value2], 0 +FROM old o +JOIN grp g + ON g.[DeviceRelayId] = o.[DeviceRelayId] + AND g.[Action] = o.[Action] + AND g.[Order] = o.[Order] + AND g.rn = o.rn;"); + + migrationBuilder.DropTable( + name: "RelayRules"); + + migrationBuilder.CreateIndex( + name: "IX_RelayRuleConditions_RelayRuleGroupId", + table: "RelayRuleConditions", + column: "RelayRuleGroupId"); + + migrationBuilder.CreateIndex( + name: "IX_RelayRuleGroups_DeviceRelayId", + table: "RelayRuleGroups", + column: "DeviceRelayId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RelayRules", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DeviceRelayId = table.Column(type: "int", nullable: false), + Action = table.Column(type: "int", nullable: false), + ComparisonType = table.Column(type: "int", nullable: false), + Order = table.Column(type: "int", nullable: false), + SensorType = table.Column(type: "int", nullable: false), + TimeType = table.Column(type: "int", nullable: false), + Value1 = table.Column(type: "decimal(18,2)", nullable: false), + Value2 = table.Column(type: "decimal(18,2)", nullable: true) + }, + 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); + }); + + // Copy sensor conditions back into flat RelayRules. Time conditions have no + // legacy equivalent and are dropped on rollback. + migrationBuilder.Sql( + @"INSERT INTO [RelayRules] ([DeviceRelayId], [Action], [ComparisonType], [SensorType], [TimeType], [Value1], [Value2], [Order]) +SELECT g.[DeviceRelayId], g.[Action], ISNULL(c.[ComparisonType], 0), ISNULL(c.[SensorType], 0), 2 /* Always */, ISNULL(c.[Value1], 0), c.[Value2], 0 +FROM [RelayRuleConditions] c +JOIN [RelayRuleGroups] g ON g.[Id] = c.[RelayRuleGroupId] +WHERE c.[ConditionType] = 0;"); + + migrationBuilder.DropTable( + name: "RelayRuleConditions"); + + migrationBuilder.DropTable( + name: "RelayRuleGroups"); + + migrationBuilder.CreateIndex( + name: "IX_RelayRules_DeviceRelayId", + table: "RelayRules", + column: "DeviceRelayId"); + } + } +} diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs index 8ec001d..c31376d 100644 --- a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs @@ -795,7 +795,49 @@ namespace GreenHome.Infrastructure.Data.Migrations b.ToTable("PowerOutageLogs", (string)null); }); - modelBuilder.Entity("GreenHome.Domain.RelayRule", b => + modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComparisonType") + .HasColumnType("int"); + + b.Property("ConditionType") + .HasColumnType("int"); + + b.Property("EndMinutes") + .HasColumnType("int"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("RelayRuleGroupId") + .HasColumnType("int"); + + b.Property("SensorType") + .HasColumnType("int"); + + b.Property("StartMinutes") + .HasColumnType("int"); + + b.Property("Value1") + .HasColumnType("decimal(18,2)"); + + b.Property("Value2") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("RelayRuleGroupId"); + + b.ToTable("RelayRuleConditions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -806,32 +848,21 @@ namespace GreenHome.Infrastructure.Data.Migrations b.Property("Action") .HasColumnType("int"); - b.Property("ComparisonType") - .HasColumnType("int"); - b.Property("DeviceRelayId") .HasColumnType("int"); + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + b.Property("Order") .HasColumnType("int"); - b.Property("SensorType") - .HasColumnType("int"); - - b.Property("TimeType") - .HasColumnType("int"); - - b.Property("Value1") - .HasColumnType("decimal(18,2)"); - - b.Property("Value2") - .HasColumnType("decimal(18,2)"); - b.HasKey("Id"); b.HasIndex("DeviceRelayId"); - b.ToTable("RelayRules", (string)null); + b.ToTable("RelayRuleGroups", (string)null); }); modelBuilder.Entity("GreenHome.Domain.ReportImage", b => @@ -1353,10 +1384,21 @@ namespace GreenHome.Infrastructure.Data.Migrations b.Navigation("Device"); }); - modelBuilder.Entity("GreenHome.Domain.RelayRule", b => + modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b => + { + b.HasOne("GreenHome.Domain.RelayRuleGroup", "RelayRuleGroup") + .WithMany("Conditions") + .HasForeignKey("RelayRuleGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RelayRuleGroup"); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => { b.HasOne("GreenHome.Domain.DeviceRelay", "DeviceRelay") - .WithMany("Rules") + .WithMany("RuleGroups") .HasForeignKey("DeviceRelayId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -1425,7 +1467,7 @@ namespace GreenHome.Infrastructure.Data.Migrations modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b => { - b.Navigation("Rules"); + b.Navigation("RuleGroups"); }); modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => @@ -1433,6 +1475,11 @@ namespace GreenHome.Infrastructure.Data.Migrations b.Navigation("Relays"); }); + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.Navigation("Conditions"); + }); + modelBuilder.Entity("GreenHome.Domain.User", b => { b.Navigation("DeviceUsers"); diff --git a/src/GreenHome.Infrastructure/Services/Alert/AlertConditionService.cs b/src/GreenHome.Infrastructure/Services/Alert/AlertConditionService.cs index 249b2ca..1e38070 100644 --- a/src/GreenHome.Infrastructure/Services/Alert/AlertConditionService.cs +++ b/src/GreenHome.Infrastructure/Services/Alert/AlertConditionService.cs @@ -124,7 +124,8 @@ public sealed class AlertConditionService : IAlertConditionService public async Task> GetRelayConditionsAsync(int deviceId, CancellationToken cancellationToken) { var relays = await _dbContext.DeviceRelays - .Include(x => x.Rules) + .Include(x => x.RuleGroups) + .ThenInclude(g => g.Conditions) .Where(x => x.DeviceSettings.DeviceId == deviceId && x.IsEnabled) .OrderBy(x => x.RelayIndex) .AsNoTracking() @@ -133,25 +134,45 @@ public sealed class AlertConditionService : IAlertConditionService var result = relays.Select(relay => new RelayConditionDto { 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, + RelayIndex = 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, - Action = (int)r.Action, - TimeType = (int)r.TimeType - }).ToList() + On = relay.RuleGroups + .Where(g => g.Action == Domain.RelayAction.On) + .OrderBy(g => g.Order) + .Select(ToRelayGroupDto) + .ToList(), + Off = relay.RuleGroups + .Where(g => g.Action == Domain.RelayAction.Off) + .OrderBy(g => g.Order) + .Select(ToRelayGroupDto) + .ToList() }).ToList(); return result; } + private static RelayGroupDto ToRelayGroupDto(Domain.RelayRuleGroup group) + { + return new RelayGroupDto + { + Label = group.Label, + Conditions = group.Conditions + .OrderBy(c => c.Order) + .Select(c => new RelayRuleDto + { + Kind = c.ConditionType == Domain.RelayConditionType.Time ? 1 : 0, + Sensor = (int?)c.SensorType, + Op = (int?)c.ComparisonType, + V1 = c.Value1, + V2 = c.Value2, + Start = c.StartMinutes, + End = c.EndMinutes + }) + .ToList() + }; + } + public async Task> GetUpdatedRelayConditionsAsync(int deviceId, DateTime? lastUpdateDate, CancellationToken cancellationToken) { if (lastUpdateDate == null) diff --git a/src/GreenHome.Infrastructure/Services/Device/DeviceSettingsService.cs b/src/GreenHome.Infrastructure/Services/Device/DeviceSettingsService.cs index 0bc7469..5981440 100644 --- a/src/GreenHome.Infrastructure/Services/Device/DeviceSettingsService.cs +++ b/src/GreenHome.Infrastructure/Services/Device/DeviceSettingsService.cs @@ -21,7 +21,8 @@ public sealed class DeviceSettingsService : IDeviceSettingsService var settings = await _dbContext.DeviceSettings .Include(x => x.Device) .Include(x => x.Relays) - .ThenInclude(r => r.Rules) + .ThenInclude(r => r.RuleGroups) + .ThenInclude(g => g.Conditions) .AsNoTracking() .FirstOrDefaultAsync(x => x.DeviceId == deviceId, cancellationToken); @@ -50,7 +51,8 @@ public sealed class DeviceSettingsService : IDeviceSettingsService { var entity = await _dbContext.DeviceSettings .Include(x => x.Relays) - .ThenInclude(r => r.Rules) + .ThenInclude(r => r.RuleGroups) + .ThenInclude(g => g.Conditions) .Include(x => x.Device) .FirstOrDefaultAsync(x => x.DeviceId == dto.DeviceId, cancellationToken); @@ -79,9 +81,16 @@ public sealed class DeviceSettingsService : IDeviceSettingsService foreach (var relay in dto.Relays) { - relay.Rules = relay.Rules? - .OrderBy(r => r.Order) - .ToList() ?? new List(); + relay.RuleGroups = relay.RuleGroups? + .OrderBy(g => g.Order) + .ToList() ?? new List(); + + foreach (var group in relay.RuleGroups) + { + group.Conditions = group.Conditions? + .OrderBy(c => c.Order) + .ToList() ?? new List(); + } } } @@ -114,26 +123,39 @@ public sealed class DeviceSettingsService : IDeviceSettingsService relay.RelayKind = dto.RelayKind; relay.UpdatedAt = now; - SyncRules(relay, dto.Rules ?? new List()); + SyncRuleGroups(relay, dto.RuleGroups ?? new List()); } } - private static void SyncRules(Domain.DeviceRelay relay, List ruleDtos) + private static void SyncRuleGroups(Domain.DeviceRelay relay, List groupDtos) { - relay.Rules.Clear(); + relay.RuleGroups.Clear(); - foreach (var dto in ruleDtos.OrderBy(r => r.Order)) + foreach (var groupDto in groupDtos.OrderBy(g => g.Order)) { - relay.Rules.Add(new Domain.RelayRule + var group = new Domain.RelayRuleGroup { - SensorType = dto.SensorType, - ComparisonType = dto.ComparisonType, - Value1 = dto.Value1, - Value2 = dto.Value2, - Action = dto.Action, - TimeType = dto.TimeType, - Order = dto.Order - }); + Action = groupDto.Action, + Label = string.IsNullOrWhiteSpace(groupDto.Label) ? null : groupDto.Label.Trim(), + Order = groupDto.Order + }; + + foreach (var conditionDto in (groupDto.Conditions ?? new List()).OrderBy(c => c.Order)) + { + group.Conditions.Add(new Domain.RelayRuleCondition + { + ConditionType = conditionDto.ConditionType, + SensorType = conditionDto.SensorType, + ComparisonType = conditionDto.ComparisonType, + Value1 = conditionDto.Value1, + Value2 = conditionDto.Value2, + StartMinutes = conditionDto.StartMinutes, + EndMinutes = conditionDto.EndMinutes, + Order = conditionDto.Order + }); + } + + relay.RuleGroups.Add(group); } } } diff --git a/src/GreenHome.Web/src/app/device-settings/page.tsx b/src/GreenHome.Web/src/app/device-settings/page.tsx index f62138e..f42fc0f 100644 --- a/src/GreenHome.Web/src/app/device-settings/page.tsx +++ b/src/GreenHome.Web/src/app/device-settings/page.tsx @@ -17,7 +17,7 @@ function makeRelay(index: number): DeviceRelayDto { name: '', isEnabled: true, relayKind: 0, - rules: [], + ruleGroups: [], } } diff --git a/src/GreenHome.Web/src/components/device-settings/RelayCard.tsx b/src/GreenHome.Web/src/components/device-settings/RelayCard.tsx index 2423c6b..81d4e0e 100644 --- a/src/GreenHome.Web/src/components/device-settings/RelayCard.tsx +++ b/src/GreenHome.Web/src/components/device-settings/RelayCard.tsx @@ -1,11 +1,11 @@ 'use client' import { useState } from 'react' -import { Plus, Plug, ToggleLeft, ToggleRight, Zap } from 'lucide-react' -import { DeviceRelayDto, DeviceRelayRuleDto } from '@/lib/api' +import { Plus, Plug, ToggleLeft, ToggleRight } from 'lucide-react' +import { DeviceRelayDto, DeviceRelayRuleGroupDto, DeviceRelayRuleConditionDto } from '@/lib/api' import { RELAY_KINDS, RELAY_HINTS, RELAY_KIND_COLORS, RelayKindValue } from './relay-constants' import { SettingField } from './SettingField' -import { RelayRuleCard } from './RelayRuleCard' +import { RelayGroupCard } from './RelayGroupCard' import { RelayRuleModal } from './RelayRuleModal' import { cn } from '@/lib/utils' @@ -15,15 +15,18 @@ type RelayCardProps = { embedded?: boolean } -function makeDefaultRule(): DeviceRelayRuleDto { +function makeGroup(action: 0 | 1, order: number): DeviceRelayRuleGroupDto { + return { id: 0, action, label: '', order, conditions: [] } +} + +function makeCondition(): DeviceRelayRuleConditionDto { return { id: 0, + conditionType: 0, sensorType: 0, comparisonType: 0, value1: 30, value2: undefined, - action: 0, - timeType: 2, order: 0, } } @@ -32,68 +35,137 @@ export function RelayCard({ relay, onChange, embedded = false }: RelayCardProps) const KindIcon = RELAY_KINDS.find(k => k.value === relay.relayKind)?.icon ?? Plug const kindColors = RELAY_KIND_COLORS[relay.relayKind] - const [ruleDialog, setRuleDialog] = useState<{ open: boolean; editingIndex: number | null }>({ open: false, editingIndex: null }) + const [conditionDialog, setConditionDialog] = useState<{ + open: boolean + group: DeviceRelayRuleGroupDto | null + editingIndex: number | null + }>({ open: false, group: null, editingIndex: null }) const update = (patch: Partial) => onChange({ ...relay, ...patch }) - const openCreate = () => setRuleDialog({ open: true, editingIndex: null }) + const groupsOf = (action: 0 | 1) => relay.ruleGroups.filter(g => g.action === action) - const openEdit = (index: number) => setRuleDialog({ open: true, editingIndex: index }) + const updateGroup = (group: DeviceRelayRuleGroupDto, updated: DeviceRelayRuleGroupDto) => { + update({ ruleGroups: relay.ruleGroups.map(g => (g === group ? updated : g)) }) + } - const closeDialog = () => setRuleDialog({ open: false, editingIndex: null }) + const addGroup = (action: 0 | 1) => { + const group = makeGroup(action, relay.ruleGroups.length) + update({ ruleGroups: [...relay.ruleGroups, group] }) + } - const confirmRule = (rule: DeviceRelayRuleDto) => { - const rules = [...relay.rules] - if (ruleDialog.editingIndex !== null) { - rules[ruleDialog.editingIndex] = { ...rule, order: ruleDialog.editingIndex } + const removeGroup = (group: DeviceRelayRuleGroupDto) => { + const next = relay.ruleGroups.filter(g => g !== group).map((g, i) => ({ ...g, order: i })) + update({ ruleGroups: next }) + } + + const openAddCondition = (group: DeviceRelayRuleGroupDto) => + setConditionDialog({ open: true, group, editingIndex: null }) + const openEditCondition = (group: DeviceRelayRuleGroupDto, index: number) => + setConditionDialog({ open: true, group, editingIndex: index }) + const closeDialog = () => setConditionDialog({ open: false, group: null, editingIndex: null }) + + const confirmCondition = (condition: DeviceRelayRuleConditionDto) => { + const group = conditionDialog.group + if (!group) return + const conditions = [...group.conditions] + if (conditionDialog.editingIndex !== null) { + conditions[conditionDialog.editingIndex] = { ...condition, order: conditionDialog.editingIndex } } else { - rules.push({ ...makeDefaultRule(), ...rule, id: 0, order: relay.rules.length }) + conditions.push({ ...makeCondition(), ...condition, id: 0, order: conditions.length }) } - update({ rules }) + updateGroup(group, { ...group, conditions }) closeDialog() } - const removeRule = (index: number) => { - update({ rules: relay.rules.filter((_, i) => i !== index).map((r, i) => ({ ...r, order: i })) }) + const removeCondition = (group: DeviceRelayRuleGroupDto, index: number) => { + const conditions = group.conditions.filter((_, i) => i !== index).map((c, i) => ({ ...c, order: i })) + updateGroup(group, { ...group, conditions }) } - const editingRule = ruleDialog.editingIndex !== null ? relay.rules[ruleDialog.editingIndex] : undefined + const editingCondition = conditionDialog.editingIndex !== null && conditionDialog.group + ? conditionDialog.group.conditions[conditionDialog.editingIndex] + : undefined + + const renderSection = (action: 0 | 1) => { + const isOn = action === 0 + const groups = groupsOf(action) + + return ( +
+
+
+ +

+ {isOn ? 'روشن شدن' : 'خاموش شدن'} +

+
+ +
+ +

+ {isOn ? RELAY_HINTS.onGroups : RELAY_HINTS.offGroups} +

+ +
+ {groups.length === 0 ? ( + + ) : ( + groups.map((group, index) => ( + updateGroup(group, updated)} + onRemove={() => removeGroup(group)} + onAddCondition={() => openAddCondition(group)} + onEditCondition={(conditionIndex) => openEditCondition(group, conditionIndex)} + onRemoveCondition={(conditionIndex) => removeCondition(group, conditionIndex)} + /> + )) + )} +
+
+ ) + } return ( <>
{/* Header */} -
+
- - + + -
-
- - رله {relay.relayIndex} - - {relay.name && ( - — {relay.name} - )} -
-

- {relay.isEnabled ? 'فعال' : 'غیرفعال'} -

+
+ رله {relay.relayIndex} + {relay.name && ( + — {relay.name} + )}
- {/* Enable toggle */}
-
+
{/* Name + Kind */} -
+
- {/* Rules */} -
-
-
-
- قوانین روشن/خاموش شدن - {relay.rules.length > 0 && ( - - {relay.rules.length} - - )} -
-

{RELAY_HINTS.rules}

-
- -
- - {relay.rules.length === 0 ? ( - - ) : ( -
- {relay.rules.map((rule, index) => ( - - ))} -
- )} + {/* ON / OFF law panels */} +
+ {renderSection(0)} + {renderSection(1)}
) diff --git a/src/GreenHome.Web/src/components/device-settings/RelayGroupCard.tsx b/src/GreenHome.Web/src/components/device-settings/RelayGroupCard.tsx new file mode 100644 index 0000000..58bd735 --- /dev/null +++ b/src/GreenHome.Web/src/components/device-settings/RelayGroupCard.tsx @@ -0,0 +1,85 @@ +'use client' + +import { Plus, Trash2 } from 'lucide-react' +import { DeviceRelayRuleGroupDto } from '@/lib/api' +import { RelayRuleCard } from './RelayRuleCard' + +type RelayGroupCardProps = { + group: DeviceRelayRuleGroupDto + index: number + onChange: (group: DeviceRelayRuleGroupDto) => void + onRemove: () => void + onAddCondition: () => void + onEditCondition: (conditionIndex: number) => void + onRemoveCondition: (conditionIndex: number) => void +} + +export function RelayGroupCard({ + group, + index, + onChange, + onRemove, + onAddCondition, + onEditCondition, + onRemoveCondition, +}: RelayGroupCardProps) { + return ( +
+ {/* Group header: number + optional label + remove */} +
+ + گروه {index + 1} + + onChange({ ...group, label: e.target.value })} + placeholder="نام گروه (اختیاری)" + title="یک اسم کوتاه برای این گروه بگذارید" + className="flex-1 min-w-0 bg-transparent px-1 py-1 text-sm text-gray-700 placeholder:text-gray-300 border-b border-transparent focus:border-green-400 focus:outline-none transition-colors" + /> + +
+ + {/* Conditions */} + {group.conditions.length === 0 ? ( + + ) : ( +
+ {group.conditions.map((condition, conditionIndex) => ( + onEditCondition(conditionIndex)} + onRemove={() => onRemoveCondition(conditionIndex)} + /> + ))} +
+ )} + + {group.conditions.length > 0 && ( + + )} +
+ ) +} diff --git a/src/GreenHome.Web/src/components/device-settings/RelayRuleCard.tsx b/src/GreenHome.Web/src/components/device-settings/RelayRuleCard.tsx index 605dc63..0c791c1 100644 --- a/src/GreenHome.Web/src/components/device-settings/RelayRuleCard.tsx +++ b/src/GreenHome.Web/src/components/device-settings/RelayRuleCard.tsx @@ -1,115 +1,58 @@ 'use client' -import { Pencil, Trash2 } from 'lucide-react' -import { DeviceRelayRuleDto } from '@/lib/api' -import { SENSOR_TYPES, COMPARISON_TYPES, TIME_TYPES } from '@/components/alert-settings/constants' -import { RELAY_ACTIONS, SENSOR_STYLES, TIME_STYLES } from './relay-constants' -import { cn } from '@/lib/utils' +import { Pencil, Trash2, Clock, Thermometer, MoonStar } from 'lucide-react' +import { DeviceRelayRuleConditionDto } from '@/lib/api' +import { formatConditionFa, isOvernightRange } from './relay-constants' type RelayRuleCardProps = { - rule: DeviceRelayRuleDto - index: number - onEdit: (index: number) => void - onRemove: (index: number) => void + condition: DeviceRelayRuleConditionDto + onEdit: () => void + onRemove: () => void } -export function RelayRuleCard({ rule, index, onEdit, onRemove }: RelayRuleCardProps) { - const sensor = SENSOR_TYPES.find(s => s.value === rule.sensorType) - const comparison = COMPARISON_TYPES.find(c => c.value === rule.comparisonType) - const time = TIME_TYPES.find(t => t.value === rule.timeType) - const relayAction = RELAY_ACTIONS.find(a => a.value === rule.action) - - const SensorIcon = sensor?.icon - const TimeIcon = time?.icon - const ActionIcon = relayAction?.icon - - const style = SENSOR_STYLES[rule.sensorType] ?? SENSOR_STYLES[0] - const timeStyle = TIME_STYLES[rule.timeType] ?? TIME_STYLES[2] - - const sensorLabel = sensor?.label ?? '' - const unit = sensor?.unit ?? '' - const symbol = rule.comparisonType === 0 ? '>' : rule.comparisonType === 1 ? '<' : '' - - // Compact numeric summary of the condition (like alert condition chips) - const conditionText = comparison?.needsMax - ? `${comparison.label}: ${rule.value1} - ${rule.value2 ?? '؟'} ${unit}` - : `${symbol} ${rule.value1} ${unit}` - - const timeLabel = time - ? rule.timeType < 2 ? `در ${time.label}` : time.label - : '' - - const isTurnOn = rule.action === 0 - const actionPill = isTurnOn - ? 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-200' - : 'bg-rose-100 text-rose-600 ring-1 ring-rose-200' +export function RelayRuleCard({ condition, onEdit, onRemove }: RelayRuleCardProps) { + const isTime = condition.conditionType === 1 + const overnight = isTime && isOvernightRange(condition.startMinutes ?? 0, condition.endMinutes ?? 0) return ( -
- {/* Clickable body - opens the edit dialog */} +
+ + {isTime + ? + : } + + - {/* Actions */} - + diff --git a/src/GreenHome.Web/src/components/device-settings/RelayRuleModal.tsx b/src/GreenHome.Web/src/components/device-settings/RelayRuleModal.tsx index ef7e823..b6c462f 100644 --- a/src/GreenHome.Web/src/components/device-settings/RelayRuleModal.tsx +++ b/src/GreenHome.Web/src/components/device-settings/RelayRuleModal.tsx @@ -1,34 +1,44 @@ 'use client' import { useEffect, useMemo, useState } from 'react' -import { Check, ChevronDown } from 'lucide-react' -import { DeviceRelayRuleDto } from '@/lib/api' +import { Check, ChevronDown, Clock, MoonStar } from 'lucide-react' +import { DeviceRelayRuleConditionDto } from '@/lib/api' import { Button, Modal } from '@/components/common' -import { SENSOR_TYPES, COMPARISON_TYPES, TIME_TYPES } from '@/components/alert-settings/constants' -import { SENSOR_STYLES, TIME_STYLES } from './relay-constants' +import { SENSOR_TYPES, COMPARISON_TYPES } from '@/components/alert-settings/constants' +import { + SENSOR_STYLES, + CONDITION_TYPES, + isOvernightRange, + formatConditionFa, + minutesToTime, + timeToMinutes, +} from './relay-constants' import { SettingField } from './SettingField' import { cn } from '@/lib/utils' type RelayRuleModalProps = { open: boolean - /** the rule being edited; when undefined a fresh rule is created */ - initial?: DeviceRelayRuleDto + /** the condition being edited; when undefined a fresh condition is created */ + initial?: DeviceRelayRuleConditionDto onClose: () => void - onConfirm: (rule: DeviceRelayRuleDto) => void + onConfirm: (condition: DeviceRelayRuleConditionDto) => void } const selectClass = - 'w-full appearance-none pl-9 pr-3.5 py-2.5 rounded-xl border border-gray-200 bg-white text-sm text-gray-700 cursor-pointer transition-all' + 'w-full appearance-none pl-9 pr-3.5 py-2.5 rounded-xl border border-gray-200 bg-white text-sm text-gray-700 cursor-pointer transition-all focus:outline-none focus:border-green-500 focus:ring-2 focus:ring-green-500/20' const numberClass = 'w-full pl-10 pr-3.5 py-2.5 rounded-xl border border-gray-200 bg-white text-sm text-gray-700 text-center transition-all focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none' -const timeButtonBase = - 'flex flex-col items-center justify-center gap-1.5 rounded-xl border px-2 py-3 text-xs font-medium transition-all' -const timeButtonInactive = 'border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-700' +const timeInputClass = + 'w-full px-3 py-2.5 rounded-xl border border-gray-200 bg-white text-sm text-gray-700 text-center font-mono transition-all focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none' + +const typeButtonBase = + 'flex-1 flex items-center justify-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-all' +const typeButtonInactive = 'border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-700' // Focus ring color per sensor type so the picker follows the sensor's theme -const SENSOR_FOCUS: Record = { +const SENSOR_FOCUS: Record, string> = { 0: 'focus:border-red-300 focus:ring-2 focus:ring-red-500/20', 1: 'focus:border-blue-300 focus:ring-2 focus:ring-blue-500/20', 2: 'focus:border-emerald-300 focus:ring-2 focus:ring-emerald-500/20', @@ -36,15 +46,29 @@ const SENSOR_FOCUS: Record = { 4: 'focus:border-violet-300 focus:ring-2 focus:ring-violet-500/20', } -function makeDefaultRule(): DeviceRelayRuleDto { +/** Formats a loose time value (digits only, or digits with ':') into 24-hour HH:mm. */ +function normalizeTime(value: string): string { + const digits = value.replace(/\D/g, '').slice(0, 4) + if (!digits) return '00:00' + + const hh = digits.length >= 3 + ? Math.min(23, parseInt(digits.slice(0, 2), 10) || 0) + : Math.min(23, parseInt(digits, 10) || 0) + const mm = digits.length >= 3 + ? Math.min(59, parseInt(digits.slice(2, 4), 10) || 0) + : 0 + + return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}` +} + +function makeDefaultCondition(): DeviceRelayRuleConditionDto { return { id: 0, + conditionType: 0, sensorType: 0, comparisonType: 0, value1: 30, value2: undefined, - action: 0, - timeType: 2, order: 0, } } @@ -52,66 +76,91 @@ function makeDefaultRule(): DeviceRelayRuleDto { export function RelayRuleModal({ open, initial, onClose, onConfirm }: RelayRuleModalProps) { const isEditing = !!initial - const [sensorType, setSensorType] = useState(0) - const [comparisonType, setComparisonType] = useState(0) + const [conditionType, setConditionType] = useState(0) + const [sensorType, setSensorType] = useState>(0) + const [comparisonType, setComparisonType] = useState>(0) const [value1, setValue1] = useState('30') const [value2, setValue2] = useState('') - const [timeType, setTimeType] = useState(2) + const [startTime, setStartTime] = useState('00:00') + const [endTime, setEndTime] = useState('00:00') // (Re)initialize the draft whenever the dialog is (re)opened useEffect(() => { if (!open) return - const base = initial ?? makeDefaultRule() - setSensorType(base.sensorType) - setComparisonType(base.comparisonType) + const base = initial ?? makeDefaultCondition() + setConditionType(base.conditionType) + setSensorType(base.sensorType ?? 0) + setComparisonType(base.comparisonType ?? 0) setValue1(base.value1 != null ? String(base.value1) : '') setValue2(base.value2 != null ? String(base.value2) : '') - setTimeType(base.timeType) + setStartTime(minutesToTime(base.startMinutes ?? 0)) + setEndTime(minutesToTime(base.endMinutes ?? 0)) }, [open, initial]) - // رله فقط عمل «روشن شود» دارد؛ وقتی «در شب» انتخاب شود، بک‌گراند دیالوگ حالت شب می‌گیرد - const isNight = timeType === 1 - + const isTime = conditionType === 1 const sensor = SENSOR_TYPES.find(s => s.value === sensorType) const SensorIcon = sensor?.icon const sensorUnit = sensor?.unit ?? '' - const sensorLabel = sensor?.label ?? '' const style = SENSOR_STYLES[sensorType] ?? SENSOR_STYLES[0] const needsMax = useMemo( () => !!COMPARISON_TYPES.find(c => c.value === comparisonType)?.needsMax, - [comparisonType] + [comparisonType], ) - const comparison = COMPARISON_TYPES.find(c => c.value === comparisonType) - const time = TIME_TYPES.find(t => t.value === timeType) - const timeText = useMemo(() => { - const label = time?.label ?? '' - return timeType < 2 ? `در ${label}` : label - }, [timeType, time]) + const startMinutes = timeToMinutes(startTime) + const endMinutes = timeToMinutes(endTime) + const overnight = useMemo( + () => isTime && isOvernightRange(startMinutes, endMinutes), + [isTime, startMinutes, endMinutes], + ) - // Human readable summary of the current draft + // Full-sentence preview of the current draft const previewText = useMemo(() => { - const v1 = value1 === '' ? '…' : value1 - if (needsMax) { - const v2 = value2 === '' ? '…' : value2 - return `وقتی ${sensorLabel} ${comparison?.label ?? ''} ${v1} تا ${v2} ${sensorUnit} شد، رله روشن شود` - } - return `وقتی ${sensorLabel} ${comparison?.label ?? ''} ${v1} ${sensorUnit} شد، رله روشن شود` - }, [sensorLabel, sensorUnit, comparison, needsMax, value1, value2]) - - const handleConfirm = () => { - const base = initial ?? makeDefaultRule() const parsed1 = value1 === '' ? 0 : Number(value1) const parsed2 = value2 === '' ? 0 : Number(value2) + const draft: DeviceRelayRuleConditionDto = isTime + ? { id: 0, conditionType: 1, startMinutes, endMinutes, order: 0 } + : { + id: 0, + conditionType: 0, + sensorType, + comparisonType, + value1: Number.isFinite(parsed1) ? parsed1 : undefined, + value2: needsMax && Number.isFinite(parsed2) ? parsed2 : undefined, + order: 0, + } + return isTime ? `فقط ${formatConditionFa(draft)}` : `وقتی ${formatConditionFa(draft)}` + }, [isTime, startMinutes, endMinutes, sensorType, comparisonType, needsMax, value1, value2]) + + const handleConfirm = () => { + const base = initial ?? makeDefaultCondition() + const parsed1 = value1 === '' ? 0 : Number(value1) + const parsed2 = value2 === '' ? 0 : Number(value2) + + if (isTime) { + onConfirm({ + ...base, + conditionType: 1, + sensorType: undefined, + comparisonType: undefined, + value1: undefined, + value2: undefined, + startMinutes: timeToMinutes(startTime), + endMinutes: timeToMinutes(endTime), + }) + return + } + onConfirm({ ...base, + conditionType: 0, sensorType, comparisonType, value1: Number.isFinite(parsed1) ? parsed1 : 0, value2: needsMax && Number.isFinite(parsed2) ? parsed2 : (base.value2 ?? undefined), - action: 0, - timeType, + startMinutes: undefined, + endMinutes: undefined, }) } @@ -119,137 +168,174 @@ export function RelayRuleModal({ open, initial, onClose, onConfirm }: RelayRuleM -
- {/* Preview banner (colored by the sensor type) */} -
- - {SensorIcon && } - -
-

- {previewText} -

-

- {timeText} بررسی می‌شود -

-
-
- - {/* Time type (day/night themed) */} +
+ {/* Condition type selector */}
- - زمان بررسی + + شرط را بر اساس چه چیزی تعریف می‌کنید؟ -
- {TIME_TYPES.map(t => { - const TimeIcon = t.icon - const active = t.value === timeType - const ts = TIME_STYLES[t.value] ?? TIME_STYLES[2] - const label = t.value < 2 ? `در ${t.label}` : t.label +

+ اگر می‌خواهید با عدد سنسور (مثل دما) رله کار کند «بر اساس سنسور»، و اگر با ساعت می‌خواهید «بر اساس ساعت» را بزنید. +

+
+ {CONDITION_TYPES.map(t => { + const TypeIcon = t.icon + const active = t.value === conditionType return ( ) })}
- {/* Condition */} - -
- {/* Sensor */} -
- - -
- - {/* Comparison */} -
- - -
+ {/* Preview banner */} +
+ + {isTime ? : SensorIcon && } + +
+

+ {previewText} +

+ {overnight && ( +

+ + این بازه از نیمه‌شب عبور می‌کند و درست حساب می‌شود +

+ )}
+
- {/* Values */} -
-
- setValue1(e.target.value)} - className={numberClass} - placeholder={needsMax ? 'از' : 'مقدار'} - /> - - {sensorUnit} - + {isTime ? ( + +
+
+ شروع + setStartTime(e.target.value)} + onBlur={e => setStartTime(normalizeTime(e.target.value))} + placeholder="00:00" + className={timeInputClass} + /> +
+ +
+ پایان + setEndTime(e.target.value)} + onBlur={e => setEndTime(normalizeTime(e.target.value))} + placeholder="00:00" + className={timeInputClass} + /> +
- {needsMax ? ( +
+ ) : ( + +
+
+ + +
+ +
+ + +
+
+ +
setValue2(e.target.value)} + value={value1} + onChange={e => setValue1(e.target.value)} className={numberClass} - placeholder="تا" + placeholder={needsMax ? 'از' : 'مقدار'} /> {sensorUnit}
- ) : ( -
- (فقط یک مقدار) -
- )} -
-
+ {needsMax ? ( +
+ setValue2(e.target.value)} + className={numberClass} + placeholder="تا" + /> + + {sensorUnit} + +
+ ) : ( +
+ (فقط یک مقدار) +
+ )} +
+ + )}
{/* Footer */} diff --git a/src/GreenHome.Web/src/components/device-settings/RelaySettingsTab.tsx b/src/GreenHome.Web/src/components/device-settings/RelaySettingsTab.tsx index 544d94b..4a8f363 100644 --- a/src/GreenHome.Web/src/components/device-settings/RelaySettingsTab.tsx +++ b/src/GreenHome.Web/src/components/device-settings/RelaySettingsTab.tsx @@ -17,7 +17,7 @@ function makeRelay(index: number): DeviceRelayDto { name: '', isEnabled: true, relayKind: 0, - rules: [], + ruleGroups: [], } } diff --git a/src/GreenHome.Web/src/components/device-settings/relay-constants.ts b/src/GreenHome.Web/src/components/device-settings/relay-constants.ts index 9a3d2c2..e2f6e29 100644 --- a/src/GreenHome.Web/src/components/device-settings/relay-constants.ts +++ b/src/GreenHome.Web/src/components/device-settings/relay-constants.ts @@ -8,12 +8,16 @@ import { Droplet, ToggleRight, ToggleLeft, + Clock, + Thermometer, type LucideIcon, } from 'lucide-react' +import type { DeviceRelayRuleConditionDto } from '@/lib/api' export type RelayKindValue = 0 | 1 | 2 | 3 | 4 | 5 | 6 export type RelayActionValue = 0 | 1 export type RelaySensorValue = 0 | 1 | 2 | 3 | 4 +export type RelayConditionValue = 0 | 1 export type RelayTimeValue = 0 | 1 | 2 export const RELAY_KINDS: { value: RelayKindValue; label: string; icon: LucideIcon; hint: string }[] = [ @@ -103,11 +107,83 @@ export const TIME_STYLES: Record = { + 0: 'دما', + 1: 'رطوبت هوا', + 2: 'رطوبت خاک', + 3: 'گاز', + 4: 'نور', +} + +/** واحد فارسی ساده برای سنسورها */ +export const SENSOR_UNITS_FA: Record = { + 0: 'درجه', + 1: 'درصد', + 2: 'درصد', + 3: 'پی‌پی‌ام', + 4: 'لوکس', +} + +/** عبارت فارسی ساده برای عملگرهای مقایسه */ +export const COMPARISON_FA: Record, string> = { + 0: 'بیشتر از', + 1: 'کمتر از', + 2: 'بین', + 3: 'بیرون از', +} + +/** نمایش یک شرط به صورت جمله کامل فارسی */ +export function formatConditionFa(condition: DeviceRelayRuleConditionDto): string { + if (condition.conditionType === 1) { + return `از ساعت ${minutesToTime(condition.startMinutes)} تا ساعت ${minutesToTime(condition.endMinutes)}` + } + + const sensor = SENSOR_LABELS_FA[condition.sensorType ?? 0] + const unit = SENSOR_UNITS_FA[condition.sensorType ?? 0] + const cmp = COMPARISON_FA[condition.comparisonType ?? 0] + + if (condition.comparisonType === 2 || condition.comparisonType === 3) { + return `${sensor} ${cmp} ${condition.value1 ?? '؟'} تا ${condition.value2 ?? '؟'} ${unit}` + } + + return `${sensor} ${cmp} ${condition.value1 ?? '؟'} ${unit}` +} + +/** تبدیل دقیقه از نیمه‌شب به «HH:mm» */ +export function minutesToTime(minutes: number | null | undefined): string { + if (minutes == null || !Number.isFinite(minutes)) return '00:00' + const total = ((minutes % 1440) + 1440) % 1440 + const h = Math.floor(total / 60) + const m = total % 60 + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` +} + +/** تبدیل «HH:mm» به دقیقه از نیمه‌شب */ +export function timeToMinutes(time: string): number { + const [h = 0, m = 0] = time.split(':').map(Number) + const hours = Number.isFinite(h) ? h : 0 + const mins = Number.isFinite(m) ? m : 0 + return (hours * 60 + mins) % 1440 +} + +/** آیا بازه زمانی از نیمه‌شب عبور می‌کند؟ (شروع بعد از پایان) */ +export function isOvernightRange(startMinutes: number, endMinutes: number): boolean { + return startMinutes > endMinutes +} diff --git a/src/GreenHome.Web/src/lib/api/types.ts b/src/GreenHome.Web/src/lib/api/types.ts index 98b4b81..e9b56b9 100644 --- a/src/GreenHome.Web/src/lib/api/types.ts +++ b/src/GreenHome.Web/src/lib/api/types.ts @@ -65,17 +65,32 @@ export type DeviceRelayDto = { name: string isEnabled: boolean relayKind: 0 | 1 | 2 | 3 | 4 | 5 | 6 - rules: DeviceRelayRuleDto[] + ruleGroups: DeviceRelayRuleGroupDto[] } -export type DeviceRelayRuleDto = { +export type DeviceRelayRuleGroupDto = { id: number - sensorType: 0 | 1 | 2 | 3 | 4 - comparisonType: 0 | 1 | 2 | 3 - value1: number - value2?: number + /** 0 = turn on, 1 = turn off */ action: 0 | 1 - timeType: 0 | 1 | 2 + /** optional label shown in logs/notifications (e.g. «ایمنی», «خنک‌سازی») */ + label?: string + order: number + /** conditions inside this group are ANDed */ + conditions: DeviceRelayRuleConditionDto[] +} + +export type DeviceRelayRuleConditionDto = { + id: number + /** 0 = sensor condition, 1 = time range condition */ + conditionType: 0 | 1 + // sensor condition + sensorType?: 0 | 1 | 2 | 3 | 4 + comparisonType?: 0 | 1 | 2 | 3 + value1?: number + value2?: number + // time condition (minutes from midnight, 0..1439) + startMinutes?: number + endMinutes?: number order: number }