Compare commits
2 Commits
559a6e589c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77a7e60ab4 | |||
| f60e85c6a8 |
@@ -1,196 +1,196 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using GreenHome.Application;
|
using GreenHome.Application;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace GreenHome.Api.Controllers;
|
namespace GreenHome.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class TelemetryController : ControllerBase
|
public class TelemetryController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ITelemetryService telemetryService;
|
private readonly ITelemetryService telemetryService;
|
||||||
private readonly IAlertService alertService;
|
private readonly IAlertService alertService;
|
||||||
private readonly IAlertConditionService alertConditionService;
|
private readonly IAlertConditionService alertConditionService;
|
||||||
|
|
||||||
public TelemetryController(ITelemetryService telemetryService, IAlertService alertService, IAlertConditionService alertConditionService)
|
public TelemetryController(ITelemetryService telemetryService, IAlertService alertService, IAlertConditionService alertConditionService)
|
||||||
{
|
{
|
||||||
this.telemetryService = telemetryService;
|
this.telemetryService = telemetryService;
|
||||||
this.alertService = alertService;
|
this.alertService = alertService;
|
||||||
this.alertConditionService = alertConditionService;
|
this.alertConditionService = alertConditionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<TelemetryDto>>> List([FromQuery] int? deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] DateTime? sinceUtc, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
|
public async Task<ActionResult<PagedResult<TelemetryDto>>> List([FromQuery] int? deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] DateTime? sinceUtc, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var filter = new TelemetryFilter { DeviceId = deviceId, StartDateUtc = startUtc, EndDateUtc = endUtc, SinceUtc = sinceUtc, Page = page, PageSize = pageSize };
|
var filter = new TelemetryFilter { DeviceId = deviceId, StartDateUtc = startUtc, EndDateUtc = endUtc, SinceUtc = sinceUtc, Page = page, PageSize = pageSize };
|
||||||
var result = await telemetryService.ListAsync(filter, cancellationToken);
|
var result = await telemetryService.ListAsync(filter, cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("AddData")]
|
[HttpGet("AddData")]
|
||||||
public async Task<ActionResult<string>> Create([FromQuery] TelemetryDto telementry, [FromQuery] string? lastUpdateDate, CancellationToken cancellationToken)
|
public async Task<ActionResult<string>> Create([FromQuery] TelemetryDto telementry, [FromQuery] string? lastUpdateDate, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
telementry.TimestampUtc = DateTime.UtcNow;
|
telementry.TimestampUtc = DateTime.UtcNow;
|
||||||
telementry.RelayStates = ParseRelayStates(Request.Query);
|
telementry.RelayStates = ParseRelayStates(Request.Query);
|
||||||
var id = await telemetryService.AddAsync(telementry, cancellationToken);
|
var id = await telemetryService.AddAsync(telementry, cancellationToken);
|
||||||
|
|
||||||
var parts = new List<string> { $"ID:{id}" };
|
var parts = new List<string> { $"ID:{id}" };
|
||||||
var deviceId = telementry.DeviceId;
|
var deviceId = telementry.DeviceId;
|
||||||
|
|
||||||
if (deviceId > 0)
|
if (deviceId > 0)
|
||||||
{
|
{
|
||||||
// 1. Check alerts and power status
|
// 1. Check alerts and power status
|
||||||
var alertResult = await alertService.CheckAndSendAlertsAsync(deviceId, telementry, cancellationToken);
|
var alertResult = await alertService.CheckAndSendAlertsAsync(deviceId, telementry, cancellationToken);
|
||||||
if (alertResult != null)
|
if (alertResult != null)
|
||||||
{
|
{
|
||||||
if (alertResult.Messages.Count > 0)
|
if (alertResult.Messages.Count > 0)
|
||||||
{
|
{
|
||||||
parts.Add($"A:{alertResult.Messages.Count}");
|
parts.Add($"A:{alertResult.Messages.Count}");
|
||||||
parts.Add($"R:{alertResult.Recipients}");
|
parts.Add($"R:{alertResult.Recipients}");
|
||||||
parts.Add($"M:{string.Join("@", alertResult.Messages)}");
|
parts.Add($"M:{string.Join("@", alertResult.Messages)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (alertResult.PowerStatus != null)
|
if (alertResult.PowerStatus != null)
|
||||||
{
|
{
|
||||||
parts.Add($"P:{alertResult.PowerStatus}");
|
parts.Add($"P:{alertResult.PowerStatus}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check relay conditions update
|
// 2. Check relay conditions update
|
||||||
if (!string.IsNullOrEmpty(lastUpdateDate))
|
if (!string.IsNullOrEmpty(lastUpdateDate))
|
||||||
{
|
{
|
||||||
var parsedDate = ParseLastUpdateDate(lastUpdateDate);
|
var parsedDate = ParseLastUpdateDate(lastUpdateDate);
|
||||||
var relayConditions = await alertConditionService.GetUpdatedRelayConditionsAsync(deviceId, parsedDate, cancellationToken);
|
var relayConditions = await alertConditionService.GetUpdatedRelayConditionsAsync(deviceId, parsedDate, cancellationToken);
|
||||||
if (relayConditions.Count > 0)
|
if (relayConditions.Count > 0)
|
||||||
{
|
{
|
||||||
parts.Add("RU:1");
|
parts.Add("RU:1");
|
||||||
parts.Add($"RL:{relayConditions.Count}");
|
parts.Add($"RL:{relayConditions.Count}");
|
||||||
parts.Add($"RC:{EncodeRelayConditions(relayConditions)}");
|
parts.Add($"RC:{EncodeRelayConditions(relayConditions)}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(string.Join("|", parts));
|
return Ok(string.Join("|", parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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 (relays separated by |):
|
/// Format (relays separated by |):
|
||||||
/// E{enabled}R{relayIndex}{groups}
|
/// E{enabled}R{relayIndex}{groups}
|
||||||
/// Group (OR between groups):
|
/// Group (OR between groups):
|
||||||
/// G{action} where action = 0 (turn on) | 1 (turn off)
|
/// G{action} where action = 0 (turn on) | 1 (turn off)
|
||||||
/// followed by its conditions joined by & (AND inside a group):
|
/// followed by its conditions joined by & (AND inside a group):
|
||||||
/// Sensor condition: S{sensor}O{op}V{value1}[,{value2}]
|
/// Sensor condition: S{sensor}O{op}V{value1}[,{value2}]
|
||||||
/// Time condition: T{start}-{end} (minutes from midnight)
|
/// Time condition: T{start}-{end} (minutes from midnight)
|
||||||
/// Time ranges with start > end cross midnight (e.g. T1200-360 = 20:00..06:00);
|
/// 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).
|
/// 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 groups = new List<string>();
|
var groups = new List<string>();
|
||||||
groups.AddRange(c.On.Select(g => EncodeGroup(g, 0)));
|
groups.AddRange(c.On.Select(g => EncodeGroup(g, 0)));
|
||||||
groups.AddRange(c.Off.Select(g => EncodeGroup(g, 1)));
|
groups.AddRange(c.Off.Select(g => EncodeGroup(g, 1)));
|
||||||
return $"E{c.Enabled}R{c.RelayIndex}{string.Concat(groups)}";
|
return $"E{c.Enabled}R{c.RelayIndex}{string.Concat(groups)}";
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EncodeGroup(RelayGroupDto group, int action)
|
private static string EncodeGroup(RelayGroupDto group, int action)
|
||||||
{
|
{
|
||||||
return $"G{action}{string.Join("&", group.Conditions.Select(EncodeCondition))}";
|
return $"G{action}{string.Join("&", group.Conditions.Select(EncodeCondition))}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EncodeCondition(RelayRuleDto condition)
|
private static string EncodeCondition(RelayRuleDto condition)
|
||||||
{
|
{
|
||||||
if (condition.Kind == 1)
|
if (condition.Kind == 1)
|
||||||
{
|
{
|
||||||
return $"T{condition.Start ?? 0}-{condition.End ?? 0}";
|
return $"T{condition.Start ?? 0}-{condition.End ?? 0}";
|
||||||
}
|
}
|
||||||
|
|
||||||
var encoded = $"S{condition.Sensor}O{condition.Op}V{condition.V1}";
|
var encoded = $"S{condition.Sensor}O{condition.Op}V{condition.V1}";
|
||||||
if (condition.V2.HasValue)
|
if (condition.V2.HasValue)
|
||||||
encoded += $",{condition.V2.Value}";
|
encoded += $",{condition.V2.Value}";
|
||||||
|
|
||||||
return encoded;
|
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>
|
||||||
private static string? ParseRelayStates(IQueryCollection query)
|
private static string? ParseRelayStates(IQueryCollection query)
|
||||||
{
|
{
|
||||||
var entries = new List<(int Index, string Value)>();
|
var entries = new List<(int Index, string Value)>();
|
||||||
foreach (var key in query.Keys)
|
foreach (var key in query.Keys)
|
||||||
{
|
{
|
||||||
var match = Regex.Match(key, @"^r(\d+)$", RegexOptions.IgnoreCase);
|
var match = Regex.Match(key, @"^r(\d+)$", RegexOptions.IgnoreCase);
|
||||||
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var index) || index <= 0)
|
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var index) || index <= 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
entries.Add((index, query[key].ToString() == "1" ? "1" : "0"));
|
entries.Add((index, query[key].ToString() == "1" ? "1" : "0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.Count == 0)
|
if (entries.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var states = new string[entries.Max(e => e.Index)];
|
var states = new string[entries.Max(e => e.Index)];
|
||||||
Array.Fill(states, "0");
|
Array.Fill(states, "0");
|
||||||
foreach (var entry in entries)
|
foreach (var entry in entries)
|
||||||
states[entry.Index - 1] = entry.Value;
|
states[entry.Index - 1] = entry.Value;
|
||||||
|
|
||||||
return string.Join(",", states);
|
return string.Join(",", states);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse date string in format: yyyy/MM/dd-HH:mm
|
/// Parse date string in format: yyyy/MM/dd-HH:mm
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static DateTime? ParseLastUpdateDate(string dateStr)
|
private static DateTime? ParseLastUpdateDate(string dateStr)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(dateStr))
|
if (string.IsNullOrEmpty(dateStr))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// Try parsing as yyyy/MM/dd-HH:mm
|
// Try parsing as yyyy/MM/dd-HH:mm
|
||||||
if (DateTime.TryParseExact(dateStr, "yyyy/MM/dd-HH:mm", null, System.Globalization.DateTimeStyles.None, out var result))
|
if (DateTime.TryParseExact(dateStr, "yyyy/MM/dd-HH:mm", null, System.Globalization.DateTimeStyles.None, out var result))
|
||||||
{
|
{
|
||||||
return DateTime.SpecifyKind(result, DateTimeKind.Utc);
|
return DateTime.SpecifyKind(result, DateTimeKind.Utc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as yyyy-MM-dd HH:mm (ISO-like)
|
// Try parsing as yyyy-MM-dd HH:mm (ISO-like)
|
||||||
if (DateTime.TryParseExact(dateStr, "yyyy-MM-dd HH:mm", null, System.Globalization.DateTimeStyles.None, out var result2))
|
if (DateTime.TryParseExact(dateStr, "yyyy-MM-dd HH:mm", null, System.Globalization.DateTimeStyles.None, out var result2))
|
||||||
{
|
{
|
||||||
return DateTime.SpecifyKind(result2, DateTimeKind.Utc);
|
return DateTime.SpecifyKind(result2, DateTimeKind.Utc);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("minmax")]
|
[HttpGet("minmax")]
|
||||||
public async Task<ActionResult<TelemetryMinMax>> MinMax([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, CancellationToken cancellationToken)
|
public async Task<ActionResult<TelemetryMinMax>> MinMax([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await telemetryService.GetMinMaxAsync(deviceId, startUtc, endUtc, cancellationToken);
|
var result = await telemetryService.GetMinMaxAsync(deviceId, startUtc, endUtc, cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("days")]
|
[HttpGet("days")]
|
||||||
public async Task<ActionResult<IReadOnlyList<DayCount>>> Days([FromQuery] int deviceId, [FromQuery] int year, [FromQuery] int month, CancellationToken cancellationToken)
|
public async Task<ActionResult<IReadOnlyList<DayCount>>> Days([FromQuery] int deviceId, [FromQuery] int year, [FromQuery] int month, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await telemetryService.GetMonthDaysAsync(deviceId, year, month, cancellationToken);
|
var result = await telemetryService.GetMonthDaysAsync(deviceId, year, month, cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("months")]
|
[HttpGet("months")]
|
||||||
public async Task<ActionResult<IReadOnlyList<int>>> Months([FromQuery] int deviceId, [FromQuery] int year, CancellationToken cancellationToken)
|
public async Task<ActionResult<IReadOnlyList<int>>> Months([FromQuery] int deviceId, [FromQuery] int year, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await telemetryService.GetActiveMonthsAsync(deviceId, year, cancellationToken);
|
var result = await telemetryService.GetActiveMonthsAsync(deviceId, year, cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("aggregate")]
|
[HttpGet("aggregate")]
|
||||||
public async Task<ActionResult<IReadOnlyList<TelemetryBucketDto>>> Aggregate([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] int bucketMinutes = 15, CancellationToken cancellationToken = default)
|
public async Task<ActionResult<IReadOnlyList<TelemetryBucketDto>>> Aggregate([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] int bucketMinutes = 15, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var result = await telemetryService.GetAggregatedAsync(deviceId, startUtc, endUtc, bucketMinutes, cancellationToken);
|
var result = await telemetryService.GetAggregatedAsync(deviceId, startUtc, endUtc, bucketMinutes, cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using GreenHome.AI.DeepSeek;
|
using GreenHome.AI.DeepSeek;
|
||||||
using Microsoft.AspNetCore.Mvc.Versioning;
|
|
||||||
using GreenHome.Application;
|
using GreenHome.Application;
|
||||||
using GreenHome.Infrastructure;
|
using GreenHome.Infrastructure;
|
||||||
using GreenHome.Infrastructure.Data;
|
using GreenHome.Infrastructure.Data;
|
||||||
using GreenHome.Sms.Ippanel;
|
using GreenHome.Sms.Ippanel;
|
||||||
using GreenHome.VoiceCall.Avanak;
|
using GreenHome.VoiceCall.Avanak;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Versioning;
|
|
||||||
using Microsoft.AspNetCore.StaticFiles;
|
using Microsoft.AspNetCore.StaticFiles;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.FileProviders;
|
using Microsoft.Extensions.FileProviders;
|
||||||
@@ -45,17 +42,7 @@ app.Run();
|
|||||||
|
|
||||||
void ConfigureCoreServices(WebApplicationBuilder builder)
|
void ConfigureCoreServices(WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
// API versioning: existing controllers stay unversioned (default v1.0, same URLs).
|
builder.Services.AddControllers();
|
||||||
// New v2 controllers use [ApiVersion("2.0")] + [Route("api/v2/[controller]")] (or [Route("api/v2")] prefix).
|
|
||||||
builder.Services
|
|
||||||
.AddApiVersioning(options =>
|
|
||||||
{
|
|
||||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
|
||||||
options.AssumeDefaultVersionWhenUnspecified = true;
|
|
||||||
options.ReportApiVersions = true;
|
|
||||||
options.ApiVersionReader = new UrlSegmentApiVersionReader();
|
|
||||||
})
|
|
||||||
.AddMvc();
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
@@ -123,19 +110,7 @@ void ApplyMigrations(WebApplication app)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var context = services.GetRequiredService<GreenHomeDbContext>();
|
var context = services.GetRequiredService<GreenHomeDbContext>();
|
||||||
var provider = app.Configuration["Database:Provider"] ?? "SqlServer";
|
context.Database.Migrate();
|
||||||
|
|
||||||
if (provider == "Postgres")
|
|
||||||
{
|
|
||||||
// Soft-migration path: Postgres starts empty. EnsureCreated() is
|
|
||||||
// idempotent — it builds the full schema from the model only when no
|
|
||||||
// tables exist yet, and is a no-op once schema + data are in place.
|
|
||||||
context.Database.EnsureCreated();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
context.Database.Migrate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,12 +5,8 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
|
||||||
"Provider": "SqlServer"
|
|
||||||
},
|
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Server=.;Integrated Security=False;Trusted_Connection=True;Encrypt=false;MultipleActiveResultSets=true;Database=GreenHomeDb;",
|
"Default": "Server=.;Integrated Security=False;Trusted_Connection=True;Encrypt=false;MultipleActiveResultSets=true;Database=GreenHomeDb;"
|
||||||
"Postgres": "Host=localhost;Port=5432;Database=greenhome;Username=greenhome;Password=greenhome"
|
|
||||||
},
|
},
|
||||||
"AvanakVoiceCall": {
|
"AvanakVoiceCall": {
|
||||||
"Token": "A948B776B90CFD919B0EC60929714136CCB49DDB"
|
"Token": "A948B776B90CFD919B0EC60929714136CCB49DDB"
|
||||||
|
|||||||
@@ -33,24 +33,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- greenNetwork
|
- greenNetwork
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:18-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: greenhome
|
|
||||||
POSTGRES_USER: greenhome
|
|
||||||
POSTGRES_PASSWORD: greenhome
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
networks:
|
|
||||||
- greenNetwork
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
serviceNetwork:
|
serviceNetwork:
|
||||||
name: greenNetwork
|
name: greenNetwork
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\GreenHome.Infrastructure\GreenHome.Infrastructure.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
using System.Data.Common;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Linq.Expressions;
|
|
||||||
using GreenHome.Infrastructure.Data;
|
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Npgsql;
|
|
||||||
|
|
||||||
namespace GreenHome.DataCopy;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// GreenHome.DataCopy — SQL Server -> PostgreSQL data copy tool
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// dotnet run --project GreenHome.DataCopy (idempotent, resumable)
|
|
||||||
// dotnet run --project GreenHome.DataCopy -- --reset (wipe target tables first)
|
|
||||||
//
|
|
||||||
// Reads from SQL Server (read-only, via EF), writes to PostgreSQL (via EF),
|
|
||||||
// preserving PKs. EF handles all column type conversions (bytea, jsonb, ...).
|
|
||||||
// Safe to re-run after a crash: only missing rows are copied.
|
|
||||||
// Ends with a row-count self-check per table (exit code 1 on mismatch).
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
internal static class Program
|
|
||||||
{
|
|
||||||
const int BatchSize = 5000;
|
|
||||||
|
|
||||||
// Generic copy handlers, dispatched per entity type via reflection.
|
|
||||||
static readonly MethodInfo SingleKeyCopy =
|
|
||||||
typeof(Program).GetMethod(nameof(CopySingleIntKeyTable), BindingFlags.NonPublic | BindingFlags.Static)!;
|
|
||||||
static readonly MethodInfo CompositeKeyCopy =
|
|
||||||
typeof(Program).GetMethod(nameof(CopyCompositeKeyTable), BindingFlags.NonPublic | BindingFlags.Static)!;
|
|
||||||
|
|
||||||
static async Task<int> Main(string[] args)
|
|
||||||
{
|
|
||||||
var config = new ConfigurationBuilder()
|
|
||||||
.SetBasePath(AppContext.BaseDirectory)
|
|
||||||
.AddJsonFile("appsettings.json")
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var sqlServerCs = config.GetConnectionString("SqlServer")
|
|
||||||
?? throw new InvalidOperationException("Missing ConnectionStrings:SqlServer");
|
|
||||||
var pgCs = config.GetConnectionString("Postgres")
|
|
||||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Postgres");
|
|
||||||
var reset = args.Contains("--reset");
|
|
||||||
|
|
||||||
using var source = new GreenHomeDbContext(
|
|
||||||
new DbContextOptionsBuilder<GreenHomeDbContext>().UseSqlServer(sqlServerCs).Options);
|
|
||||||
using var target = new GreenHomeDbContext(
|
|
||||||
new DbContextOptionsBuilder<GreenHomeDbContext>().UseNpgsql(pgCs).Options);
|
|
||||||
|
|
||||||
target.Database.EnsureCreated();
|
|
||||||
|
|
||||||
var entityTypes = target.Model.GetEntityTypes()
|
|
||||||
.Where(e => e.GetTableName() is not null)
|
|
||||||
.Select(e => (Entity: e, Table: e.GetTableName()!))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// --- Topological sort: parents before children (Kahn) ---
|
|
||||||
var deps = entityTypes.ToDictionary(
|
|
||||||
t => t.Table,
|
|
||||||
t => t.Entity.GetForeignKeys()
|
|
||||||
.Select(fk => fk.PrincipalEntityType.GetTableName()!)
|
|
||||||
.Where(pt => pt != t.Table)
|
|
||||||
.ToHashSet());
|
|
||||||
|
|
||||||
var order = new List<(IEntityType Entity, string Table)>();
|
|
||||||
var remaining = deps.ToDictionary(kv => kv.Key, kv => kv.Value.ToHashSet());
|
|
||||||
while (remaining.Count > 0)
|
|
||||||
{
|
|
||||||
var ready = remaining.Where(kv => kv.Value.Count == 0).Select(kv => kv.Key).OrderBy(t => t).ToList();
|
|
||||||
if (ready.Count == 0)
|
|
||||||
ready = [remaining.Keys.OrderBy(t => t).First()]; // cycle fallback (shouldn't happen)
|
|
||||||
foreach (var table in ready)
|
|
||||||
{
|
|
||||||
order.Add(entityTypes.First(e => e.Table == table));
|
|
||||||
remaining.Remove(table);
|
|
||||||
foreach (var kv in remaining) kv.Value.Remove(table);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Optional reset: children first (reverse dependency order) ---
|
|
||||||
if (reset)
|
|
||||||
{
|
|
||||||
foreach (var (_, table) in order.AsEnumerable().Reverse())
|
|
||||||
await target.Database.ExecuteSqlRawAsync($"DELETE FROM \"{table}\"");
|
|
||||||
Console.WriteLine("[reset] all tables cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Copy each table ---
|
|
||||||
foreach (var (entity, table) in order)
|
|
||||||
{
|
|
||||||
var pk = entity.FindPrimaryKey();
|
|
||||||
if (pk is null)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[skip ] {table} (no primary key)");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pk.Properties.Count == 1 && pk.Properties[0].ClrType == typeof(int))
|
|
||||||
await (Task)SingleKeyCopy.MakeGenericMethod(entity.ClrType)
|
|
||||||
.Invoke(null, [source, target, pgCs, entity, table, BatchSize])!;
|
|
||||||
else
|
|
||||||
await (Task)CompositeKeyCopy.MakeGenericMethod(entity.ClrType)
|
|
||||||
.Invoke(null, [source, target, entity, BatchSize])!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Self-check: row counts must match ---
|
|
||||||
Console.WriteLine();
|
|
||||||
var problems = 0;
|
|
||||||
await using var srcConn = new SqlConnection(sqlServerCs);
|
|
||||||
await using var pgConn = new NpgsqlConnection(pgCs);
|
|
||||||
await srcConn.OpenAsync();
|
|
||||||
await pgConn.OpenAsync();
|
|
||||||
|
|
||||||
foreach (var (_, table) in order)
|
|
||||||
{
|
|
||||||
long src = (long)(await ScalarAsync(srcConn, $"SELECT COUNT(*) FROM {table}"))!;
|
|
||||||
long dst = (long)(await ScalarAsync(pgConn, $"SELECT COUNT(*) FROM \"{table}\""))!;
|
|
||||||
if (src != dst) problems++;
|
|
||||||
Console.WriteLine($"{(src == dst ? "OK " : "FAIL ")} {table,-28} source={src,-10} target={dst}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine(problems == 0
|
|
||||||
? "\nSelf-check passed: all tables match."
|
|
||||||
: $"\nSelf-check FAILED: {problems} table(s) mismatched.");
|
|
||||||
return problems == 0 ? 0 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Generic copy handlers
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
static async Task CopySingleIntKeyTable<TEntity>(
|
|
||||||
GreenHomeDbContext source, GreenHomeDbContext target, string pgCs,
|
|
||||||
IEntityType entity, string table, int batchSize) where TEntity : class
|
|
||||||
{
|
|
||||||
var pkName = entity.FindPrimaryKey()!.Properties[0].Name;
|
|
||||||
|
|
||||||
await using var pg = new NpgsqlConnection(pgCs);
|
|
||||||
await pg.OpenAsync();
|
|
||||||
|
|
||||||
// Resume point: only copy rows with PK greater than what target already has
|
|
||||||
var frontier = (int)(await ScalarAsync(pg, $"SELECT COALESCE(MAX(\"{pkName}\"), 0) FROM \"{table}\""))!;
|
|
||||||
long copied = 0;
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var param = Expression.Parameter(typeof(TEntity), "e");
|
|
||||||
var pkProp = Expression.Property(param, pkName);
|
|
||||||
var where = Expression.Lambda<Func<TEntity, bool>>(
|
|
||||||
Expression.GreaterThan(pkProp, Expression.Constant(frontier)), param);
|
|
||||||
var orderBy = Expression.Lambda<Func<TEntity, int>>(pkProp, param);
|
|
||||||
|
|
||||||
var batch = await source.Set<TEntity>().AsNoTracking()
|
|
||||||
.Where(where).OrderBy(orderBy).Take(batchSize).ToListAsync();
|
|
||||||
if (batch.Count == 0) break;
|
|
||||||
|
|
||||||
target.Set<TEntity>().AddRange(batch);
|
|
||||||
await target.SaveChangesAsync();
|
|
||||||
copied += batch.Count;
|
|
||||||
frontier = batch.Max(e => (int)GetProp(e, pkName)!);
|
|
||||||
Console.Write($"\r[copy ] {table}: {copied} rows (pk={frontier}) ");
|
|
||||||
}
|
|
||||||
|
|
||||||
var max = (int)(await ScalarAsync(pg, $"SELECT COALESCE(MAX(\"{pkName}\"), 0) FROM \"{table}\""))!;
|
|
||||||
if (max > 0)
|
|
||||||
{
|
|
||||||
// Keep the identity sequence above the copied max so new rows don't collide
|
|
||||||
await target.Database.ExecuteSqlRawAsync(
|
|
||||||
$"SELECT setval(pg_get_serial_sequence('{table}', '{pkName}'), {max})");
|
|
||||||
}
|
|
||||||
Console.WriteLine($"\n[done ] {table}: copied={copied}, maxId={max}");
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task CopyCompositeKeyTable<TEntity>(
|
|
||||||
GreenHomeDbContext source, GreenHomeDbContext target, IEntityType entity, int batchSize) where TEntity : class
|
|
||||||
{
|
|
||||||
var pkNames = entity.FindPrimaryKey()!.Properties.Select(p => p.Name).ToArray();
|
|
||||||
|
|
||||||
// Composite-PK tables are small (e.g. DeviceUsers): load all source rows,
|
|
||||||
// skip ones already present, write in batches.
|
|
||||||
var existing = new HashSet<string>();
|
|
||||||
foreach (var e in await target.Set<TEntity>().AsNoTracking().ToListAsync())
|
|
||||||
existing.Add(KeyOf(e, pkNames));
|
|
||||||
|
|
||||||
long copied = 0;
|
|
||||||
foreach (var row in await source.Set<TEntity>().AsNoTracking().ToListAsync())
|
|
||||||
{
|
|
||||||
if (existing.Contains(KeyOf(row, pkNames))) continue;
|
|
||||||
target.Set<TEntity>().Add(row);
|
|
||||||
existing.Add(KeyOf(row, pkNames));
|
|
||||||
copied++;
|
|
||||||
if (copied % batchSize == 0) await target.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
await target.SaveChangesAsync();
|
|
||||||
Console.WriteLine($"[done ] {typeof(TEntity).Name} (composite PK): copied={copied}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Plumbing
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
static async Task<object?> ScalarAsync(DbConnection conn, string sql)
|
|
||||||
{
|
|
||||||
await using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = sql;
|
|
||||||
return await cmd.ExecuteScalarAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
static object? GetProp(object entity, string name)
|
|
||||||
=> entity.GetType().GetProperty(name)?.GetValue(entity);
|
|
||||||
|
|
||||||
static string KeyOf(object entity, string[] pkNames)
|
|
||||||
=> string.Join("|", pkNames.Select(n => GetProp(entity, n)?.ToString() ?? "null"));
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"SqlServer": "Server=.;Integrated Security=False;Trusted_Connection=True;Encrypt=false;Database=GreenHomeDb;",
|
|
||||||
"Postgres": "Host=localhost;Port=5432;Database=greenhome;Username=greenhome;Password=greenhome"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ using GreenHome.Infrastructure.Services.Telemetry;
|
|||||||
using GreenHome.Infrastructure.Services.Usage;
|
using GreenHome.Infrastructure.Services.Usage;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace GreenHome.Infrastructure;
|
namespace GreenHome.Infrastructure;
|
||||||
@@ -29,19 +28,9 @@ public static class DependencyInjection
|
|||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Entity Framework
|
// Entity Framework
|
||||||
// Provider is switchable via config ("Database:Provider" = "SqlServer" | "Postgres")
|
|
||||||
// to allow a soft migration: default stays SqlServer until cutover.
|
|
||||||
var provider = configuration["Database:Provider"] ?? "SqlServer";
|
|
||||||
services.AddDbContext<GreenHomeDbContext>(options =>
|
services.AddDbContext<GreenHomeDbContext>(options =>
|
||||||
{
|
{
|
||||||
if (provider == "Postgres")
|
options.UseSqlServer(configuration.GetConnectionString("Default"));
|
||||||
{
|
|
||||||
options.UseNpgsql(configuration.GetConnectionString("Postgres"));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
options.UseSqlServer(configuration.GetConnectionString("Default"));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
Reference in New Issue
Block a user