fix conditions logic
This commit is contained in:
@@ -77,22 +77,44 @@ public class TelemetryController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Encode relay conditions into a compact string format for STM32 parsing.
|
/// 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}]
|
/// Format (relays separated by |):
|
||||||
/// Multiple conditions separated by |, multiple rules within a condition 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string EncodeRelayConditions(IReadOnlyList<RelayConditionDto> conditions)
|
private static string EncodeRelayConditions(IReadOnlyList<RelayConditionDto> conditions)
|
||||||
{
|
{
|
||||||
return string.Join("|", conditions.Select(c =>
|
return string.Join("|", conditions.Select(c =>
|
||||||
{
|
{
|
||||||
var rules = string.Join("&", c.Rules.Select(r =>
|
var groups = new List<string>();
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
var rule = $"S{r.Sensor}O{r.Op}V{r.V1}";
|
return $"G{action}{string.Join("&", group.Conditions.Select(EncodeCondition))}";
|
||||||
if (r.V2.HasValue)
|
}
|
||||||
rule += $",{r.V2.Value}";
|
|
||||||
return rule;
|
private static string EncodeCondition(RelayRuleDto condition)
|
||||||
}));
|
{
|
||||||
return $"E{c.Enabled}T{c.TimeType}R{c.Relay}{rules}";
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,43 +1,54 @@
|
|||||||
namespace GreenHome.Application;
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DTO for relay alert conditions (notificationType > 0)
|
/// DTO for relay conditions sent to the device firmware.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RelayConditionDto
|
public sealed class RelayConditionDto
|
||||||
{
|
{
|
||||||
public int Enabled { get; set; }
|
public int Enabled { get; set; }
|
||||||
public int TimeType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>شناسه رله برای سازگاری با فریمور قبلی (مثلاً R1)</summary>
|
||||||
/// شناسه رله برای سازگاری با فریمور قبلی (مثلاً R1)
|
|
||||||
/// </summary>
|
|
||||||
public string Relay { get; set; } = string.Empty;
|
public string Relay { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>شماره رله (۱ تا RelayCount)</summary>
|
||||||
/// نام نمایشی رله (فن، هیتر و ...)
|
public int RelayIndex { get; set; }
|
||||||
/// </summary>
|
|
||||||
|
/// <summary>نام نمایشی رله (فن، هیتر و ...)</summary>
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
public List<RelayRuleDto> Rules { get; set; } = new();
|
/// <summary>گروههای روشن شدن (OR بین گروهها)</summary>
|
||||||
|
public List<RelayGroupDto> On { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>گروههای خاموش شدن (OR بین گروهها)</summary>
|
||||||
|
public List<RelayGroupDto> Off { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DTO for relay condition rules
|
/// یک گروه شرط (AND داخل گروه)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RelayGroupDto
|
||||||
|
{
|
||||||
|
/// <summary>برچسب اختیاری گروه برای لاگ/اعلان</summary>
|
||||||
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
public List<RelayRuleDto> Conditions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// یک شرط واحد: سنسوری (Kind=0) یا زمانی (Kind=1)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RelayRuleDto
|
public sealed class RelayRuleDto
|
||||||
{
|
{
|
||||||
public string Sensor { get; set; } = string.Empty;
|
/// <summary>نوع شرط: 0 = سنسوری، 1 = زمانی</summary>
|
||||||
public string Op { get; set; } = string.Empty;
|
public int Kind { get; set; }
|
||||||
public decimal V1 { get; set; }
|
|
||||||
|
// سنسوری
|
||||||
|
public int? Sensor { get; set; }
|
||||||
|
public int? Op { get; set; }
|
||||||
|
public decimal? V1 { get; set; }
|
||||||
public decimal? V2 { get; set; }
|
public decimal? V2 { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
// زمانی (دقیقه از نیمهشب)
|
||||||
/// اقدام: 0 = روشن، 1 = خاموش
|
public int? Start { get; set; }
|
||||||
/// </summary>
|
public int? End { get; set; }
|
||||||
public int Action { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// زمان اعمال: 0 = روز، 1 = شب، 2 = همیشه
|
|
||||||
/// </summary>
|
|
||||||
public int TimeType { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,51 @@ public sealed class DeviceRelayDto
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public bool IsEnabled { get; set; } = true;
|
public bool IsEnabled { get; set; } = true;
|
||||||
public Domain.RelayKind RelayKind { get; set; } = Domain.RelayKind.Generic;
|
public Domain.RelayKind RelayKind { get; set; } = Domain.RelayKind.Generic;
|
||||||
public List<DeviceRelayRuleDto> Rules { get; set; } = new();
|
|
||||||
|
/// <summary>
|
||||||
|
/// گروههای شرط رله. گروههای همعمل (روشن یا خاموش) با OR ترکیب میشوند
|
||||||
|
/// و شرایط داخل هر گروه با AND.
|
||||||
|
/// </summary>
|
||||||
|
public List<DeviceRelayRuleGroupDto> RuleGroups { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// قانون روشن/خاموش شدن رله
|
/// یک گروه شرط برای روشن یا خاموش شدن رله
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DeviceRelayRuleDto
|
public sealed class DeviceRelayRuleGroupDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public Domain.SensorType SensorType { get; set; }
|
|
||||||
public Domain.ComparisonType ComparisonType { get; set; }
|
/// <summary>اقدام: روشن (On) یا خاموش (Off)</summary>
|
||||||
public decimal Value1 { get; set; }
|
|
||||||
public decimal? Value2 { get; set; }
|
|
||||||
public Domain.RelayAction Action { get; set; } = Domain.RelayAction.On;
|
public Domain.RelayAction Action { get; set; } = Domain.RelayAction.On;
|
||||||
public Domain.AlertTimeType TimeType { get; set; } = Domain.AlertTimeType.Always;
|
|
||||||
|
/// <summary>برچسب اختیاری گروه برای لاگ/اعلان</summary>
|
||||||
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
public int Order { get; set; }
|
||||||
|
|
||||||
|
public List<DeviceRelayRuleConditionDto> Conditions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// یک شرط واحد داخل گروه (سنسوری یا زمانی)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeviceRelayRuleConditionDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>نوع شرط: سنسوری (0) یا زمانی (1)</summary>
|
||||||
|
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; }
|
public int Order { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ public sealed class MappingProfile : Profile
|
|||||||
CreateMap<Domain.DeviceRelay, DeviceRelayDto>()
|
CreateMap<Domain.DeviceRelay, DeviceRelayDto>()
|
||||||
.ReverseMap()
|
.ReverseMap()
|
||||||
.ForMember(dest => dest.DeviceSettings, opt => opt.Ignore())
|
.ForMember(dest => dest.DeviceSettings, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.Rules, opt => opt.Ignore());
|
.ForMember(dest => dest.RuleGroups, opt => opt.Ignore());
|
||||||
|
|
||||||
CreateMap<Domain.RelayRule, DeviceRelayRuleDto>()
|
CreateMap<Domain.RelayRuleGroup, DeviceRelayRuleGroupDto>()
|
||||||
.ReverseMap()
|
.ReverseMap()
|
||||||
.ForMember(dest => dest.DeviceRelay, opt => opt.Ignore());
|
.ForMember(dest => dest.DeviceRelay, opt => opt.Ignore());
|
||||||
|
|
||||||
|
CreateMap<Domain.RelayRuleCondition, DeviceRelayRuleConditionDto>()
|
||||||
|
.ReverseMap()
|
||||||
|
.ForMember(dest => dest.RelayRuleGroup, opt => opt.Ignore());
|
||||||
|
|
||||||
CreateMap<Domain.AlertCondition, AlertConditionDto>()
|
CreateMap<Domain.AlertCondition, AlertConditionDto>()
|
||||||
.ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
|
.ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
|
||||||
.ReverseMap()
|
.ReverseMap()
|
||||||
|
|||||||
@@ -63,37 +63,74 @@ public sealed class DeviceRelay
|
|||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>قوانین روشن/خاموش شدن رله</summary>
|
/// <summary>گروههای شرط روشن/خاموش شدن رله (گروهها با OR ترکیب میشوند)</summary>
|
||||||
public ICollection<RelayRule> Rules { get; set; } = new List<RelayRule>();
|
public ICollection<RelayRuleGroup> RuleGroups { get; set; } = new List<RelayRuleGroup>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// یک قانون برای روشن یا خاموش کردن رله بر اساس سنسورها
|
/// یک گروه شرط برای روشن یا خاموش کردن رله.
|
||||||
|
/// داخل هر گروه شرایط با AND ترکیب میشوند؛ گروههای همعمل با OR.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RelayRule
|
public sealed class RelayRuleGroup
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int DeviceRelayId { get; set; }
|
public int DeviceRelayId { get; set; }
|
||||||
public DeviceRelay DeviceRelay { get; set; } = null!;
|
public DeviceRelay DeviceRelay { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>اقدام: روشن یا خاموش کردن رله</summary>
|
||||||
|
public RelayAction Action { get; set; } = RelayAction.On;
|
||||||
|
|
||||||
|
/// <summary>برچسب اختیاری گروه (مثلاً «ایمنی»، «خنکسازی») برای استفاده در لاگ/اعلان</summary>
|
||||||
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
/// <summary>ترتیب گروهها</summary>
|
||||||
|
public int Order { get; set; }
|
||||||
|
|
||||||
|
/// <summary>شرایط داخل این گروه (AND)</summary>
|
||||||
|
public ICollection<RelayRuleCondition> Conditions { get; set; } = new List<RelayRuleCondition>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نوع یک شرط رله: سنسوری یا زمانی
|
||||||
|
/// </summary>
|
||||||
|
public enum RelayConditionType
|
||||||
|
{
|
||||||
|
Sensor = 0,
|
||||||
|
Time = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// یک شرط واحد داخل گروه (سنسوری یا بازه زمانی)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RelayRuleCondition
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int RelayRuleGroupId { get; set; }
|
||||||
|
public RelayRuleGroup RelayRuleGroup { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>نوع شرط: سنسوری یا زمانی</summary>
|
||||||
|
public RelayConditionType ConditionType { get; set; } = RelayConditionType.Sensor;
|
||||||
|
|
||||||
|
// ---- شرط سنسوری ----
|
||||||
/// <summary>نوع سنسور: دما، رطوبت، خاک، گاز، نور</summary>
|
/// <summary>نوع سنسور: دما، رطوبت، خاک، گاز، نور</summary>
|
||||||
public SensorType SensorType { get; set; }
|
public SensorType? SensorType { get; set; }
|
||||||
|
|
||||||
/// <summary>نوع مقایسه: بزرگتر، کوچکتر، بین، خارج از محدوده</summary>
|
/// <summary>نوع مقایسه: بزرگتر، کوچکتر، بین، خارج از محدوده</summary>
|
||||||
public ComparisonType ComparisonType { get; set; }
|
public ComparisonType? ComparisonType { get; set; }
|
||||||
|
|
||||||
/// <summary>مقدار عددی اول</summary>
|
/// <summary>مقدار عددی اول</summary>
|
||||||
public decimal Value1 { get; set; }
|
public decimal? Value1 { get; set; }
|
||||||
|
|
||||||
/// <summary>مقدار عددی دوم (برای بین و خارج از محدوده)</summary>
|
/// <summary>مقدار عددی دوم (برای بین و خارج از محدوده)</summary>
|
||||||
public decimal? Value2 { get; set; }
|
public decimal? Value2 { get; set; }
|
||||||
|
|
||||||
/// <summary>اقدام: روشن یا خاموش کردن رله</summary>
|
// ---- شرط زمانی ----
|
||||||
public RelayAction Action { get; set; } = RelayAction.On;
|
/// <summary>شروع بازه به دقیقه از نیمهشب (۰ تا ۱۴۳۹)</summary>
|
||||||
|
public int? StartMinutes { get; set; }
|
||||||
|
|
||||||
/// <summary>زمان اعمال: روز، شب، همیشه</summary>
|
/// <summary>پایان بازه به دقیقه از نیمهشب (۰ تا ۱۴۳۹) — اگر کوچکتر از شروع باشد یعنی از نیمهشب عبور میکند</summary>
|
||||||
public AlertTimeType TimeType { get; set; } = AlertTimeType.Always;
|
public int? EndMinutes { get; set; }
|
||||||
|
|
||||||
/// <summary>ترتیب قوانین</summary>
|
/// <summary>ترتیب شرایط داخل گروه</summary>
|
||||||
public int Order { get; set; }
|
public int Order { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
public DbSet<Domain.AlertCondition> AlertConditions => Set<Domain.AlertCondition>();
|
public DbSet<Domain.AlertCondition> AlertConditions => Set<Domain.AlertCondition>();
|
||||||
public DbSet<Domain.AlertRule> AlertRules => Set<Domain.AlertRule>();
|
public DbSet<Domain.AlertRule> AlertRules => Set<Domain.AlertRule>();
|
||||||
public DbSet<Domain.DeviceRelay> DeviceRelays => Set<Domain.DeviceRelay>();
|
public DbSet<Domain.DeviceRelay> DeviceRelays => Set<Domain.DeviceRelay>();
|
||||||
public DbSet<Domain.RelayRule> RelayRules => Set<Domain.RelayRule>();
|
public DbSet<Domain.RelayRuleGroup> RelayRuleGroups => Set<Domain.RelayRuleGroup>();
|
||||||
|
public DbSet<Domain.RelayRuleCondition> RelayRuleConditions => Set<Domain.RelayRuleCondition>();
|
||||||
public DbSet<Domain.User> Users => Set<Domain.User>();
|
public DbSet<Domain.User> Users => Set<Domain.User>();
|
||||||
public DbSet<Domain.VerificationCode> VerificationCodes => Set<Domain.VerificationCode>();
|
public DbSet<Domain.VerificationCode> VerificationCodes => Set<Domain.VerificationCode>();
|
||||||
public DbSet<Domain.DeviceUser> DeviceUsers => Set<Domain.DeviceUser>();
|
public DbSet<Domain.DeviceUser> DeviceUsers => Set<Domain.DeviceUser>();
|
||||||
@@ -101,24 +102,37 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
b.HasIndex(x => new { x.DeviceSettingsId, x.RelayIndex }).IsUnique();
|
b.HasIndex(x => new { x.DeviceSettingsId, x.RelayIndex }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Domain.RelayRule>(b =>
|
modelBuilder.Entity<Domain.RelayRuleGroup>(b =>
|
||||||
{
|
{
|
||||||
b.ToTable("RelayRules");
|
b.ToTable("RelayRuleGroups");
|
||||||
b.HasKey(x => x.Id);
|
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.Action).IsRequired().HasConversion<int>();
|
||||||
b.Property(x => x.TimeType).IsRequired().HasConversion<int>();
|
b.Property(x => x.Label).HasMaxLength(200);
|
||||||
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.Property(x => x.Order).IsRequired();
|
||||||
b.HasOne(x => x.DeviceRelay)
|
b.HasOne(x => x.DeviceRelay)
|
||||||
.WithMany(r => r.Rules)
|
.WithMany(r => r.RuleGroups)
|
||||||
.HasForeignKey(x => x.DeviceRelayId)
|
.HasForeignKey(x => x.DeviceRelayId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
b.HasIndex(x => x.DeviceRelayId);
|
b.HasIndex(x => x.DeviceRelayId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Domain.RelayRuleCondition>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("RelayRuleConditions");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
b.Property(x => x.ConditionType).IsRequired().HasConversion<int>();
|
||||||
|
b.Property(x => x.SensorType).HasConversion<int>();
|
||||||
|
b.Property(x => x.ComparisonType).HasConversion<int>();
|
||||||
|
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<Domain.AlertCondition>(b =>
|
modelBuilder.Entity<Domain.AlertCondition>(b =>
|
||||||
{
|
{
|
||||||
b.ToTable("AlertConditions");
|
b.ToTable("AlertConditions");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RelayRuleGroupsAndConditions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RelayRuleGroups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
DeviceRelayId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Action = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Label = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
Order = table.Column<int>(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<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
RelayRuleGroupId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ConditionType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SensorType = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ComparisonType = table.Column<int>(type: "int", nullable: true),
|
||||||
|
Value1 = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
Value2 = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
StartMinutes = table.Column<int>(type: "int", nullable: true),
|
||||||
|
EndMinutes = table.Column<int>(type: "int", nullable: true),
|
||||||
|
Order = table.Column<int>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
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),
|
||||||
|
Action = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ComparisonType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Order = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SensorType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TimeType = 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)
|
||||||
|
},
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -795,7 +795,49 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("PowerOutageLogs", (string)null);
|
b.ToTable("PowerOutageLogs", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.RelayRule", b =>
|
modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("ComparisonType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("ConditionType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("EndMinutes")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("RelayRuleGroupId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("SensorType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("StartMinutes")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Value1")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Value2")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RelayRuleGroupId");
|
||||||
|
|
||||||
|
b.ToTable("RelayRuleConditions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -806,32 +848,21 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Property<int>("Action")
|
b.Property<int>("Action")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("ComparisonType")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<int>("DeviceRelayId")
|
b.Property<int>("DeviceRelayId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
b.Property<int>("Order")
|
b.Property<int>("Order")
|
||||||
.HasColumnType("int");
|
.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.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("DeviceRelayId");
|
b.HasIndex("DeviceRelayId");
|
||||||
|
|
||||||
b.ToTable("RelayRules", (string)null);
|
b.ToTable("RelayRuleGroups", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.ReportImage", b =>
|
modelBuilder.Entity("GreenHome.Domain.ReportImage", b =>
|
||||||
@@ -1353,10 +1384,21 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Device");
|
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")
|
b.HasOne("GreenHome.Domain.DeviceRelay", "DeviceRelay")
|
||||||
.WithMany("Rules")
|
.WithMany("RuleGroups")
|
||||||
.HasForeignKey("DeviceRelayId")
|
.HasForeignKey("DeviceRelayId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
@@ -1425,7 +1467,7 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b =>
|
modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Rules");
|
b.Navigation("RuleGroups");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
|
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
|
||||||
@@ -1433,6 +1475,11 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Relays");
|
b.Navigation("Relays");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Conditions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.User", b =>
|
modelBuilder.Entity("GreenHome.Domain.User", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("DeviceUsers");
|
b.Navigation("DeviceUsers");
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ public sealed class AlertConditionService : IAlertConditionService
|
|||||||
public async Task<IReadOnlyList<RelayConditionDto>> GetRelayConditionsAsync(int deviceId, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<RelayConditionDto>> GetRelayConditionsAsync(int deviceId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var relays = await _dbContext.DeviceRelays
|
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)
|
.Where(x => x.DeviceSettings.DeviceId == deviceId && x.IsEnabled)
|
||||||
.OrderBy(x => x.RelayIndex)
|
.OrderBy(x => x.RelayIndex)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -133,25 +134,45 @@ public sealed class AlertConditionService : IAlertConditionService
|
|||||||
var result = relays.Select(relay => new RelayConditionDto
|
var result = relays.Select(relay => new RelayConditionDto
|
||||||
{
|
{
|
||||||
Enabled = relay.IsEnabled ? 1 : 0,
|
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,
|
Relay = "R" + relay.RelayIndex,
|
||||||
|
RelayIndex = relay.RelayIndex,
|
||||||
Name = relay.Name,
|
Name = relay.Name,
|
||||||
Rules = relay.Rules.OrderBy(r => r.Order).Select(r => new RelayRuleDto
|
On = relay.RuleGroups
|
||||||
{
|
.Where(g => g.Action == Domain.RelayAction.On)
|
||||||
Sensor = ((int)r.SensorType).ToString(),
|
.OrderBy(g => g.Order)
|
||||||
Op = ((int)r.ComparisonType).ToString(),
|
.Select(ToRelayGroupDto)
|
||||||
V1 = r.Value1,
|
.ToList(),
|
||||||
V2 = r.Value2,
|
Off = relay.RuleGroups
|
||||||
Action = (int)r.Action,
|
.Where(g => g.Action == Domain.RelayAction.Off)
|
||||||
TimeType = (int)r.TimeType
|
.OrderBy(g => g.Order)
|
||||||
}).ToList()
|
.Select(ToRelayGroupDto)
|
||||||
|
.ToList()
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return result;
|
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<IReadOnlyList<RelayConditionDto>> GetUpdatedRelayConditionsAsync(int deviceId, DateTime? lastUpdateDate, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<RelayConditionDto>> GetUpdatedRelayConditionsAsync(int deviceId, DateTime? lastUpdateDate, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (lastUpdateDate == null)
|
if (lastUpdateDate == null)
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
|
|||||||
var settings = await _dbContext.DeviceSettings
|
var settings = await _dbContext.DeviceSettings
|
||||||
.Include(x => x.Device)
|
.Include(x => x.Device)
|
||||||
.Include(x => x.Relays)
|
.Include(x => x.Relays)
|
||||||
.ThenInclude(r => r.Rules)
|
.ThenInclude(r => r.RuleGroups)
|
||||||
|
.ThenInclude(g => g.Conditions)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.DeviceId == deviceId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.DeviceId == deviceId, cancellationToken);
|
||||||
|
|
||||||
@@ -50,7 +51,8 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
|
|||||||
{
|
{
|
||||||
var entity = await _dbContext.DeviceSettings
|
var entity = await _dbContext.DeviceSettings
|
||||||
.Include(x => x.Relays)
|
.Include(x => x.Relays)
|
||||||
.ThenInclude(r => r.Rules)
|
.ThenInclude(r => r.RuleGroups)
|
||||||
|
.ThenInclude(g => g.Conditions)
|
||||||
.Include(x => x.Device)
|
.Include(x => x.Device)
|
||||||
.FirstOrDefaultAsync(x => x.DeviceId == dto.DeviceId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.DeviceId == dto.DeviceId, cancellationToken);
|
||||||
|
|
||||||
@@ -79,9 +81,16 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
|
|||||||
|
|
||||||
foreach (var relay in dto.Relays)
|
foreach (var relay in dto.Relays)
|
||||||
{
|
{
|
||||||
relay.Rules = relay.Rules?
|
relay.RuleGroups = relay.RuleGroups?
|
||||||
.OrderBy(r => r.Order)
|
.OrderBy(g => g.Order)
|
||||||
.ToList() ?? new List<DeviceRelayRuleDto>();
|
.ToList() ?? new List<DeviceRelayRuleGroupDto>();
|
||||||
|
|
||||||
|
foreach (var group in relay.RuleGroups)
|
||||||
|
{
|
||||||
|
group.Conditions = group.Conditions?
|
||||||
|
.OrderBy(c => c.Order)
|
||||||
|
.ToList() ?? new List<DeviceRelayRuleConditionDto>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,26 +123,39 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
|
|||||||
relay.RelayKind = dto.RelayKind;
|
relay.RelayKind = dto.RelayKind;
|
||||||
relay.UpdatedAt = now;
|
relay.UpdatedAt = now;
|
||||||
|
|
||||||
SyncRules(relay, dto.Rules ?? new List<DeviceRelayRuleDto>());
|
SyncRuleGroups(relay, dto.RuleGroups ?? new List<DeviceRelayRuleGroupDto>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SyncRules(Domain.DeviceRelay relay, List<DeviceRelayRuleDto> ruleDtos)
|
private static void SyncRuleGroups(Domain.DeviceRelay relay, List<DeviceRelayRuleGroupDto> 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,
|
Action = groupDto.Action,
|
||||||
ComparisonType = dto.ComparisonType,
|
Label = string.IsNullOrWhiteSpace(groupDto.Label) ? null : groupDto.Label.Trim(),
|
||||||
Value1 = dto.Value1,
|
Order = groupDto.Order
|
||||||
Value2 = dto.Value2,
|
};
|
||||||
Action = dto.Action,
|
|
||||||
TimeType = dto.TimeType,
|
foreach (var conditionDto in (groupDto.Conditions ?? new List<DeviceRelayRuleConditionDto>()).OrderBy(c => c.Order))
|
||||||
Order = dto.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function makeRelay(index: number): DeviceRelayDto {
|
|||||||
name: '',
|
name: '',
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
relayKind: 0,
|
relayKind: 0,
|
||||||
rules: [],
|
ruleGroups: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Plug, ToggleLeft, ToggleRight, Zap } from 'lucide-react'
|
import { Plus, Plug, ToggleLeft, ToggleRight } from 'lucide-react'
|
||||||
import { DeviceRelayDto, DeviceRelayRuleDto } from '@/lib/api'
|
import { DeviceRelayDto, DeviceRelayRuleGroupDto, DeviceRelayRuleConditionDto } from '@/lib/api'
|
||||||
import { RELAY_KINDS, RELAY_HINTS, RELAY_KIND_COLORS, RelayKindValue } from './relay-constants'
|
import { RELAY_KINDS, RELAY_HINTS, RELAY_KIND_COLORS, RelayKindValue } from './relay-constants'
|
||||||
import { SettingField } from './SettingField'
|
import { SettingField } from './SettingField'
|
||||||
import { RelayRuleCard } from './RelayRuleCard'
|
import { RelayGroupCard } from './RelayGroupCard'
|
||||||
import { RelayRuleModal } from './RelayRuleModal'
|
import { RelayRuleModal } from './RelayRuleModal'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -15,15 +15,18 @@ type RelayCardProps = {
|
|||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDefaultRule(): DeviceRelayRuleDto {
|
function makeGroup(action: 0 | 1, order: number): DeviceRelayRuleGroupDto {
|
||||||
|
return { id: 0, action, label: '', order, conditions: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCondition(): DeviceRelayRuleConditionDto {
|
||||||
return {
|
return {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
conditionType: 0,
|
||||||
sensorType: 0,
|
sensorType: 0,
|
||||||
comparisonType: 0,
|
comparisonType: 0,
|
||||||
value1: 30,
|
value1: 30,
|
||||||
value2: undefined,
|
value2: undefined,
|
||||||
action: 0,
|
|
||||||
timeType: 2,
|
|
||||||
order: 0,
|
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 KindIcon = RELAY_KINDS.find(k => k.value === relay.relayKind)?.icon ?? Plug
|
||||||
const kindColors = RELAY_KIND_COLORS[relay.relayKind]
|
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<DeviceRelayDto>) => onChange({ ...relay, ...patch })
|
const update = (patch: Partial<DeviceRelayDto>) => 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 confirmRule = (rule: DeviceRelayRuleDto) => {
|
|
||||||
const rules = [...relay.rules]
|
|
||||||
if (ruleDialog.editingIndex !== null) {
|
|
||||||
rules[ruleDialog.editingIndex] = { ...rule, order: ruleDialog.editingIndex }
|
|
||||||
} else {
|
|
||||||
rules.push({ ...makeDefaultRule(), ...rule, id: 0, order: relay.rules.length })
|
|
||||||
}
|
}
|
||||||
update({ rules })
|
|
||||||
|
const addGroup = (action: 0 | 1) => {
|
||||||
|
const group = makeGroup(action, relay.ruleGroups.length)
|
||||||
|
update({ ruleGroups: [...relay.ruleGroups, group] })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
conditions.push({ ...makeCondition(), ...condition, id: 0, order: conditions.length })
|
||||||
|
}
|
||||||
|
updateGroup(group, { ...group, conditions })
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeRule = (index: number) => {
|
const removeCondition = (group: DeviceRelayRuleGroupDto, index: number) => {
|
||||||
update({ rules: relay.rules.filter((_, i) => i !== index).map((r, i) => ({ ...r, order: i })) })
|
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 (
|
||||||
|
<section className="rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className={cn('w-2 h-2 rounded-full shrink-0', isOn ? 'bg-emerald-500' : 'bg-rose-400')} />
|
||||||
|
<h3 className="text-sm font-bold text-gray-800">
|
||||||
|
{isOn ? 'روشن شدن' : 'خاموش شدن'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addGroup(action)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
گروه جدید
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1 text-[11px] leading-5 text-gray-400">
|
||||||
|
{isOn ? RELAY_HINTS.onGroups : RELAY_HINTS.offGroups}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2.5">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addGroup(action)}
|
||||||
|
className="w-full rounded-xl border border-dashed border-gray-200 py-6 text-center text-sm text-gray-400 hover:text-green-600 hover:border-green-300 transition-colors"
|
||||||
|
>
|
||||||
|
{isOn ? 'افزودن اولین گروه روشن شدن' : 'افزودن اولین گروه خاموش شدن'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
groups.map((group, index) => (
|
||||||
|
<RelayGroupCard
|
||||||
|
key={relay.relayIndex + '-' + action + '-' + index}
|
||||||
|
group={group}
|
||||||
|
index={index}
|
||||||
|
onChange={(updated) => updateGroup(group, updated)}
|
||||||
|
onRemove={() => removeGroup(group)}
|
||||||
|
onAddCondition={() => openAddCondition(group)}
|
||||||
|
onEditCondition={(conditionIndex) => openEditCondition(group, conditionIndex)}
|
||||||
|
onRemoveCondition={(conditionIndex) => removeCondition(group, conditionIndex)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn(!embedded && 'rounded-3xl border border-gray-200 bg-white shadow-sm overflow-hidden')}>
|
<div className={cn(!embedded && 'rounded-3xl border border-gray-200 bg-white shadow-sm overflow-hidden')}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={cn(
|
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||||
'flex items-center justify-between gap-3 px-5 py-4 border-b border-gray-100',
|
|
||||||
relay.isEnabled ? 'bg-gradient-to-l from-gray-50 to-white' : 'bg-gray-50'
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center justify-center">
|
<span className="flex items-center justify-center w-10 h-10 rounded-xl bg-gray-50">
|
||||||
<KindIcon className={cn('w-8 h-8', kindColors.iconText)} />
|
<KindIcon className={cn('w-6 h-6', kindColors.iconText)} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div className="text-sm font-semibold text-gray-900">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
|
||||||
رله {relay.relayIndex}
|
رله {relay.relayIndex}
|
||||||
</span>
|
|
||||||
{relay.name && (
|
{relay.name && (
|
||||||
<span className="text-sm text-gray-500">— {relay.name}</span>
|
<span className="text-gray-400 font-normal"> — {relay.name}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-0.5">
|
|
||||||
{relay.isEnabled ? 'فعال' : 'غیرفعال'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enable toggle */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => update({ isEnabled: !relay.isEnabled })}
|
onClick={() => update({ isEnabled: !relay.isEnabled })}
|
||||||
title={RELAY_HINTS.enabled}
|
title={RELAY_HINTS.enabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors',
|
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors',
|
||||||
relay.isEnabled ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-600'
|
relay.isEnabled ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{relay.isEnabled ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
{relay.isEnabled ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
||||||
@@ -101,9 +173,9 @@ export function RelayCard({ relay, onChange, embedded = false }: RelayCardProps)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 space-y-5">
|
<div className="px-5 pb-5 space-y-5">
|
||||||
{/* Name + Kind */}
|
{/* Name + Kind */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<SettingField label="نام رله" hint={RELAY_HINTS.name}>
|
<SettingField label="نام رله" hint={RELAY_HINTS.name}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -129,63 +201,19 @@ export function RelayCard({ relay, onChange, embedded = false }: RelayCardProps)
|
|||||||
</SettingField>
|
</SettingField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rules */}
|
{/* ON / OFF law panels */}
|
||||||
<div className="pt-4 border-t border-gray-100">
|
<div className="grid gap-4 sm:grid-cols-2 items-start">
|
||||||
<div className="flex items-center justify-between mb-3">
|
{renderSection(0)}
|
||||||
<div>
|
{renderSection(1)}
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-sm font-medium text-gray-700">قوانین روشن/خاموش شدن</span>
|
|
||||||
{relay.rules.length > 0 && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 text-gray-500">
|
|
||||||
{relay.rules.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-0.5">{RELAY_HINTS.rules}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openCreate}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium bg-green-50 text-green-700 hover:bg-green-100 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
افزودن قانون
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{relay.rules.length === 0 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openCreate}
|
|
||||||
className="w-full rounded-2xl border border-dashed border-gray-200 bg-gray-50 py-8 text-center hover:border-green-300 hover:bg-green-50/40 transition-colors"
|
|
||||||
>
|
|
||||||
<Zap className="w-6 h-6 mx-auto text-gray-300 mb-2" />
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
هنوز قانونی تعریف نشده است. برای تعریف اولین قانون کلیک کنید.
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{relay.rules.map((rule, index) => (
|
|
||||||
<RelayRuleCard
|
|
||||||
key={index}
|
|
||||||
rule={rule}
|
|
||||||
index={index}
|
|
||||||
onEdit={openEdit}
|
|
||||||
onRemove={removeRule}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RelayRuleModal
|
<RelayRuleModal
|
||||||
open={ruleDialog.open}
|
open={conditionDialog.open}
|
||||||
initial={editingRule}
|
initial={editingCondition}
|
||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
onConfirm={confirmRule}
|
onConfirm={confirmCondition}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-3.5 py-3">
|
||||||
|
{/* Group header: number + optional label + remove */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-bold text-gray-400 whitespace-nowrap">
|
||||||
|
گروه {index + 1}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={group.label ?? ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
|
||||||
|
title="حذف این گروه"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conditions */}
|
||||||
|
{group.conditions.length === 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddCondition}
|
||||||
|
className="mt-2 w-full rounded-xl border border-dashed border-gray-200 py-3 text-xs text-gray-400 hover:text-green-600 hover:border-green-300 transition-colors"
|
||||||
|
>
|
||||||
|
افزودن شرط به این گروه
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1.5 divide-y divide-gray-100">
|
||||||
|
{group.conditions.map((condition, conditionIndex) => (
|
||||||
|
<RelayRuleCard
|
||||||
|
key={conditionIndex}
|
||||||
|
condition={condition}
|
||||||
|
onEdit={() => onEditCondition(conditionIndex)}
|
||||||
|
onRemove={() => onRemoveCondition(conditionIndex)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group.conditions.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddCondition}
|
||||||
|
className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-green-600 hover:text-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
افزودن شرط
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,115 +1,58 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Pencil, Trash2 } from 'lucide-react'
|
import { Pencil, Trash2, Clock, Thermometer, MoonStar } from 'lucide-react'
|
||||||
import { DeviceRelayRuleDto } from '@/lib/api'
|
import { DeviceRelayRuleConditionDto } from '@/lib/api'
|
||||||
import { SENSOR_TYPES, COMPARISON_TYPES, TIME_TYPES } from '@/components/alert-settings/constants'
|
import { formatConditionFa, isOvernightRange } from './relay-constants'
|
||||||
import { RELAY_ACTIONS, SENSOR_STYLES, TIME_STYLES } from './relay-constants'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
type RelayRuleCardProps = {
|
type RelayRuleCardProps = {
|
||||||
rule: DeviceRelayRuleDto
|
condition: DeviceRelayRuleConditionDto
|
||||||
index: number
|
onEdit: () => void
|
||||||
onEdit: (index: number) => void
|
onRemove: () => void
|
||||||
onRemove: (index: number) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RelayRuleCard({ rule, index, onEdit, onRemove }: RelayRuleCardProps) {
|
export function RelayRuleCard({ condition, onEdit, onRemove }: RelayRuleCardProps) {
|
||||||
const sensor = SENSOR_TYPES.find(s => s.value === rule.sensorType)
|
const isTime = condition.conditionType === 1
|
||||||
const comparison = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)
|
const overnight = isTime && isOvernightRange(condition.startMinutes ?? 0, condition.endMinutes ?? 0)
|
||||||
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'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center gap-2.5 py-1.5">
|
||||||
className={cn('group flex items-stretch gap-2 rounded-xl border transition-all', style.chip)}
|
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-white shadow-sm shrink-0">
|
||||||
title={`ویرایش قانون ${index + 1}`}
|
{isTime
|
||||||
>
|
? <Clock className="w-3.5 h-3.5 text-slate-500" />
|
||||||
{/* Clickable body - opens the edit dialog */}
|
: <Thermometer className="w-3.5 h-3.5 text-gray-500" />}
|
||||||
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onEdit(index)}
|
onClick={onEdit}
|
||||||
className="flex items-center gap-2.5 flex-1 min-w-0 px-3 py-2.5 text-right"
|
title="ویرایش این شرط"
|
||||||
|
className="flex-1 min-w-0 text-right"
|
||||||
>
|
>
|
||||||
{/* Sensor icon bubble (colored by sensor type) */}
|
<span className="block text-sm text-gray-700 leading-relaxed">
|
||||||
<span className={cn(
|
{formatConditionFa(condition)}
|
||||||
'flex items-center justify-center w-10 h-10 rounded-xl border shrink-0 shadow-sm',
|
|
||||||
style.bubble
|
|
||||||
)}>
|
|
||||||
{SensorIcon && <SensorIcon className="w-5 h-5" />}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="min-w-0 flex-1 space-y-1">
|
|
||||||
{/* main line: sensor + condition */}
|
|
||||||
<span className="flex items-center gap-1.5 flex-wrap text-sm">
|
|
||||||
<span className={cn('font-bold', style.label)}>{sensorLabel}</span>
|
|
||||||
<span className={cn(
|
|
||||||
'font-mono text-[11px] font-semibold px-2 py-0.5 rounded-md border bg-white/90',
|
|
||||||
style.mono
|
|
||||||
)}>
|
|
||||||
{conditionText}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* meta line: time badge + action pill */}
|
|
||||||
<span className="flex items-center gap-1.5 flex-wrap">
|
|
||||||
<span className={cn(
|
|
||||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
|
||||||
timeStyle.badge
|
|
||||||
)}>
|
|
||||||
{TimeIcon && <TimeIcon className="w-3 h-3" />}
|
|
||||||
{timeLabel}
|
|
||||||
</span>
|
|
||||||
<span className={cn(
|
|
||||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
|
||||||
actionPill
|
|
||||||
)}>
|
|
||||||
{ActionIcon && <ActionIcon className="w-3 h-3" />}
|
|
||||||
رله {relayAction?.label ?? ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
{overnight && (
|
||||||
|
<span className="mt-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-slate-400">
|
||||||
|
<MoonStar className="w-3 h-3" />
|
||||||
|
از نیمهشب عبور میکند
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Actions */}
|
<span className="flex items-center gap-0.5 shrink-0">
|
||||||
<span className="flex items-center gap-0.5 pl-1.5">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onEdit(index)}
|
onClick={onEdit}
|
||||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
className="p-1 text-gray-300 hover:text-blue-500 transition-colors"
|
||||||
title="ویرایش این قانون"
|
title="ویرایش این شرط"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(index)}
|
onClick={onRemove}
|
||||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
|
||||||
title="حذف این قانون"
|
title="حذف این شرط"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,34 +1,44 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Check, ChevronDown } from 'lucide-react'
|
import { Check, ChevronDown, Clock, MoonStar } from 'lucide-react'
|
||||||
import { DeviceRelayRuleDto } from '@/lib/api'
|
import { DeviceRelayRuleConditionDto } from '@/lib/api'
|
||||||
import { Button, Modal } from '@/components/common'
|
import { Button, Modal } from '@/components/common'
|
||||||
import { SENSOR_TYPES, COMPARISON_TYPES, TIME_TYPES } from '@/components/alert-settings/constants'
|
import { SENSOR_TYPES, COMPARISON_TYPES } from '@/components/alert-settings/constants'
|
||||||
import { SENSOR_STYLES, TIME_STYLES } from './relay-constants'
|
import {
|
||||||
|
SENSOR_STYLES,
|
||||||
|
CONDITION_TYPES,
|
||||||
|
isOvernightRange,
|
||||||
|
formatConditionFa,
|
||||||
|
minutesToTime,
|
||||||
|
timeToMinutes,
|
||||||
|
} from './relay-constants'
|
||||||
import { SettingField } from './SettingField'
|
import { SettingField } from './SettingField'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type RelayRuleModalProps = {
|
type RelayRuleModalProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
/** the rule being edited; when undefined a fresh rule is created */
|
/** the condition being edited; when undefined a fresh condition is created */
|
||||||
initial?: DeviceRelayRuleDto
|
initial?: DeviceRelayRuleConditionDto
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onConfirm: (rule: DeviceRelayRuleDto) => void
|
onConfirm: (condition: DeviceRelayRuleConditionDto) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectClass =
|
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 =
|
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'
|
'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 =
|
const timeInputClass =
|
||||||
'flex flex-col items-center justify-center gap-1.5 rounded-xl border px-2 py-3 text-xs font-medium transition-all'
|
'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 timeButtonInactive = 'border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
|
||||||
|
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
|
// Focus ring color per sensor type so the picker follows the sensor's theme
|
||||||
const SENSOR_FOCUS: Record<DeviceRelayRuleDto['sensorType'], string> = {
|
const SENSOR_FOCUS: Record<NonNullable<DeviceRelayRuleConditionDto['sensorType']>, string> = {
|
||||||
0: 'focus:border-red-300 focus:ring-2 focus:ring-red-500/20',
|
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',
|
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',
|
2: 'focus:border-emerald-300 focus:ring-2 focus:ring-emerald-500/20',
|
||||||
@@ -36,15 +46,29 @@ const SENSOR_FOCUS: Record<DeviceRelayRuleDto['sensorType'], string> = {
|
|||||||
4: 'focus:border-violet-300 focus:ring-2 focus:ring-violet-500/20',
|
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 {
|
return {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
conditionType: 0,
|
||||||
sensorType: 0,
|
sensorType: 0,
|
||||||
comparisonType: 0,
|
comparisonType: 0,
|
||||||
value1: 30,
|
value1: 30,
|
||||||
value2: undefined,
|
value2: undefined,
|
||||||
action: 0,
|
|
||||||
timeType: 2,
|
|
||||||
order: 0,
|
order: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,66 +76,91 @@ function makeDefaultRule(): DeviceRelayRuleDto {
|
|||||||
export function RelayRuleModal({ open, initial, onClose, onConfirm }: RelayRuleModalProps) {
|
export function RelayRuleModal({ open, initial, onClose, onConfirm }: RelayRuleModalProps) {
|
||||||
const isEditing = !!initial
|
const isEditing = !!initial
|
||||||
|
|
||||||
const [sensorType, setSensorType] = useState<DeviceRelayRuleDto['sensorType']>(0)
|
const [conditionType, setConditionType] = useState<DeviceRelayRuleConditionDto['conditionType']>(0)
|
||||||
const [comparisonType, setComparisonType] = useState<DeviceRelayRuleDto['comparisonType']>(0)
|
const [sensorType, setSensorType] = useState<NonNullable<DeviceRelayRuleConditionDto['sensorType']>>(0)
|
||||||
|
const [comparisonType, setComparisonType] = useState<NonNullable<DeviceRelayRuleConditionDto['comparisonType']>>(0)
|
||||||
const [value1, setValue1] = useState('30')
|
const [value1, setValue1] = useState('30')
|
||||||
const [value2, setValue2] = useState('')
|
const [value2, setValue2] = useState('')
|
||||||
const [timeType, setTimeType] = useState<DeviceRelayRuleDto['timeType']>(2)
|
const [startTime, setStartTime] = useState('00:00')
|
||||||
|
const [endTime, setEndTime] = useState('00:00')
|
||||||
|
|
||||||
// (Re)initialize the draft whenever the dialog is (re)opened
|
// (Re)initialize the draft whenever the dialog is (re)opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const base = initial ?? makeDefaultRule()
|
const base = initial ?? makeDefaultCondition()
|
||||||
setSensorType(base.sensorType)
|
setConditionType(base.conditionType)
|
||||||
setComparisonType(base.comparisonType)
|
setSensorType(base.sensorType ?? 0)
|
||||||
|
setComparisonType(base.comparisonType ?? 0)
|
||||||
setValue1(base.value1 != null ? String(base.value1) : '')
|
setValue1(base.value1 != null ? String(base.value1) : '')
|
||||||
setValue2(base.value2 != null ? String(base.value2) : '')
|
setValue2(base.value2 != null ? String(base.value2) : '')
|
||||||
setTimeType(base.timeType)
|
setStartTime(minutesToTime(base.startMinutes ?? 0))
|
||||||
|
setEndTime(minutesToTime(base.endMinutes ?? 0))
|
||||||
}, [open, initial])
|
}, [open, initial])
|
||||||
|
|
||||||
// رله فقط عمل «روشن شود» دارد؛ وقتی «در شب» انتخاب شود، بکگراند دیالوگ حالت شب میگیرد
|
const isTime = conditionType === 1
|
||||||
const isNight = timeType === 1
|
|
||||||
|
|
||||||
const sensor = SENSOR_TYPES.find(s => s.value === sensorType)
|
const sensor = SENSOR_TYPES.find(s => s.value === sensorType)
|
||||||
const SensorIcon = sensor?.icon
|
const SensorIcon = sensor?.icon
|
||||||
const sensorUnit = sensor?.unit ?? ''
|
const sensorUnit = sensor?.unit ?? ''
|
||||||
const sensorLabel = sensor?.label ?? ''
|
|
||||||
const style = SENSOR_STYLES[sensorType] ?? SENSOR_STYLES[0]
|
const style = SENSOR_STYLES[sensorType] ?? SENSOR_STYLES[0]
|
||||||
|
|
||||||
const needsMax = useMemo(
|
const needsMax = useMemo(
|
||||||
() => !!COMPARISON_TYPES.find(c => c.value === comparisonType)?.needsMax,
|
() => !!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 startMinutes = timeToMinutes(startTime)
|
||||||
const label = time?.label ?? ''
|
const endMinutes = timeToMinutes(endTime)
|
||||||
return timeType < 2 ? `در ${label}` : label
|
const overnight = useMemo(
|
||||||
}, [timeType, time])
|
() => 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 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 parsed1 = value1 === '' ? 0 : Number(value1)
|
||||||
const parsed2 = value2 === '' ? 0 : Number(value2)
|
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({
|
onConfirm({
|
||||||
...base,
|
...base,
|
||||||
|
conditionType: 1,
|
||||||
|
sensorType: undefined,
|
||||||
|
comparisonType: undefined,
|
||||||
|
value1: undefined,
|
||||||
|
value2: undefined,
|
||||||
|
startMinutes: timeToMinutes(startTime),
|
||||||
|
endMinutes: timeToMinutes(endTime),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm({
|
||||||
|
...base,
|
||||||
|
conditionType: 0,
|
||||||
sensorType,
|
sensorType,
|
||||||
comparisonType,
|
comparisonType,
|
||||||
value1: Number.isFinite(parsed1) ? parsed1 : 0,
|
value1: Number.isFinite(parsed1) ? parsed1 : 0,
|
||||||
value2: needsMax && Number.isFinite(parsed2) ? parsed2 : (base.value2 ?? undefined),
|
value2: needsMax && Number.isFinite(parsed2) ? parsed2 : (base.value2 ?? undefined),
|
||||||
action: 0,
|
startMinutes: undefined,
|
||||||
timeType,
|
endMinutes: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,88 +168,125 @@ export function RelayRuleModal({ open, initial, onClose, onConfirm }: RelayRuleM
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={isEditing ? 'ویرایش قانون' : 'افزودن قانون جدید'}
|
title={isEditing ? 'ویرایش شرط' : 'افزودن شرط جدید'}
|
||||||
size="md"
|
size="md"
|
||||||
contentClassName="p-0"
|
contentClassName="p-0"
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div className="p-5 space-y-5">
|
||||||
'p-5 space-y-5 transition-colors duration-300',
|
{/* Condition type selector */}
|
||||||
isNight && 'bg-gradient-to-b from-indigo-950 via-slate-900 to-indigo-950'
|
|
||||||
)}>
|
|
||||||
{/* Preview banner (colored by the sensor type) */}
|
|
||||||
<div className={cn('flex items-start gap-3 rounded-xl border px-4 py-3', style.banner)}>
|
|
||||||
<span className={cn(
|
|
||||||
'flex items-center justify-center w-9 h-9 rounded-xl border shrink-0 shadow-sm',
|
|
||||||
style.bubble
|
|
||||||
)}>
|
|
||||||
{SensorIcon && <SensorIcon className="w-5 h-5" />}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className={cn('text-sm font-semibold leading-relaxed', style.bannerText)}>
|
|
||||||
{previewText}
|
|
||||||
</p>
|
|
||||||
<p className={cn('text-[11px] mt-1 font-medium opacity-80', style.bannerText)}>
|
|
||||||
{timeText} بررسی میشود
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time type (day/night themed) */}
|
|
||||||
<div>
|
<div>
|
||||||
<span className={cn('block text-sm font-medium mb-2', isNight ? 'text-slate-100' : 'text-gray-700')}>
|
<span className="block text-sm font-medium mb-1.5 text-gray-700">
|
||||||
زمان بررسی
|
شرط را بر اساس چه چیزی تعریف میکنید؟
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<p className="text-xs text-gray-400 mb-2">
|
||||||
{TIME_TYPES.map(t => {
|
اگر میخواهید با عدد سنسور (مثل دما) رله کار کند «بر اساس سنسور»، و اگر با ساعت میخواهید «بر اساس ساعت» را بزنید.
|
||||||
const TimeIcon = t.icon
|
</p>
|
||||||
const active = t.value === timeType
|
<div className="flex gap-2">
|
||||||
const ts = TIME_STYLES[t.value] ?? TIME_STYLES[2]
|
{CONDITION_TYPES.map(t => {
|
||||||
const label = t.value < 2 ? `در ${t.label}` : t.label
|
const TypeIcon = t.icon
|
||||||
|
const active = t.value === conditionType
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.value}
|
key={t.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTimeType(t.value as DeviceRelayRuleDto['timeType'])}
|
onClick={() => setConditionType(t.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
timeButtonBase,
|
typeButtonBase,
|
||||||
active ? ts.activeButton : timeButtonInactive
|
active
|
||||||
|
? 'border-green-400 bg-green-50 text-green-700 ring-2 ring-green-300/30'
|
||||||
|
: typeButtonInactive,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TimeIcon className={cn('w-4 h-4', ts.iconText)} />
|
<TypeIcon className="w-4 h-4" />
|
||||||
{label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Condition */}
|
{/* Preview banner */}
|
||||||
|
<div className={cn('flex items-start gap-3 rounded-xl border px-4 py-3', isTime ? 'bg-gradient-to-l from-slate-50 to-slate-100/60 border-slate-200' : style.banner)}>
|
||||||
|
<span className={cn(
|
||||||
|
'flex items-center justify-center w-9 h-9 rounded-xl border shrink-0 shadow-sm',
|
||||||
|
isTime ? 'bg-slate-100 text-slate-600 border-slate-200' : style.bubble,
|
||||||
|
)}>
|
||||||
|
{isTime ? <Clock className="w-5 h-5" /> : SensorIcon && <SensorIcon className="w-5 h-5" />}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={cn('text-sm font-semibold leading-relaxed', isTime ? 'text-slate-800' : style.bannerText)}>
|
||||||
|
{previewText}
|
||||||
|
</p>
|
||||||
|
{overnight && (
|
||||||
|
<p className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-slate-500">
|
||||||
|
<MoonStar className="w-3.5 h-3.5" />
|
||||||
|
این بازه از نیمهشب عبور میکند و درست حساب میشود
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isTime ? (
|
||||||
<SettingField
|
<SettingField
|
||||||
label="شرط (وقتی...)"
|
label="بازه ساعت"
|
||||||
hint="یک شرط برای روشن شدن خودکار رله تعریف کنید."
|
hint="از چه ساعتی تا چه ساعتی؟ ساعت را ۲۴ ساعته بنویسید (مثلاً ۱۴:۳۰ یعنی ۲:۳۰ بعدازظهر). اگر شروع دیرتر از پایان باشد یعنی از نیمهشب رد میشود."
|
||||||
onDark={isNight}
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs text-gray-500 mb-1">شروع</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
dir="ltr"
|
||||||
|
maxLength={5}
|
||||||
|
value={startTime}
|
||||||
|
onChange={e => setStartTime(e.target.value)}
|
||||||
|
onBlur={e => setStartTime(normalizeTime(e.target.value))}
|
||||||
|
placeholder="00:00"
|
||||||
|
className={timeInputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs text-gray-500 mb-1">پایان</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
dir="ltr"
|
||||||
|
maxLength={5}
|
||||||
|
value={endTime}
|
||||||
|
onChange={e => setEndTime(e.target.value)}
|
||||||
|
onBlur={e => setEndTime(normalizeTime(e.target.value))}
|
||||||
|
placeholder="00:00"
|
||||||
|
className={timeInputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingField>
|
||||||
|
) : (
|
||||||
|
<SettingField
|
||||||
|
label="شرط بر اساس سنسور"
|
||||||
|
hint="مثلاً بگویید دما بیشتر از ۳۰ درجه شود یا رطوبت کمتر از ۲۰ درصد شود."
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{/* Sensor */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
className={cn(selectClass, 'focus:outline-none', SENSOR_FOCUS[sensorType])}
|
className={cn(selectClass, SENSOR_FOCUS[sensorType])}
|
||||||
value={sensorType}
|
value={sensorType}
|
||||||
onChange={e => setSensorType(Number(e.target.value) as DeviceRelayRuleDto['sensorType'])}
|
onChange={e => setSensorType(Number(e.target.value) as typeof sensorType)}
|
||||||
>
|
>
|
||||||
{SENSOR_TYPES.map(s => (
|
{SENSOR_TYPES.map(s => (
|
||||||
<option key={s.value} value={s.value}>{s.label} ({s.unit})</option>
|
<option key={s.value} value={s.value}>{s.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
<ChevronDown className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comparison */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
className={cn(selectClass, 'focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none')}
|
className={cn(selectClass, 'focus:border-green-500 focus:ring-2 focus:ring-green-500/20')}
|
||||||
value={comparisonType}
|
value={comparisonType}
|
||||||
onChange={e => setComparisonType(Number(e.target.value) as DeviceRelayRuleDto['comparisonType'])}
|
onChange={e => setComparisonType(Number(e.target.value) as typeof comparisonType)}
|
||||||
>
|
>
|
||||||
{COMPARISON_TYPES.map(c => (
|
{COMPARISON_TYPES.map(c => (
|
||||||
<option key={c.value} value={c.value}>{c.label}</option>
|
<option key={c.value} value={c.value}>{c.label}</option>
|
||||||
@@ -210,7 +296,6 @@ export function RelayRuleModal({ open, initial, onClose, onConfirm }: RelayRuleM
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Values */}
|
|
||||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@@ -250,6 +335,7 @@ export function RelayRuleModal({ open, initial, onClose, onConfirm }: RelayRuleM
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingField>
|
</SettingField>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function makeRelay(index: number): DeviceRelayDto {
|
|||||||
name: '',
|
name: '',
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
relayKind: 0,
|
relayKind: 0,
|
||||||
rules: [],
|
ruleGroups: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import {
|
|||||||
Droplet,
|
Droplet,
|
||||||
ToggleRight,
|
ToggleRight,
|
||||||
ToggleLeft,
|
ToggleLeft,
|
||||||
|
Clock,
|
||||||
|
Thermometer,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import type { DeviceRelayRuleConditionDto } from '@/lib/api'
|
||||||
|
|
||||||
export type RelayKindValue = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
export type RelayKindValue = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||||
export type RelayActionValue = 0 | 1
|
export type RelayActionValue = 0 | 1
|
||||||
export type RelaySensorValue = 0 | 1 | 2 | 3 | 4
|
export type RelaySensorValue = 0 | 1 | 2 | 3 | 4
|
||||||
|
export type RelayConditionValue = 0 | 1
|
||||||
export type RelayTimeValue = 0 | 1 | 2
|
export type RelayTimeValue = 0 | 1 | 2
|
||||||
|
|
||||||
export const RELAY_KINDS: { value: RelayKindValue; label: string; icon: LucideIcon; hint: string }[] = [
|
export const RELAY_KINDS: { value: RelayKindValue; label: string; icon: LucideIcon; hint: string }[] = [
|
||||||
@@ -103,11 +107,83 @@ export const TIME_STYLES: Record<RelayTimeValue, {
|
|||||||
2: { iconText: 'text-emerald-500', badge: 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-200', activeButton: 'border-emerald-400 bg-emerald-50 text-emerald-700 ring-2 ring-emerald-300/30' },
|
2: { iconText: 'text-emerald-500', badge: 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-200', activeButton: 'border-emerald-400 bg-emerald-50 text-emerald-700 ring-2 ring-emerald-300/30' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CONDITION_TYPES: { value: RelayConditionValue; label: string; icon: LucideIcon }[] = [
|
||||||
|
{ value: 0, label: 'بر اساس سنسور', icon: Thermometer },
|
||||||
|
{ value: 1, label: 'بر اساس ساعت', icon: Clock },
|
||||||
|
]
|
||||||
|
|
||||||
export const RELAY_HINTS = {
|
export const RELAY_HINTS = {
|
||||||
name: 'یک نام دلخواه برای این رله بگذارید تا در گزارشها و هشدارها بهجای «رله ۱» همین نام نمایش داده شود.',
|
name: 'برای این رله یک اسم ساده بگذارید (مثلاً فن یا بخاری).',
|
||||||
enabled: 'اگر این گزینه خاموش باشد، رله در هیچ شرایطی فعال نمیشود.',
|
enabled: 'اگر این کلید خاموش باشد، رله اصلاً کار نمیکند.',
|
||||||
kind: 'نوع تجهیز متصل به رله را مشخص کنید تا آیکون و پیشنهادهای مناسب نمایش داده شود.',
|
kind: 'بگویید چه چیزی به این رله وصل است تا آیکون آن درست نشان داده شود.',
|
||||||
rules: 'شرایطی را تعریف کنید که با برقرار شدن آن، رله روشن یا خاموش شود. میتوانید چند قانون اضافه کنید.',
|
onGroups: 'اینجا بگویید رله چهوقت روشن شود. اگر همه شرطهای یک گروه درست شد، رله روشن میشود.',
|
||||||
|
offGroups: 'اینجا بگویید رله چهوقت خاموش شود. اگر همه شرطهای یک گروه درست شد، رله خاموش میشود.',
|
||||||
|
groupLabel: 'یک اسم کوتاه برای این گروه بگذارید تا بعداً معلوم باشد رله بهخاطر کدام گروه روشن یا خاموش شده است.',
|
||||||
action: 'مشخص کنید با برقرار شدن شرط، رله روشن شود یا خاموش.',
|
action: 'مشخص کنید با برقرار شدن شرط، رله روشن شود یا خاموش.',
|
||||||
timeType: 'این قانون فقط در روز، فقط در شب، یا همیشه بررسی شود.',
|
timeType: 'این قانون فقط در روز، فقط در شب، یا همیشه بررسی شود.',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** برچسب فارسی ساده برای سنسورها */
|
||||||
|
export const SENSOR_LABELS_FA: Record<RelaySensorValue, string> = {
|
||||||
|
0: 'دما',
|
||||||
|
1: 'رطوبت هوا',
|
||||||
|
2: 'رطوبت خاک',
|
||||||
|
3: 'گاز',
|
||||||
|
4: 'نور',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** واحد فارسی ساده برای سنسورها */
|
||||||
|
export const SENSOR_UNITS_FA: Record<RelaySensorValue, string> = {
|
||||||
|
0: 'درجه',
|
||||||
|
1: 'درصد',
|
||||||
|
2: 'درصد',
|
||||||
|
3: 'پیپیام',
|
||||||
|
4: 'لوکس',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** عبارت فارسی ساده برای عملگرهای مقایسه */
|
||||||
|
export const COMPARISON_FA: Record<NonNullable<DeviceRelayRuleConditionDto['comparisonType']>, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,17 +65,32 @@ export type DeviceRelayDto = {
|
|||||||
name: string
|
name: string
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
relayKind: 0 | 1 | 2 | 3 | 4 | 5 | 6
|
relayKind: 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||||
rules: DeviceRelayRuleDto[]
|
ruleGroups: DeviceRelayRuleGroupDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceRelayRuleDto = {
|
export type DeviceRelayRuleGroupDto = {
|
||||||
id: number
|
id: number
|
||||||
sensorType: 0 | 1 | 2 | 3 | 4
|
/** 0 = turn on, 1 = turn off */
|
||||||
comparisonType: 0 | 1 | 2 | 3
|
|
||||||
value1: number
|
|
||||||
value2?: number
|
|
||||||
action: 0 | 1
|
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
|
order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user