fix conditions logic

This commit is contained in:
2026-09-11 23:52:11 +03:30
parent 8eb465c0e7
commit a48deb95da
19 changed files with 2545 additions and 453 deletions

View File

@@ -77,24 +77,46 @@ 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)));
var rule = $"S{r.Sensor}O{r.Op}V{r.V1}"; groups.AddRange(c.Off.Select(g => EncodeGroup(g, 1)));
if (r.V2.HasValue) return $"E{c.Enabled}R{c.RelayIndex}{string.Concat(groups)}";
rule += $",{r.V2.Value}";
return rule;
}));
return $"E{c.Enabled}T{c.TimeType}R{c.Relay}{rules}";
})); }));
} }
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;
}
/// <summary> /// <summary>
/// Collect relay states from query params r1, r2, ... (missing relay = off/0). /// Collect relay states from query params r1, r2, ... (missing relay = off/0).
/// </summary> /// </summary>

View File

@@ -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; }
} }

View File

@@ -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; }
} }

View File

@@ -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()

View File

@@ -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; }
} }

View File

@@ -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");

View File

@@ -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");
}
}
}

View File

@@ -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");

View File

@@ -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)

View File

@@ -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);
} }
} }
} }

View File

@@ -17,7 +17,7 @@ function makeRelay(index: number): DeviceRelayDto {
name: '', name: '',
isEnabled: true, isEnabled: true,
relayKind: 0, relayKind: 0,
rules: [], ruleGroups: [],
} }
} }

View File

@@ -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 addGroup = (action: 0 | 1) => {
const group = makeGroup(action, relay.ruleGroups.length)
update({ ruleGroups: [...relay.ruleGroups, group] })
}
const confirmRule = (rule: DeviceRelayRuleDto) => { const removeGroup = (group: DeviceRelayRuleGroupDto) => {
const rules = [...relay.rules] const next = relay.ruleGroups.filter(g => g !== group).map((g, i) => ({ ...g, order: i }))
if (ruleDialog.editingIndex !== null) { update({ ruleGroups: next })
rules[ruleDialog.editingIndex] = { ...rule, order: ruleDialog.editingIndex } }
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 { } 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() 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"> رله {relay.relayIndex}
<span className="text-sm font-semibold text-gray-900"> {relay.name && (
رله {relay.relayIndex} <span className="text-gray-400 font-normal"> — {relay.name}</span>
</span> )}
{relay.name && (
<span className="text-sm text-gray-500">— {relay.name}</span>
)}
</div>
<p className="text-xs text-gray-400 mt-0.5">
{relay.isEnabled ? 'فعال' : 'غیرفعال'}
</p>
</div> </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}
/> />
</> </>
) )

View File

@@ -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>
)
}

View File

@@ -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>
{overnight && (
<span className="min-w-0 flex-1 space-y-1"> <span className="mt-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-slate-400">
{/* main line: sensor + condition */} <MoonStar className="w-3 h-3" />
<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> </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>
</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>

View File

@@ -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({
...base,
conditionType: 1,
sensorType: undefined,
comparisonType: undefined,
value1: undefined,
value2: undefined,
startMinutes: timeToMinutes(startTime),
endMinutes: timeToMinutes(endTime),
})
return
}
onConfirm({ onConfirm({
...base, ...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,137 +168,174 @@ 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 */}
<SettingField <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)}>
label="شرط (وقتی...)" <span className={cn(
hint="یک شرط برای روشن شدن خودکار رله تعریف کنید." 'flex items-center justify-center w-9 h-9 rounded-xl border shrink-0 shadow-sm',
onDark={isNight} isTime ? 'bg-slate-100 text-slate-600 border-slate-200' : style.bubble,
> )}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> {isTime ? <Clock className="w-5 h-5" /> : SensorIcon && <SensorIcon className="w-5 h-5" />}
{/* Sensor */} </span>
<div className="relative"> <div className="flex-1 min-w-0">
<select <p className={cn('text-sm font-semibold leading-relaxed', isTime ? 'text-slate-800' : style.bannerText)}>
className={cn(selectClass, 'focus:outline-none', SENSOR_FOCUS[sensorType])} {previewText}
value={sensorType} </p>
onChange={e => setSensorType(Number(e.target.value) as DeviceRelayRuleDto['sensorType'])} {overnight && (
> <p className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-slate-500">
{SENSOR_TYPES.map(s => ( <MoonStar className="w-3.5 h-3.5" />
<option key={s.value} value={s.value}>{s.label} ({s.unit})</option> این بازه از نیمه‌شب عبور می‌کند و درست حساب می‌شود
))} </p>
</select> )}
<ChevronDown className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
{/* Comparison */}
<div className="relative">
<select
className={cn(selectClass, 'focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none')}
value={comparisonType}
onChange={e => setComparisonType(Number(e.target.value) as DeviceRelayRuleDto['comparisonType'])}
>
{COMPARISON_TYPES.map(c => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<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> </div>
</div>
{/* Values */} {isTime ? (
<div className="grid grid-cols-2 gap-2 mt-2"> <SettingField
<div className="relative"> label="بازه ساعت"
<input hint="از چه ساعتی تا چه ساعتی؟ ساعت را ۲۴ ساعته بنویسید (مثلاً ۱۴:۳۰ یعنی ۲:۳۰ بعدازظهر). اگر شروع دیرتر از پایان باشد یعنی از نیمه‌شب رد می‌شود."
type="number" >
step="0.1" <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
dir="ltr" <div>
inputMode="decimal" <span className="block text-xs text-gray-500 mb-1">شروع</span>
value={value1} <input
onChange={e => setValue1(e.target.value)} type="text"
className={numberClass} inputMode="numeric"
placeholder={needsMax ? 'از' : 'مقدار'} dir="ltr"
/> maxLength={5}
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[10px] text-gray-400 pointer-events-none"> value={startTime}
{sensorUnit} onChange={e => setStartTime(e.target.value)}
</span> 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> </div>
{needsMax ? ( </SettingField>
) : (
<SettingField
label="شرط بر اساس سنسور"
hint="مثلاً بگویید دما بیشتر از ۳۰ درجه شود یا رطوبت کمتر از ۲۰ درصد شود."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="relative">
<select
className={cn(selectClass, SENSOR_FOCUS[sensorType])}
value={sensorType}
onChange={e => setSensorType(Number(e.target.value) as typeof sensorType)}
>
{SENSOR_TYPES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<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 className="relative">
<select
className={cn(selectClass, 'focus:border-green-500 focus:ring-2 focus:ring-green-500/20')}
value={comparisonType}
onChange={e => setComparisonType(Number(e.target.value) as typeof comparisonType)}
>
{COMPARISON_TYPES.map(c => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<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>
<div className="grid grid-cols-2 gap-2 mt-2">
<div className="relative"> <div className="relative">
<input <input
type="number" type="number"
step="0.1" step="0.1"
dir="ltr" dir="ltr"
inputMode="decimal" inputMode="decimal"
value={value2} value={value1}
onChange={e => setValue2(e.target.value)} onChange={e => setValue1(e.target.value)}
className={numberClass} className={numberClass}
placeholder="تا" placeholder={needsMax ? 'از' : 'مقدار'}
/> />
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[10px] text-gray-400 pointer-events-none"> <span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[10px] text-gray-400 pointer-events-none">
{sensorUnit} {sensorUnit}
</span> </span>
</div> </div>
) : ( {needsMax ? (
<div className="flex items-center justify-center rounded-xl border border-dashed border-gray-200 bg-gray-50 text-xs text-gray-300 select-none"> <div className="relative">
(فقط یک مقدار) <input
</div> type="number"
)} step="0.1"
</div> dir="ltr"
</SettingField> inputMode="decimal"
value={value2}
onChange={e => setValue2(e.target.value)}
className={numberClass}
placeholder="تا"
/>
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[10px] text-gray-400 pointer-events-none">
{sensorUnit}
</span>
</div>
) : (
<div className="flex items-center justify-center rounded-xl border border-dashed border-gray-200 bg-gray-50 text-xs text-gray-300 select-none">
(فقط یک مقدار)
</div>
)}
</div>
</SettingField>
)}
</div> </div>
{/* Footer */} {/* Footer */}

View File

@@ -17,7 +17,7 @@ function makeRelay(index: number): DeviceRelayDto {
name: '', name: '',
isEnabled: true, isEnabled: true,
relayKind: 0, relayKind: 0,
rules: [], ruleGroups: [],
} }
} }

View File

@@ -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
}

View File

@@ -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
} }