Released package
Release notes
The full release notes are available at gist.
Change log
Change log in this release:
- 2025-01-12 update assembly version
- 2025-01-08 disable waring CS0419
- 2025-01-04 add SmartMeterDataAggregator.IsRunning
- 2024-12-17 add SmartMeterDataAggregator.HandleAggregationTaskException
- 2024-12-17 avoid refering disconnected smart meter object
- 2024-12-10 rename from Smdn.Net.EchonetLite.RouteB.DataAggregation to Smdn.Net.SmartMeter
API changes
API changes in this release:
diff --git a/doc/api-list/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter-net8.0.apilist.cs b/doc/api-list/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter-net8.0.apilist.cs
new file mode 100644
index 0000000..71ae204
--- /dev/null
+++ b/doc/api-list/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter-net8.0.apilist.cs
@@ -0,0 +1,140 @@
+// Smdn.Net.SmartMeter.dll (Smdn.Net.SmartMeter-2.0.0)
+// Name: Smdn.Net.SmartMeter
+// AssemblyVersion: 2.0.0.0
+// InformationalVersion: 2.0.0+1702d101b7d7da969b9b6258406b4aea5a1b98b4
+// TargetFramework: .NETCoreApp,Version=v8.0
+// Configuration: Release
+// Referenced assemblies:
+// Microsoft.Extensions.DependencyInjection.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
+// Microsoft.Extensions.Logging.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
+// Polly.Core, Version=8.0.0.0, Culture=neutral, PublicKeyToken=c8a3ffc3f8f825cc
+// Smdn.Net.EchonetLite, Version=2.0.0.0, Culture=neutral
+// Smdn.Net.EchonetLite.RouteB, Version=2.0.0.0, Culture=neutral
+// Smdn.Net.EchonetLite.RouteB.Primitives, Version=2.0.0.0, Culture=neutral
+// System.Collections, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
+// System.ComponentModel, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
+// System.ComponentModel.Primitives, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
+// System.Linq, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
+// System.ObjectModel, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
+// System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
+// System.Threading, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
+#nullable enable annotations
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Polly.Registry;
+using Smdn.Net.EchonetLite.RouteB;
+using Smdn.Net.EchonetLite.RouteB.Credentials;
+using Smdn.Net.EchonetLite.RouteB.Transport;
+using Smdn.Net.SmartMeter;
+
+namespace Smdn.Net.SmartMeter {
+ public sealed class CumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ public CumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection) {}
+
+ public override TimeSpan DurationOfMeasurementPeriod { get; }
+ public override DateTime StartOfMeasurementPeriod { get; }
+
+ protected override bool TryGetBaselineValue(bool normalOrReverseDirection, out MeasurementValue<ElectricEnergyValue> @value) {}
+ }
+
+ public sealed class DailyCumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ public DailyCumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection) {}
+
+ public override TimeSpan DurationOfMeasurementPeriod { get; }
+ public override DateTime StartOfMeasurementPeriod { get; }
+ }
+
+ [TupleElementNames(new string[] { "RPhase", "TPhase" })]
+ public class InstantaneousCurrentAggregation : MeasurementValueAggregation<(ElectricCurrentValue, ElectricCurrentValue)> {
+ public static readonly TimeSpan DefaultAggregationInterval; // = "00:01:00"
+
+ public InstantaneousCurrentAggregation() {}
+ public InstantaneousCurrentAggregation(TimeSpan aggregationInterval) {}
+ }
+
+ public class InstantaneousElectricPowerAggregation : MeasurementValueAggregation<int> {
+ public static readonly TimeSpan DefaultAggregationInterval; // = "00:01:00"
+
+ public InstantaneousElectricPowerAggregation() {}
+ public InstantaneousElectricPowerAggregation(TimeSpan aggregationInterval) {}
+ }
+
+ public abstract class MeasurementValueAggregation<TMeasurementValue> : SmartMeterDataAggregation {
+ public TimeSpan AggregationInterval { get; }
+ public DateTime LatestMeasurementTime { get; }
+ public TMeasurementValue LatestValue { get; }
+
+ internal protected virtual void OnLatestValueUpdated() {}
+ }
+
+ public sealed class MonthlyCumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ public MonthlyCumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection) {}
+
+ public override TimeSpan DurationOfMeasurementPeriod { get; }
+ public override DateTime StartOfMeasurementPeriod { get; }
+ }
+
+ public abstract class PeriodicCumulativeElectricEnergyAggregation : SmartMeterDataAggregation {
+ protected PeriodicCumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection) {}
+
+ public bool AggregateNormalDirection { get; }
+ public bool AggregateReverseDirection { get; }
+ public abstract TimeSpan DurationOfMeasurementPeriod { get; }
+ public decimal NormalDirectionValueInKiloWattHours { get; }
+ public decimal ReverseDirectionValueInKiloWattHours { get; }
+ public abstract DateTime StartOfMeasurementPeriod { get; }
+
+ protected virtual void OnNormalDirectionBaselineValueUpdated() {}
+ internal protected virtual void OnNormalDirectionLatestValueUpdated() {}
+ protected virtual void OnNormalDirectionValueChanged() {}
+ protected virtual void OnReverseDirectionBaselineValueUpdated() {}
+ internal protected virtual void OnReverseDirectionLatestValueUpdated() {}
+ protected virtual void OnReverseDirectionValueChanged() {}
+ protected virtual bool TryGetBaselineValue(bool normalOrReverseDirection, out MeasurementValue<ElectricEnergyValue> @value) {}
+ public virtual bool TryGetCumulativeValue(bool normalOrReverseDirection, out decimal valueInKiloWattHours, out DateTime measuredAt) {}
+ }
+
+ public abstract class SmartMeterDataAggregation : INotifyPropertyChanged {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) {}
+ }
+
+ public class SmartMeterDataAggregator : HemsController {
+ public static readonly string ResiliencePipelineKeyForAcquirePropertyValuesForAggregatingData = "SmartMeterDataAggregator.resiliencePipelineAcquirePropertyValuesForAggregatingData";
+ public static readonly string ResiliencePipelineKeyForRunAggregationTask = "SmartMeterDataAggregator.resiliencePipelineRunAggregationTask";
+ public static readonly string ResiliencePipelineKeyForSmartMeterConnection = "SmartMeterDataAggregator.resiliencePipelineConnectToSmartMeter";
+ public static readonly string ResiliencePipelineKeyForSmartMeterPropertyValueReadService = "SmartMeterDataAggregator.ResiliencePipelineReadSmartMeterPropertyValue";
+ public static readonly string ResiliencePipelineKeyForSmartMeterPropertyValueWriteService = "SmartMeterDataAggregator.ResiliencePipelineWriteSmartMeterPropertyValue";
+ public static readonly string ResiliencePipelineKeyForSmartMeterReconnection = "SmartMeterDataAggregator.resiliencePipelineReconnectToSmartMeter";
+ public static readonly string ResiliencePipelineKeyForUpdatePeriodicCumulativeElectricEnergyBaselineValue = "SmartMeterDataAggregator.resiliencePipelineUpdatePeriodicCumulativeElectricEnergyBaselineValue";
+
+ public SmartMeterDataAggregator(IEnumerable<SmartMeterDataAggregation> dataAggregations, IRouteBEchonetLiteHandlerFactory echonetLiteHandlerFactory, IRouteBCredentialProvider routeBCredentialProvider, ResiliencePipelineProvider<string>? resiliencePipelineProvider, ILogger? logger, ILoggerFactory? loggerFactoryForEchonetClient) {}
+ public SmartMeterDataAggregator(IEnumerable<SmartMeterDataAggregation> dataAggregations, IServiceProvider serviceProvider) {}
+
+ public IReadOnlyCollection<SmartMeterDataAggregation> DataAggregations { get; }
+ public bool IsRunning { get; }
+
+ protected override void Dispose(bool disposing) {}
+ protected virtual bool HandleAggregationTaskException(Exception exception) {}
+ public ValueTask StartAsync(CancellationToken cancellationToken = default) {}
+ public async ValueTask StartAsync(TaskFactory? aggregationTaskFactory, CancellationToken cancellationToken = default) {}
+ public async ValueTask StopAsync(CancellationToken cancellationToken) {}
+ }
+
+ public sealed class WeeklyCumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ public WeeklyCumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection, DayOfWeek firstDayOfWeek) {}
+
+ public override TimeSpan DurationOfMeasurementPeriod { get; }
+ public DayOfWeek FirstDayOfWeek { get; }
+ public override DateTime StartOfMeasurementPeriod { get; }
+ }
+}
+// API list generated by Smdn.Reflection.ReverseGenerating.ListApi.MSBuild.Tasks v1.5.0.0.
+// Smdn.Reflection.ReverseGenerating.ListApi.Core v1.3.1.0 (https://github.com/smdn/Smdn.Reflection.ReverseGenerating)
diff --git a/doc/api-list/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter-netstandard2.1.apilist.cs b/doc/api-list/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter-netstandard2.1.apilist.cs
new file mode 100644
index 0000000..158fb84
--- /dev/null
+++ b/doc/api-list/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter-netstandard2.1.apilist.cs
@@ -0,0 +1,134 @@
+// Smdn.Net.SmartMeter.dll (Smdn.Net.SmartMeter-2.0.0)
+// Name: Smdn.Net.SmartMeter
+// AssemblyVersion: 2.0.0.0
+// InformationalVersion: 2.0.0+1702d101b7d7da969b9b6258406b4aea5a1b98b4
+// TargetFramework: .NETStandard,Version=v2.1
+// Configuration: Release
+// Referenced assemblies:
+// Microsoft.Extensions.DependencyInjection.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
+// Microsoft.Extensions.Logging.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
+// Polly.Core, Version=8.0.0.0, Culture=neutral, PublicKeyToken=c8a3ffc3f8f825cc
+// Smdn.Net.EchonetLite, Version=2.0.0.0, Culture=neutral
+// Smdn.Net.EchonetLite.RouteB, Version=2.0.0.0, Culture=neutral
+// Smdn.Net.EchonetLite.RouteB.Primitives, Version=2.0.0.0, Culture=neutral
+// netstandard, Version=2.1.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
+#nullable enable annotations
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Polly.Registry;
+using Smdn.Net.EchonetLite.RouteB;
+using Smdn.Net.EchonetLite.RouteB.Credentials;
+using Smdn.Net.EchonetLite.RouteB.Transport;
+using Smdn.Net.SmartMeter;
+
+namespace Smdn.Net.SmartMeter {
+ public sealed class CumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ public CumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection) {}
+
+ public override TimeSpan DurationOfMeasurementPeriod { get; }
+ public override DateTime StartOfMeasurementPeriod { get; }
+
+ protected override bool TryGetBaselineValue(bool normalOrReverseDirection, out MeasurementValue<ElectricEnergyValue> @value) {}
+ }
+
+ public sealed class DailyCumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ public DailyCumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection) {}
+
+ public override TimeSpan DurationOfMeasurementPeriod { get; }
+ public override DateTime StartOfMeasurementPeriod { get; }
+ }
+
+ [TupleElementNames(new string[] { "RPhase", "TPhase" })]
+ public class InstantaneousCurrentAggregation : MeasurementValueAggregation<(ElectricCurrentValue, ElectricCurrentValue)> {
+ public static readonly TimeSpan DefaultAggregationInterval; // = "00:01:00"
+
+ public InstantaneousCurrentAggregation() {}
+ public InstantaneousCurrentAggregation(TimeSpan aggregationInterval) {}
+ }
+
+ public class InstantaneousElectricPowerAggregation : MeasurementValueAggregation<int> {
+ public static readonly TimeSpan DefaultAggregationInterval; // = "00:01:00"
+
+ public InstantaneousElectricPowerAggregation() {}
+ public InstantaneousElectricPowerAggregation(TimeSpan aggregationInterval) {}
+ }
+
+ public abstract class MeasurementValueAggregation<TMeasurementValue> : SmartMeterDataAggregation {
+ public TimeSpan AggregationInterval { get; }
+ public DateTime LatestMeasurementTime { get; }
+ public TMeasurementValue LatestValue { get; }
+
+ internal protected virtual void OnLatestValueUpdated() {}
+ }
+
+ public sealed class MonthlyCumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ public MonthlyCumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection) {}
+
+ public override TimeSpan DurationOfMeasurementPeriod { get; }
+ public override DateTime StartOfMeasurementPeriod { get; }
+ }
+
+ public abstract class PeriodicCumulativeElectricEnergyAggregation : SmartMeterDataAggregation {
+ protected PeriodicCumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection) {}
+
+ public bool AggregateNormalDirection { get; }
+ public bool AggregateReverseDirection { get; }
+ public abstract TimeSpan DurationOfMeasurementPeriod { get; }
+ public decimal NormalDirectionValueInKiloWattHours { get; }
+ public decimal ReverseDirectionValueInKiloWattHours { get; }
+ public abstract DateTime StartOfMeasurementPeriod { get; }
+
+ protected virtual void OnNormalDirectionBaselineValueUpdated() {}
+ internal protected virtual void OnNormalDirectionLatestValueUpdated() {}
+ protected virtual void OnNormalDirectionValueChanged() {}
+ protected virtual void OnReverseDirectionBaselineValueUpdated() {}
+ internal protected virtual void OnReverseDirectionLatestValueUpdated() {}
+ protected virtual void OnReverseDirectionValueChanged() {}
+ protected virtual bool TryGetBaselineValue(bool normalOrReverseDirection, out MeasurementValue<ElectricEnergyValue> @value) {}
+ public virtual bool TryGetCumulativeValue(bool normalOrReverseDirection, out decimal valueInKiloWattHours, out DateTime measuredAt) {}
+ }
+
+ public abstract class SmartMeterDataAggregation : INotifyPropertyChanged {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) {}
+ }
+
+ public class SmartMeterDataAggregator : HemsController {
+ public static readonly string ResiliencePipelineKeyForAcquirePropertyValuesForAggregatingData = "SmartMeterDataAggregator.resiliencePipelineAcquirePropertyValuesForAggregatingData";
+ public static readonly string ResiliencePipelineKeyForRunAggregationTask = "SmartMeterDataAggregator.resiliencePipelineRunAggregationTask";
+ public static readonly string ResiliencePipelineKeyForSmartMeterConnection = "SmartMeterDataAggregator.resiliencePipelineConnectToSmartMeter";
+ public static readonly string ResiliencePipelineKeyForSmartMeterPropertyValueReadService = "SmartMeterDataAggregator.ResiliencePipelineReadSmartMeterPropertyValue";
+ public static readonly string ResiliencePipelineKeyForSmartMeterPropertyValueWriteService = "SmartMeterDataAggregator.ResiliencePipelineWriteSmartMeterPropertyValue";
+ public static readonly string ResiliencePipelineKeyForSmartMeterReconnection = "SmartMeterDataAggregator.resiliencePipelineReconnectToSmartMeter";
+ public static readonly string ResiliencePipelineKeyForUpdatePeriodicCumulativeElectricEnergyBaselineValue = "SmartMeterDataAggregator.resiliencePipelineUpdatePeriodicCumulativeElectricEnergyBaselineValue";
+
+ public SmartMeterDataAggregator(IEnumerable<SmartMeterDataAggregation> dataAggregations, IRouteBEchonetLiteHandlerFactory echonetLiteHandlerFactory, IRouteBCredentialProvider routeBCredentialProvider, ResiliencePipelineProvider<string>? resiliencePipelineProvider, ILogger? logger, ILoggerFactory? loggerFactoryForEchonetClient) {}
+ public SmartMeterDataAggregator(IEnumerable<SmartMeterDataAggregation> dataAggregations, IServiceProvider serviceProvider) {}
+
+ public IReadOnlyCollection<SmartMeterDataAggregation> DataAggregations { get; }
+ public bool IsRunning { get; }
+
+ protected override void Dispose(bool disposing) {}
+ protected virtual bool HandleAggregationTaskException(Exception exception) {}
+ public ValueTask StartAsync(CancellationToken cancellationToken = default) {}
+ public async ValueTask StartAsync(TaskFactory? aggregationTaskFactory, CancellationToken cancellationToken = default) {}
+ public async ValueTask StopAsync(CancellationToken cancellationToken) {}
+ }
+
+ public sealed class WeeklyCumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ public WeeklyCumulativeElectricEnergyAggregation(bool aggregateNormalDirection, bool aggregateReverseDirection, DayOfWeek firstDayOfWeek) {}
+
+ public override TimeSpan DurationOfMeasurementPeriod { get; }
+ public DayOfWeek FirstDayOfWeek { get; }
+ public override DateTime StartOfMeasurementPeriod { get; }
+ }
+}
+// API list generated by Smdn.Reflection.ReverseGenerating.ListApi.MSBuild.Tasks v1.5.0.0.
+// Smdn.Reflection.ReverseGenerating.ListApi.Core v1.3.1.0 (https://github.com/smdn/Smdn.Reflection.ReverseGenerating)
Full changes
Full changes in this release:
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter.csproj b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter.csproj
new file mode 100644
index 0000000..393a326
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter.csproj
@@ -0,0 +1,63 @@
+<!--
+SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
+SPDX-License-Identifier: MIT
+-->
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFrameworks>netstandard2.1;net8.0</TargetFrameworks>
+ <VersionPrefix>2.0.0</VersionPrefix>
+ <VersionSuffix></VersionSuffix>
+ <!-- <PackageValidationBaselineVersion>2.0.0</PackageValidationBaselineVersion> -->
+ <Nullable>enable</Nullable>
+ <RootNamespace/> <!-- empty the root namespace so that the namespace is determined only by the directory name, for code style rule IDE0030 -->
+ <NoWarn>CS1591;$(NoWarn)</NoWarn> <!-- CS1591: Missing XML comment for publicly visible type or member 'Type_or_Member' -->
+ </PropertyGroup>
+
+ <PropertyGroup Label="assembly attributes">
+ <Description>
+<![CDATA[Provides a class `SmartMeterDataAggregator`, which implements the **HEMS Controller** that periodically collects the data from the **low-voltage smart electric energy meter** via the **route-B**.
+
+「Bルート」を介して「低圧スマート電力量メータ」から定期的なデータ収集を行う「HEMS コントローラ」の実装であるクラス`SmartMeterDataAggregator`を提供します。]]>
+ </Description>
+ <CopyrightYear>2024</CopyrightYear>
+ </PropertyGroup>
+
+ <PropertyGroup Label="package properties">
+ <PackageTags>Route-B;B-Route;smart-meter;smart-energy-meter;HEMS;HEMS-controller;LVSM;$(PackageTags)</PackageTags>
+ <GenerateNupkgReadmeFileDependsOnTargets>$(GenerateNupkgReadmeFileDependsOnTargets);GenerateReadmeFileContent</GenerateNupkgReadmeFileDependsOnTargets>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectOrPackageReference Include="$([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)..\Smdn.Net.EchonetLite.RouteB\Smdn.Net.EchonetLite.RouteB.csproj'))" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Compile Include="$(MSBuildThisFileDirectory)..\Common\Shim\System.Linq\EnumerableChunkShim.cs"/>
+ <Compile Include="$(MSBuildThisFileDirectory)..\Common\Smdn.Net.EchonetLite.ComponentModel\EventInvoker.cs"/>
+ </ItemGroup>
+
+ <Target Name="GenerateReadmeFileContent">
+ <PropertyGroup>
+ <PackageReadmeFileContent><![CDATA[# $(PackageId) $(PackageVersion)
+$(Description)
+
+## Usage
+This is an example that implements a `BackgroundService` that periodically reads and displays the data from the smart meter.
+
+以下のコードでは、スマートメーターから定期的にデータを読み出し、表示する`BackgroundService`を実装します。
+
+```cs
+$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\..\examples\$(PackageId)\GettingStarted\SmartMeterAggregationService.cs').TrimEnd())
+```
+
+The entire code is available on the [GitHub repository]($(RepositoryUrl)/tree/main/examples/$(PackageId)/).
+
+完全なコードは[GitHubリポジトリ]($(RepositoryUrl)/tree/main/examples/$(PackageId)/)を参照してください。
+
+## Contributing
+This project welcomes contributions, feedbacks and suggestions. You can contribute to this project by submitting [Issues]($(RepositoryUrl)/issues/new/choose) or [Pull Requests]($(RepositoryUrl)/pulls/) on the [GitHub repository]($(RepositoryUrl)).
+]]></PackageReadmeFileContent>
+ </PropertyGroup>
+ </Target>
+</Project>
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/CumulativeElectricEnergyAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/CumulativeElectricEnergyAggregation.cs
new file mode 100644
index 0000000..52a05c6
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/CumulativeElectricEnergyAggregation.cs
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+
+using Smdn.Net.EchonetLite.RouteB;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// 定時積算電力量計測値を収集・取得するためのインターフェイスを提供します。
+/// このクラスでは、最新の定時積算電力量計測値(指示値)を収集します。
+/// 基準値および計測期間は定義されないため、<see cref="StartOfMeasurementPeriod"/>および
+/// <see cref="DurationOfMeasurementPeriod"/>は使用されません。
+/// </summary>
+public sealed class CumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ /// <summary>
+ /// 基準値となる積算電力量計測値を計測すべき日付を指定します。
+ /// 計測期間は定義されないため、このクラスでは<see cref="DateTime.MinValue"/>を返します。
+ /// </summary>
+ public override DateTime StartOfMeasurementPeriod => DateTime.MinValue;
+
+ /// <summary>
+ /// 積算電力量を収集・計算するための収集期間の長さを指定します。
+ /// 計測期間は定義されないため、このクラスでは<see cref="TimeSpan.MaxValue"/>を返します。
+ /// </summary>
+ public override TimeSpan DurationOfMeasurementPeriod => TimeSpan.MaxValue;
+
+ public CumulativeElectricEnergyAggregation(
+ bool aggregateNormalDirection,
+ bool aggregateReverseDirection
+ )
+ : base(
+ aggregateNormalDirection: aggregateNormalDirection,
+ aggregateReverseDirection: aggregateReverseDirection
+ )
+ {
+ }
+
+ protected override bool TryGetBaselineValue(
+ bool normalOrReverseDirection,
+ out MeasurementValue<ElectricEnergyValue> value
+ )
+ {
+ value = default; // has no baseline value, so return 0 always
+
+ return true; // has no baseline value, so always claims to be up-to-date always
+ }
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/DailyCumulativeElectricEnergyAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/DailyCumulativeElectricEnergyAggregation.cs
new file mode 100644
index 0000000..2384c37
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/DailyCumulativeElectricEnergyAggregation.cs
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// 日間の積算電力量を収集・取得するためのインターフェイスを提供します。
+/// このクラスでは、ローカル時刻で各日の0時ちょうどにおける積算電力量計測値を基準(0kWh)として、現時点までの積算電力量を計算・取得します。
+/// </summary>
+public sealed class DailyCumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ /// <summary>
+ /// 基準値となる積算電力量計測値を計測すべき日付を指定します。
+ /// このクラスでは、<see cref="DateTime.Today"/>を使用します。
+ /// </summary>
+ public override DateTime StartOfMeasurementPeriod => DateTime.Today; // at 00:00:00.0 AM, everyday
+
+ /// <summary>
+ /// 積算電力量を収集・計算するための収集期間の長さを指定します。
+ /// このクラスでは、<see cref="TimeSpan.TotalDays"/>が<c>1.0</c>となる<see cref="TimeSpan"/>を使用します。
+ /// </summary>
+ public override TimeSpan DurationOfMeasurementPeriod { get; } = TimeSpan.FromDays(1.0);
+
+ public DailyCumulativeElectricEnergyAggregation(
+ bool aggregateNormalDirection,
+ bool aggregateReverseDirection
+ )
+ : base(
+ aggregateNormalDirection: aggregateNormalDirection,
+ aggregateReverseDirection: aggregateReverseDirection
+ )
+ {
+ }
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/IMeasurementValueAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/IMeasurementValueAggregation.cs
new file mode 100644
index 0000000..7305dc4
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/IMeasurementValueAggregation.cs
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+using System.Collections.Generic;
+
+using Smdn.Net.EchonetLite.ObjectModel;
+
+namespace Smdn.Net.SmartMeter;
+
+internal interface IMeasurementValueAggregation {
+ TimeSpan AggregationInterval { get; }
+ IEchonetPropertyAccessor PropertyAccessor { get; }
+
+ void OnLatestValueUpdated();
+ IEnumerable<byte> EnumeratePropertyCodesToAquire();
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/InstantaneousCurrentAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/InstantaneousCurrentAggregation.cs
new file mode 100644
index 0000000..b7bfe11
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/InstantaneousCurrentAggregation.cs
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+
+using Smdn.Net.EchonetLite.ObjectModel;
+using Smdn.Net.EchonetLite.RouteB;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// 瞬時電流計測値を収集・取得するためのインターフェイスを提供します。
+/// </summary>
+/// <remarks>
+/// <see cref="MeasurementValueAggregation{TMeasurementValue}.LatestValue"/>は、
+/// 瞬時電流計測値をR相・T相の順で<see cref="ValueTuple{ElectricCurrentValue,ElectricCurrentValue}"/>で表します。
+/// </remarks>
+/// <seealso cref="Smdn.Net.EchonetLite.RouteB.LowVoltageSmartElectricEnergyMeter.InstantaneousCurrent"/>
+public class InstantaneousCurrentAggregation : MeasurementValueAggregation<(ElectricCurrentValue RPhase, ElectricCurrentValue TPhase)> {
+ public static readonly TimeSpan DefaultAggregationInterval = TimeSpan.FromMinutes(1);
+
+ internal override IEchonetPropertyGetAccessor<(ElectricCurrentValue RPhase, ElectricCurrentValue TPhase)> PropertyAccessor
+ => GetAggregatorOrThrow().SmartMeter.InstantaneousCurrent;
+
+ public InstantaneousCurrentAggregation()
+ : this(DefaultAggregationInterval)
+ {
+ }
+
+ public InstantaneousCurrentAggregation(TimeSpan aggregationInterval)
+ : base(aggregationInterval)
+ {
+ }
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/InstantaneousElectricPowerAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/InstantaneousElectricPowerAggregation.cs
new file mode 100644
index 0000000..b26680f
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/InstantaneousElectricPowerAggregation.cs
@@ -0,0 +1,32 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+
+using Smdn.Net.EchonetLite.ObjectModel;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// 瞬時電力計測値を収集・取得するためのインターフェイスを提供します。
+/// </summary>
+/// <remarks>
+/// <see cref="MeasurementValueAggregation{TMeasurementValue}.LatestValue"/>は、
+/// 瞬時電力計測値を<see cref="int"/>で表します。 単位は「W」です。
+/// </remarks>
+/// <seealso cref="Smdn.Net.EchonetLite.RouteB.LowVoltageSmartElectricEnergyMeter.InstantaneousElectricPower"/>
+public class InstantaneousElectricPowerAggregation : MeasurementValueAggregation<int> {
+ public static readonly TimeSpan DefaultAggregationInterval = TimeSpan.FromMinutes(1);
+
+ internal override IEchonetPropertyGetAccessor<int> PropertyAccessor
+ => GetAggregatorOrThrow().SmartMeter.InstantaneousElectricPower;
+
+ public InstantaneousElectricPowerAggregation()
+ : this(DefaultAggregationInterval)
+ {
+ }
+
+ public InstantaneousElectricPowerAggregation(TimeSpan aggregationInterval)
+ : base(aggregationInterval)
+ {
+ }
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/MeasurementValueAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/MeasurementValueAggregation.cs
new file mode 100644
index 0000000..e258c3d
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/MeasurementValueAggregation.cs
@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+using System.Collections.Generic;
+
+using Smdn.Net.EchonetLite.ObjectModel;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// スマートメーターから現在の計測値を収集・取得するためのインターフェイスを提供します。
+/// </summary>
+/// <typeparam name="TMeasurementValue">このクラスで収集・取得する計測値の型です。</typeparam>
+public abstract class MeasurementValueAggregation<TMeasurementValue> : SmartMeterDataAggregation, IMeasurementValueAggregation {
+ /// <summary>
+ /// 計測値を収集する時間間隔を指定します。
+ /// </summary>
+ /// <remarks>
+ /// この値は、現在保持している計測値を更新するべきかどうか判断するために使用されます。
+ /// 計測値の計測日時からこの時間間隔を経過している場合は、更新を試行します。
+ /// この値で指定された間隔で計測値が更新されることは保証されません。
+ /// </remarks>
+ public TimeSpan AggregationInterval { get; }
+
+ /// <summary>
+ /// 最新の計測値を取得します。
+ /// </summary>
+ /// <exception cref="InvalidOperationException">
+ /// このインスタンスが適切な<see cref="Smdn.Net.EchonetLite.RouteB.HemsController"/>と関連付けられていません。
+ /// もしくは、最新の計測値がまだ取得されていません。
+ /// </exception>
+ public TMeasurementValue LatestValue {
+ get {
+ if (!PropertyAccessor.TryGetValue(out var value))
+ throw new InvalidOperationException("latest value is not yet aggregated or property is not available");
+
+ return value;
+ }
+ }
+
+ public DateTime LatestMeasurementTime {
+ get {
+ if (!PropertyAccessor.IsAvailable)
+ throw new InvalidOperationException("property is not available");
+
+ return PropertyAccessor.BaseProperty.LastUpdatedTime;
+ }
+ }
+
+ IEchonetPropertyAccessor IMeasurementValueAggregation.PropertyAccessor => PropertyAccessor;
+
+ internal abstract IEchonetPropertyGetAccessor<TMeasurementValue> PropertyAccessor { get; }
+
+ private protected MeasurementValueAggregation(TimeSpan aggregationInterval)
+ {
+ if (aggregationInterval <= TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(message: "must be non-zero positive value", actualValue: aggregationInterval, paramName: nameof(aggregationInterval));
+
+ AggregationInterval = aggregationInterval;
+ }
+
+ void IMeasurementValueAggregation.OnLatestValueUpdated()
+ => OnLatestValueUpdated();
+
+ /// <summary>
+ /// 最新の計測値が更新された場合に呼び出されるコールバックメソッドです。
+ /// このメソッドは、最新の計測値が最初に取得された時点、および一定時間おきの取得要求により更新された場合に呼び出されます。
+ /// </summary>
+ protected internal virtual void OnLatestValueUpdated()
+ => OnPropertyChanged(nameof(LatestValue));
+
+ IEnumerable<byte> IMeasurementValueAggregation.EnumeratePropertyCodesToAquire()
+ => EnumeratePropertyCodesToAquire();
+
+ internal IEnumerable<byte> EnumeratePropertyCodesToAquire()
+ {
+ if (PropertyAccessor.HasElapsedSinceLastUpdated(AggregationInterval))
+ yield return PropertyAccessor.PropertyCode;
+ }
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/MonthlyCumulativeElectricEnergyAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/MonthlyCumulativeElectricEnergyAggregation.cs
new file mode 100644
index 0000000..d59dc17
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/MonthlyCumulativeElectricEnergyAggregation.cs
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// 月間の積算電力量を収集・取得するためのインターフェイスを提供します。
+/// このクラスでは、ローカル時刻で月の開始日の0時ちょうどにおける積算電力量計測値を基準(0kWh)として、現時点までの積算電力量を計算・取得します。
+/// </summary>
+public sealed class MonthlyCumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ /// <summary>
+ /// 基準値となる積算電力量計測値を計測すべき日付を指定します。
+ /// このクラスでは、現在の月の最初の日を表す<see cref="DateTime"/>を使用します。
+ /// </summary>
+ public override DateTime StartOfMeasurementPeriod
+ => DateTime.Today.AddDays(1 - DateTime.Today.Day); // at 00:00:00.0 AM, every first day of month
+
+ /// <summary>
+ /// 積算電力量を収集・計算するための収集期間の長さを指定します。
+ /// このクラスでは、<see cref="TimeSpan.TotalDays"/>が現在の月の日数と等しい<see cref="TimeSpan"/>を使用します。
+ /// </summary>
+ public override TimeSpan DurationOfMeasurementPeriod {
+ get {
+ var monthOfMeasurement = StartOfMeasurementPeriod;
+
+ return TimeSpan.FromDays(
+ DateTime.DaysInMonth(
+ year: monthOfMeasurement.Year,
+ month: monthOfMeasurement.Month
+ )
+ );
+ }
+ }
+
+ public MonthlyCumulativeElectricEnergyAggregation(
+ bool aggregateNormalDirection,
+ bool aggregateReverseDirection
+ )
+ : base(
+ aggregateNormalDirection: aggregateNormalDirection,
+ aggregateReverseDirection: aggregateReverseDirection
+ )
+ {
+ }
+
+#if false // TODO: support firstDayOfMonth
+ public MonthlyCumulativeElectricEnergyAggregation(
+ bool aggregateNormalDirection,
+ bool aggregateReverseDirection,
+ int firstDayOfMonth
+ )
+ => throw new NotImplementedException();
+#endif
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/PeriodicCumulativeElectricEnergyAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/PeriodicCumulativeElectricEnergyAggregation.cs
new file mode 100644
index 0000000..8a0605d
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/PeriodicCumulativeElectricEnergyAggregation.cs
@@ -0,0 +1,547 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+#pragma warning disable CA1848 // CA1848: パフォーマンスを向上させるには、LoggerMessage デリゲートを使用します -->
+
+// ref: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-13.0/ref-unsafe-in-iterators-async
+// #define CSHARP_13_REF_UNSAFE_IN_ITERATORS_ASYNC
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.Extensions.Logging;
+
+using Smdn.Net.EchonetLite;
+using Smdn.Net.EchonetLite.ObjectModel;
+using Smdn.Net.EchonetLite.RouteB;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// 一定期間内における積算電力量を収集するための収集期間を定義し、現時点までの積算電力量を取得するためのインターフェイスを提供します。
+/// </summary>
+public abstract class PeriodicCumulativeElectricEnergyAggregation : SmartMeterDataAggregation {
+ /// <summary>
+ /// 計測期間内における現時点までの積算電力量(正方向)の値を取得します。 値の単位は[kWh]です。
+ /// </summary>
+ /// <exception cref="InvalidOperationException">
+ /// このインスタンスが適切な<see cref="HemsController"/>と関連付けられていません。
+ /// もしくは、積算電力の基準値または最新の計測値がまだ取得されていないため、値が計算できません。
+ /// </exception>
+ public decimal NormalDirectionValueInKiloWattHours {
+ get {
+ if (TryGetCumulativeValue(normalOrReverseDirection: true, out var val, out _))
+ return val;
+
+ throw new InvalidOperationException("baseline and/or latest value is not yet aggregated, or is outdated");
+ }
+ }
+
+ /// <summary>
+ /// 計測期間内における現時点までの積算電力量(正方向)の値を取得します。 値の単位は[kWh]です。
+ /// </summary>
+ /// <exception cref="InvalidOperationException">
+ /// このインスタンスが適切な<see cref="HemsController"/>と関連付けられていません。
+ /// もしくは、積算電力の基準値または最新の計測値がまだ取得されていないため、値が計算できません。
+ /// </exception>
+ public decimal ReverseDirectionValueInKiloWattHours {
+ get {
+ if (TryGetCumulativeValue(normalOrReverseDirection: false, out var val, out _))
+ return val;
+
+ throw new InvalidOperationException("baseline and/or latest value is not yet aggregated, or is outdated");
+ }
+ }
+
+ /// <summary>
+ /// 正方向の積算電力量を収集するかどうかを指定する値を取得します。
+ /// </summary>
+ public bool AggregateNormalDirection { get; }
+
+ /// <summary>
+ /// 逆方向の積算電力量を収集するかどうかを指定する値を取得します。
+ /// </summary>
+ public bool AggregateReverseDirection { get; }
+
+ /// <summary>
+ /// 基準値となる積算電力量計測値を計測すべき日付を指定します。
+ /// このプロパティの時刻部分(<see cref="DateTime.TimeOfDay"/>)は常に無視されます。 つまり、常に日付変更直後の日時を表すものとして扱われます。
+ /// </summary>
+ public abstract DateTime StartOfMeasurementPeriod { get; }
+
+ internal DateTime StartDateOfMeasurementPeriod => StartOfMeasurementPeriod.Date; // TODO: support every 30min time
+
+ /// <summary>
+ /// 積算電力量を収集・計算するための収集期間の長さを指定します。
+ /// </summary>
+ /// <remarks>
+ /// <see cref="StartOfMeasurementPeriod"/>から<see cref="DurationOfMeasurementPeriod"/>以上経過している場合、
+ /// 現在保持している基準値は無効になったと判断し、再取得・更新を行います。
+ /// </remarks>
+ public abstract TimeSpan DurationOfMeasurementPeriod { get; }
+
+ /// <summary>
+ /// 現在の計測期間内の基準値(正方向)、つまり<see cref="StartOfMeasurementPeriod"/>における積算電力量計測値(正方向)を取得します。
+ /// </summary>
+ private MeasurementValue<ElectricEnergyValue>? baselineElectricEnergyNormalDirection;
+
+ /// <summary>
+ /// 現在の計測期間内の基準値(逆方向)、つまり<see cref="StartOfMeasurementPeriod"/>における積算電力量計測値(逆方向)を取得します。
+ /// </summary>
+ private MeasurementValue<ElectricEnergyValue>? baselineElectricEnergyReverseDirection;
+
+ /// <param name="aggregateNormalDirection">正方向計測値を収集するかどうかを指定します。</param>
+ /// <param name="aggregateReverseDirection">逆方向計測値を収集するかどうかを指定します。</param>
+ protected PeriodicCumulativeElectricEnergyAggregation(
+ bool aggregateNormalDirection,
+ bool aggregateReverseDirection
+ )
+ {
+ AggregateNormalDirection = aggregateNormalDirection;
+ AggregateReverseDirection = aggregateReverseDirection;
+ }
+
+ /// <summary>
+ /// <see cref="NormalDirectionValueInKiloWattHours"/>の値が更新された場合に呼び出されるコールバックメソッドです。
+ /// このメソッドは、最新の計測値が最初に取得された時点、および一定時間おきの取得要求により更新された場合に呼び出されます。
+ /// </summary>
+ protected virtual void OnNormalDirectionValueChanged()
+ => OnPropertyChanged(propertyName: nameof(NormalDirectionValueInKiloWattHours));
+
+ /// <summary>
+ /// <see cref="ReverseDirectionValueInKiloWattHours"/>の値が更新された場合に呼び出されるコールバックメソッドです。
+ /// このメソッドは、最新の計測値が最初に取得された時点、および一定時間おきの取得要求により更新された場合に呼び出されます。
+ /// </summary>
+ protected virtual void OnReverseDirectionValueChanged()
+ => OnPropertyChanged(propertyName: nameof(ReverseDirectionValueInKiloWattHours));
+
+ /// <summary>
+ /// 最新の計測値(正方向)が更新された場合に呼び出されるコールバックメソッドです。
+ /// このメソッドは、最新の計測値が最初に取得された時点、および一定時間おきの取得要求により更新された場合に呼び出されます。
+ /// </summary>
+ protected internal virtual void OnNormalDirectionLatestValueUpdated()
+ => OnNormalDirectionValueChanged();
+
+ /// <summary>
+ /// 最新の計測値(逆方向)が更新された場合に呼び出されるコールバックメソッドです。
+ /// このメソッドは、最新の計測値が最初に取得された時点、および一定時間おきの取得要求により更新された場合に呼び出されます。
+ /// </summary>
+ protected internal virtual void OnReverseDirectionLatestValueUpdated()
+ => OnReverseDirectionValueChanged();
+
+ /// <summary>
+ /// 計測期間内の基準値(正方向)が更新された場合に呼び出されるコールバックメソッドです。
+ /// このメソッドは、基準値が最初に取得された時点、および以前の基準値が計測期間外となり再取得された場合に呼び出されます。
+ /// </summary>
+ protected virtual void OnNormalDirectionBaselineValueUpdated()
+ => OnNormalDirectionValueChanged();
+
+ /// <summary>
+ /// 計測期間内の基準値(逆方向)が更新された場合に呼び出されるコールバックメソッドです。
+ /// このメソッドは、基準値が最初に取得された時点、および以前の基準値が計測期間外となり再取得された場合に呼び出されます。
+ /// </summary>
+ protected virtual void OnReverseDirectionBaselineValueUpdated()
+ => OnReverseDirectionValueChanged();
+
+ /// <summary>
+ /// 計測期間内の基準値の取得を試みます
+ /// </summary>
+ /// <param name="normalOrReverseDirection">
+ /// <see langword="true"/>の場合は、正方向の積算電力基準値を取得します。
+ /// <see langword="false"/>の場合は、逆方向の積算電力基準値を取得します。
+ /// </param>
+ /// <param name="value">計測期間内の積算電力基準値。</param>
+ /// <returns>
+ /// 取得できた場合は、<see langword="true"/>。
+ /// 基準値がまだ取得されていない、あるいは基準値の取得日時が計測期間外となっていて無効な場合は<see langword="false"/>。
+ /// </returns>
+ protected virtual bool TryGetBaselineValue(
+ bool normalOrReverseDirection,
+ out MeasurementValue<ElectricEnergyValue> value
+ )
+ {
+ value = default;
+
+ var baselineMeasurementValueMayBeNull = normalOrReverseDirection
+ ? baselineElectricEnergyNormalDirection
+ : baselineElectricEnergyReverseDirection;
+
+ if (baselineMeasurementValueMayBeNull is not MeasurementValue<ElectricEnergyValue> baselineMeasurementValue)
+ return false; // baseline value is not aggregated yet
+
+ if (
+ baselineMeasurementValue.MeasuredAt.Date < StartDateOfMeasurementPeriod ||
+ DurationOfMeasurementPeriod <= (baselineMeasurementValue.MeasuredAt.Date - StartDateOfMeasurementPeriod)
+ ) {
+ return false; // baseline value is outdated
+ }
+
+ value = baselineMeasurementValue;
+
+ return true; // baseline value is valid and up-to-date.
+ }
+
+ internal ValueTask<bool> UpdateBaselineValueAsync(
+ ILogger? logger,
+ CancellationToken cancellationToken
+ )
+ {
+ if (!(AggregateNormalDirection || AggregateReverseDirection))
+ return new(false); // nothing to do
+
+ var smartMeter = GetAggregatorOrThrow().SmartMeter;
+
+#if CSHARP_13_REF_UNSAFE_IN_ITERATORS_ASYNC
+ for (var direction = 0; direction <= 1; direction++) { // 0: normal, 1: reverse
+ var doAggregate = direction switch {
+ 0 => AggregateNormalDirection,
+ _ => AggregateReverseDirection,
+ };
+
+ if (!doAggregate)
+ continue; // nothing to do
+
+ var directionalCumulativeElectricEnergyAtEvery30Min = direction == 0
+ ? smartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min
+ : smartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min;
+ ref var directionalBaselineValue = ref (
+ direction == 0
+ ? ref baselineElectricEnergyNormalDirection
+ : ref baselineElectricEnergyReverseDirection
+ );
+
+ if (
+ directionalCumulativeElectricEnergyAtEvery30Min.TryGetValue(out var directionalMeasurementValue) &&
+ directionalMeasurementValue.MeasuredAt == StartDateOfMeasurementPeriod
+ ) {
+ // if the first measurement value of the day is set, use it as the baseline value
+ // in this way, the baseline value can be updated without querying historical data
+ var prevValue = directionalBaselineValue;
+
+ directionalBaselineValue = directionalMeasurementValue;
+
+ if (!prevValue.HasValue || prevValue.Value.MeasuredAt != directionalMeasurementValue.MeasuredAt) {
+ logger?.LogDebug(
+ "{TypeName}.{FieldName}: {Value} ({MeasuredAt}, first measurement value of the day)",
+ GetType().FullName,
+ direction == 0
+ ? nameof(baselineElectricEnergyNormalDirection)
+ : nameof(baselineElectricEnergyReverseDirection),
+ directionalBaselineValue!.Value.Value,
+ directionalBaselineValue!.Value.MeasuredAt
+ );
+
+ if (direction == 0)
+ OnNormalDirectionBaselineValueUpdated();
+ else
+ OnReverseDirectionBaselineValueUpdated();
+ }
+ }
+ }
+#else
+ if (
+ AggregateNormalDirection &&
+ smartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min.TryGetValue(out var normalDirectionValue) &&
+ normalDirectionValue.MeasuredAt == StartDateOfMeasurementPeriod
+ ) {
+ // if the first measurement value of the day is set, use it as the baseline value
+ // in this way, the baseline value can be updated without querying historical data
+ var prevValue = baselineElectricEnergyNormalDirection;
+
+ baselineElectricEnergyNormalDirection = normalDirectionValue;
+
+ if (!prevValue.HasValue || prevValue.Value.MeasuredAt != normalDirectionValue.MeasuredAt) {
+ logger?.LogDebug(
+ "{TypeName}.{FieldName}: {Value} ({MeasuredAt}, first measurement value of the day)",
+ GetType().FullName,
+ nameof(baselineElectricEnergyNormalDirection),
+ baselineElectricEnergyNormalDirection!.Value.Value,
+ baselineElectricEnergyNormalDirection!.Value.MeasuredAt
+ );
+
+ OnNormalDirectionBaselineValueUpdated();
+ }
+ }
+
+ if (
+ AggregateReverseDirection &&
+ smartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min.TryGetValue(out var reverseDirectionValue) &&
+ reverseDirectionValue.MeasuredAt == StartDateOfMeasurementPeriod
+ ) {
+ // if the first measurement value of the day is set, use it as the baseline value
+ // in this way, the baseline value can be updated without querying historical data
+ var prevValue = baselineElectricEnergyReverseDirection;
+
+ baselineElectricEnergyReverseDirection = reverseDirectionValue;
+
+ if (!prevValue.HasValue || prevValue.Value.MeasuredAt != reverseDirectionValue.MeasuredAt) {
+ logger?.LogDebug(
+ "{TypeName}.{FieldName}: {Value} ({MeasuredAt}, first measurement value of the day)",
+ GetType().FullName,
+ nameof(baselineElectricEnergyNormalDirection),
+ baselineElectricEnergyReverseDirection!.Value.Value,
+ baselineElectricEnergyReverseDirection!.Value.MeasuredAt
+ );
+
+ OnReverseDirectionBaselineValueUpdated();
+ }
+ }
+#endif
+
+ var shouldUpdateNormalDirection = AggregateNormalDirection && !TryGetBaselineValue(normalOrReverseDirection: true, out _);
+ var shouldUpdateReverseDirection = AggregateReverseDirection && !TryGetBaselineValue(normalOrReverseDirection: false, out _);
+
+ if (!(shouldUpdateNormalDirection || shouldUpdateReverseDirection))
+ return new(false); // nothing to do
+
+ /*
+ * update the value for the baseline (value for the start date of the specific period)
+ */
+ if (!smartMeter.CurrentDateAndTime.HasValue())
+ throw new InvalidOperationException($"The value for {nameof(smartMeter.CurrentDateAndTime)} have not yet been acquired.");
+
+ // postpones the aggregation until the date setting of
+ // the smart meter becomes the same as the HEMS controller.
+ if (smartMeter.CurrentDateAndTime.Value.Date != DateTime.Today)
+ return new(false); // do nothing
+
+ return UpdateBaselineValueAsyncCore(
+ logger: logger,
+ shouldUpdateNormalDirection: shouldUpdateNormalDirection,
+ shouldUpdateReverseDirection: shouldUpdateReverseDirection,
+ cancellationToken: cancellationToken
+ );
+ }
+
+ private async ValueTask<bool> UpdateBaselineValueAsyncCore(
+ ILogger? logger,
+ bool shouldUpdateNormalDirection,
+ bool shouldUpdateReverseDirection,
+ CancellationToken cancellationToken
+ )
+ {
+ static bool IsFirstMeasurementValueOfDay<TValue>(MeasurementValue<TValue> measurementValue) where TValue : struct
+ => measurementValue.MeasuredAt.TimeOfDay == TimeSpan.Zero; // 00:00:00
+
+ if (!(shouldUpdateNormalDirection || shouldUpdateReverseDirection))
+ return false; // nothing to do
+
+ var aggregator = GetAggregatorOrThrow();
+ var smartMeter = aggregator.SmartMeter;
+
+ // > https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/lvsm/lvsm_aif_ver1.01.pdf
+ // > 低圧スマート電力量メータ・HEMS コントローラ間アプリケーション通信インタフェース仕様書 Version 1.01
+ // > 図 3-6 積算電力量計測値履歴(1 日単位)取得シーケンス例
+
+ // set the day which the querying historical data was measured to the property 0xE5 ...
+ _ = await aggregator.RunWithResponseWaitTimer1Async(
+ asyncAction: async ct => {
+ smartMeter.DayForTheHistoricalDataOfCumulativeElectricEnergy1.Value = StartDateOfMeasurementPeriod;
+
+ return await smartMeter.WritePropertiesAsync(
+ writePropertyCodes: [smartMeter.DayForTheHistoricalDataOfCumulativeElectricEnergy1.PropertyCode],
+ sourceObject: aggregator.Controller,
+ resiliencePipeline: aggregator.ResiliencePipelineWriteSmartMeterPropertyValue,
+ cancellationToken: ct
+ ).ConfigureAwait(false);
+ },
+ messageForTimeoutException: "Timed out while requesting SetC 0xE5.",
+ cancellationToken: cancellationToken
+ ).ConfigureAwait(false);
+
+ // ... then get the historical data (EPC=0xE2 and/or 0xE4)
+#if CSHARP_13_REF_UNSAFE_IN_ITERATORS_ASYNC
+ for (var direction = 0; direction <= 1; direction++) { // 0: normal, 1: reverse
+ var shouldUpdate = direction == 0
+ ? shouldUpdateNormalDirection
+ : shouldUpdateReverseDirection;
+
+ if (!shouldUpdate)
+ continue; // do nothing
+
+ var directionalCumulativeElectricEnergyLog1 = direction == 0
+ ? smartMeter.NormalDirectionCumulativeElectricEnergyLog1
+ : smartMeter.ReverseDirectionCumulativeElectricEnergyLog1;
+
+ // EPC=0xE2/0xE4を要求するため、応答待ちタイマー2を使用する
+ // > https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/lvsm/lvsm_aif_ver1.01.pdf
+ // > 低圧スマート電力量メータ・HEMS コントローラ間アプリケーション通信インタフェース仕様書 Version 1.01
+ // > 2.4.2 応答待ちタイマー
+ _ = await aggregator.RunWithResponseWaitTimer2Async(
+ asyncAction: ct => smartMeter.ReadPropertiesAsync(
+ readPropertyCodes: [directionalCumulativeElectricEnergyLog1.PropertyCode],
+ sourceObject: aggregator.Controller,
+ resiliencePipeline: aggregator.ResiliencePipelineReadSmartMeterPropertyValue,
+ cancellationToken: ct
+ ),
+ messageForTimeoutException: $"Timed out while requesting Get 0x{directionalCumulativeElectricEnergyLog1.PropertyCode:X2}.",
+ cancellationToken: cancellationToken
+ ).ConfigureAwait(false);
+
+ ref var directionalBaselineValue = ref (
+ direction == 0
+ ? ref baselineElectricEnergyNormalDirection
+ : ref baselineElectricEnergyReverseDirection
+ );
+
+ directionalBaselineValue = directionalCumulativeElectricEnergyLog1.Value.First(IsFirstMeasurementValueOfDay);
+
+ logger?.LogDebug(
+ "{TypeName}.{FieldName}: {Value} ({MeasuredAt:s})",
+ GetType().FullName,
+ direction == 0
+ ? nameof(baselineElectricEnergyNormalDirection)
+ : nameof(baselineElectricEnergyReverseDirection),
+ directionalBaselineValue!.Value.Value,
+ directionalBaselineValue!.Value.MeasuredAt
+ );
+
+ if (direction == 0)
+ OnNormalDirectionBaselineValueUpdated();
+ else
+ OnReverseDirectionBaselineValueUpdated();
+
+ if (TryGetCumulativeValue(direction == 0, out var periodicValueInKiloWattHours, out var measuredAt)) {
+ logger?.LogDebug(
+ "{TypeName} ({Direction} direction): {Value} [kWh] ({MeasuredAt:s})",
+ direction == 0 ? "normal" : "reverse",
+ GetType().FullName,
+ periodicValueInKiloWattHours,
+ measuredAt
+ );
+ }
+ }
+#else
+ if (shouldUpdateNormalDirection) {
+ // EPC=0xE2を要求するため、応答待ちタイマー2を使用する
+ // > https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/lvsm/lvsm_aif_ver1.01.pdf
+ // > 低圧スマート電力量メータ・HEMS コントローラ間アプリケーション通信インタフェース仕様書 Version 1.01
+ // > 2.4.2 応答待ちタイマー
+ _ = await aggregator.RunWithResponseWaitTimer2Async(
+ asyncAction: ct => smartMeter.ReadPropertiesAsync(
+ readPropertyCodes: [smartMeter.NormalDirectionCumulativeElectricEnergyLog1.PropertyCode],
+ sourceObject: aggregator.Controller,
+ resiliencePipeline: aggregator.ResiliencePipelineReadSmartMeterPropertyValue,
+ cancellationToken: ct
+ ),
+ messageForTimeoutException: "Timed out while requesting Get 0xE2.",
+ cancellationToken: cancellationToken
+ ).ConfigureAwait(false);
+
+ baselineElectricEnergyNormalDirection = smartMeter.NormalDirectionCumulativeElectricEnergyLog1.Value.First(IsFirstMeasurementValueOfDay);
+
+ logger?.LogDebug(
+ "{TypeName}.{FieldName}: {Value} ({MeasuredAt:s})",
+ GetType().FullName,
+ nameof(baselineElectricEnergyNormalDirection),
+ baselineElectricEnergyNormalDirection!.Value.Value,
+ baselineElectricEnergyNormalDirection!.Value.MeasuredAt
+ );
+
+ OnNormalDirectionBaselineValueUpdated();
+
+ if (TryGetCumulativeValue(normalOrReverseDirection: true, out var periodicValueInKiloWattHours, out var measuredAt)) {
+ logger?.LogDebug(
+ "{TypeName} (normal direction): {Value} [kWh] ({MeasuredAt:s})",
+ GetType().FullName,
+ periodicValueInKiloWattHours,
+ measuredAt
+ );
+ }
+ }
+
+ if (shouldUpdateReverseDirection) {
+ // EPC=0xE4を要求するため、応答待ちタイマー2を使用する
+ // > https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/lvsm/lvsm_aif_ver1.01.pdf
+ // > 低圧スマート電力量メータ・HEMS コントローラ間アプリケーション通信インタフェース仕様書 Version 1.01
+ // > 2.4.2 応答待ちタイマー
+ _ = await aggregator.RunWithResponseWaitTimer2Async(
+ asyncAction: ct => smartMeter.ReadPropertiesAsync(
+ readPropertyCodes: [smartMeter.ReverseDirectionCumulativeElectricEnergyLog1.PropertyCode],
+ sourceObject: aggregator.Controller,
+ resiliencePipeline: aggregator.ResiliencePipelineReadSmartMeterPropertyValue,
+ cancellationToken: ct
+ ),
+ messageForTimeoutException: "Timed out while requesting Get 0xE4.",
+ cancellationToken: cancellationToken
+ ).ConfigureAwait(false);
+
+ baselineElectricEnergyReverseDirection = smartMeter.ReverseDirectionCumulativeElectricEnergyLog1.Value.First(IsFirstMeasurementValueOfDay);
+
+ logger?.LogDebug(
+ "{TypeName}.{FieldName}: {Value} ({MeasuredAt:s})",
+ GetType().FullName,
+ nameof(baselineElectricEnergyReverseDirection),
+ baselineElectricEnergyReverseDirection!.Value.Value,
+ baselineElectricEnergyReverseDirection!.Value.MeasuredAt
+ );
+
+ OnReverseDirectionBaselineValueUpdated();
+
+ if (TryGetCumulativeValue(normalOrReverseDirection: false, out var periodicValueInKiloWattHours, out var measuredAt)) {
+ logger?.LogDebug(
+ "{TypeName} (reverse direction): {Value} [kWh] ({MeasuredAt:s})",
+ GetType().FullName,
+ periodicValueInKiloWattHours,
+ measuredAt
+ );
+ }
+ }
+#endif
+
+ return true;
+ }
+
+ /// <summary>
+ /// <see cref="StartOfMeasurementPeriod"/>で定義される計測期間の開始時点から、現時点までの積算電力量(変化量)の取得を試みます。
+ /// </summary>
+ /// <param name="normalOrReverseDirection">
+ /// <see langword="true"/>の場合は、正方向の積算電力変化量を取得します。
+ /// <see langword="false"/>の場合は、逆方向の積算電力変化量を取得します。
+ /// </param>
+ /// <param name="valueInKiloWattHours">
+ /// 取得できた場合は、現時点までの積算電力変化量を表す値。 値の単位は[kWh]です。
+ /// </param>
+ /// <param name="measuredAt">
+ /// 取得できた場合は、積算電力変化量の計測日時を表す<see cref="DateTime"/>の値。
+ /// </param>
+ /// <returns>
+ /// 現時点までの積算電力変化量が取得できた場合は、<see langword="true"/>。
+ /// 積算電力の基準値または最新の計測値がまだ取得されていないなどの理由で変化量が取得できない場合は、<see langword="false"/>。
+ /// </returns>
+ /// <exception cref="InvalidOperationException">
+ /// このインスタンスが適切な<see cref="HemsController"/>と関連付けられていません。
+ /// このインスタンスは単体で使用することはできないため、<see cref="SmartMeterDataAggregator"/>を使用してください。
+ /// </exception>
+ public virtual bool TryGetCumulativeValue(
+ bool normalOrReverseDirection,
+ out decimal valueInKiloWattHours,
+ out DateTime measuredAt
+ )
+ {
+ var smartMeter = GetAggregatorOrThrow().SmartMeter;
+
+ valueInKiloWattHours = default;
+ measuredAt = default;
+
+ if (!TryGetBaselineValue(normalOrReverseDirection, out var baselineMeasurementValue))
+ return false; // baseline value is not aggregated yet or is outdated already
+
+ var latestMeasurementValue = normalOrReverseDirection
+ ? smartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min.Value
+ : smartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min.Value;
+
+ if (
+ baselineMeasurementValue.Value.TryGetValueAsKiloWattHours(out var kwhBaseline) &&
+ latestMeasurementValue.Value.TryGetValueAsKiloWattHours(out var kwhLatest)
+ ) {
+ valueInKiloWattHours = kwhLatest - kwhBaseline;
+ measuredAt = latestMeasurementValue.MeasuredAt;
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/SmartMeterDataAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/SmartMeterDataAggregation.cs
new file mode 100644
index 0000000..0ddbdc6
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/SmartMeterDataAggregation.cs
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+using Smdn.Net.EchonetLite.ComponentModel;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// スマートメーターから計測値を収集・取得するためのインターフェイスを提供します。
+/// </summary>
+public abstract class SmartMeterDataAggregation : INotifyPropertyChanged {
+ /// <summary>
+ /// スマートメーターから収集した計測値が更新されたときに発生するイベントです。
+ /// </summary>
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ internal SmartMeterDataAggregator? Aggregator { get; set; }
+
+ private protected SmartMeterDataAggregation()
+ {
+ }
+
+ private protected SmartMeterDataAggregator GetAggregatorOrThrow()
+ => Aggregator ?? throw new InvalidOperationException($"Not associated with the appropriate {nameof(SmartMeterDataAggregator)}.");
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ => EventInvoker.Invoke(
+ GetAggregatorOrThrow().SynchronizingObject,
+ this,
+ PropertyChanged,
+ new PropertyChangedEventArgs(propertyName)
+ );
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/SmartMeterDataAggregator.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/SmartMeterDataAggregator.cs
new file mode 100644
index 0000000..cff75cd
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/SmartMeterDataAggregator.cs
@@ -0,0 +1,672 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+#pragma warning disable CA1848 // CA1848: パフォーマンスを向上させるには、LoggerMessage デリゲートを使用します -->
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+using Polly;
+using Polly.Registry;
+
+using Smdn.Net.EchonetLite;
+using Smdn.Net.EchonetLite.ObjectModel;
+using Smdn.Net.EchonetLite.RouteB;
+using Smdn.Net.EchonetLite.RouteB.Credentials;
+using Smdn.Net.EchonetLite.RouteB.Transport;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// スマートメーターに対して定期的なデータ収集を行う<see cref="HemsController"/>を実装します。
+/// </summary>
+/// <remarks>
+/// スマートメーターに対するデータ収集要求は、バックグラウンドで動作するタスクによって非同期的に行われます。
+/// </remarks>
+public class SmartMeterDataAggregator : HemsController {
+ /// <summary>
+ /// スマートメーターへの接続中における例外から回復するために使用される<see cref="ResiliencePipeline"/>と関連付けるキーを表します。
+ /// </summary>
+ public static readonly string ResiliencePipelineKeyForSmartMeterConnection
+ = nameof(SmartMeterDataAggregator) + "." + nameof(resiliencePipelineConnectToSmartMeter);
+
+ /// <summary>
+ /// スマートメーターへの再接続中における例外から回復するために使用される<see cref="ResiliencePipeline"/>と関連付けるキーを表します。
+ /// </summary>
+ public static readonly string ResiliencePipelineKeyForSmartMeterReconnection
+ = nameof(SmartMeterDataAggregator) + "." + nameof(resiliencePipelineReconnectToSmartMeter);
+
+ /// <summary>
+ /// スマートメーターに対する計測値の取得要求中における例外から回復するために使用される<see cref="ResiliencePipeline"/>と関連付けるキーを表します。
+ /// </summary>
+ public static readonly string ResiliencePipelineKeyForAcquirePropertyValuesForAggregatingData
+ = nameof(SmartMeterDataAggregator) + "." + nameof(resiliencePipelineAcquirePropertyValuesForAggregatingData);
+
+ /// <summary>
+ /// スマートメーターに対する積算電力量計測値基準値の取得要求中における例外から回復するために使用される<see cref="ResiliencePipeline"/>と関連付けるキーを表します。
+ /// </summary>
+ public static readonly string ResiliencePipelineKeyForUpdatePeriodicCumulativeElectricEnergyBaselineValue
+ = nameof(SmartMeterDataAggregator) + "." + nameof(resiliencePipelineUpdatePeriodicCumulativeElectricEnergyBaselineValue);
+
+ /// <summary>
+ /// スマートメーターに対するプロパティ値読み出しサービス要求中における例外から回復するために使用される<see cref="ResiliencePipeline"/>と関連付けるキーを表します。
+ /// </summary>
+ public static readonly string ResiliencePipelineKeyForSmartMeterPropertyValueReadService
+ = nameof(SmartMeterDataAggregator) + "." + nameof(ResiliencePipelineReadSmartMeterPropertyValue);
+
+ /// <summary>
+ /// スマートメーターに対するプロパティ値書き込みサービス要求中における例外から回復するために使用される<see cref="ResiliencePipeline"/>と関連付けるキーを表します。
+ /// </summary>
+ public static readonly string ResiliencePipelineKeyForSmartMeterPropertyValueWriteService
+ = nameof(SmartMeterDataAggregator) + "." + nameof(ResiliencePipelineWriteSmartMeterPropertyValue);
+
+ /// <summary>
+ /// データ収集のタスクの実行中における例外から回復するために使用される<see cref="ResiliencePipeline"/>と関連付けるキーを表します。
+ /// </summary>
+ public static readonly string ResiliencePipelineKeyForRunAggregationTask
+ = nameof(SmartMeterDataAggregator) + "." + nameof(resiliencePipelineRunAggregationTask);
+
+ /// <summary>
+ /// 収集する対象のデータを表す<see cref="SmartMeterDataAggregation"/>のコレクションを取得します。
+ /// </summary>
+ public IReadOnlyCollection<SmartMeterDataAggregation> DataAggregations { get; }
+
+ private readonly IReadOnlyCollection<IMeasurementValueAggregation> measurementValueAggregations;
+ private readonly IReadOnlyCollection<PeriodicCumulativeElectricEnergyAggregation> periodicCumulativeElectricEnergyAggregations;
+
+ private readonly bool shouldAggregateCumulativeElectricEnergyNormalDirection;
+ private readonly bool shouldAggregateCumulativeElectricEnergyReverseDirection;
+
+ private Task? aggregationTask;
+ private CancellationTokenSource? aggregationTaskStoppingTokenSource;
+
+#pragma warning disable CS0419
+ /// <summary>
+ /// 定期的にスマートメーターからデータ収集を行うタスクが動作しているかどうかを表す値を返します。
+ /// </summary>
+ /// <seealso cref="StartAsync"/>
+ /// <seealso cref="StopAsync"/>
+#pragma warning restore CS0419
+ public bool IsRunning => aggregationTask is not null;
+
+ private readonly ResiliencePipeline resiliencePipelineConnectToSmartMeter;
+ private readonly ResiliencePipeline resiliencePipelineReconnectToSmartMeter;
+ internal ResiliencePipeline ResiliencePipelineReadSmartMeterPropertyValue { get; }
+ internal ResiliencePipeline ResiliencePipelineWriteSmartMeterPropertyValue { get; }
+ private readonly ResiliencePipeline resiliencePipelineAcquirePropertyValuesForAggregatingData;
+ private readonly ResiliencePipeline resiliencePipelineUpdatePeriodicCumulativeElectricEnergyBaselineValue;
+ private readonly ResiliencePipeline resiliencePipelineRunAggregationTask;
+
+ public SmartMeterDataAggregator(
+ IEnumerable<SmartMeterDataAggregation> dataAggregations,
+ IServiceProvider serviceProvider
+ )
+ : this(
+ dataAggregations: dataAggregations,
+ echonetLiteHandlerFactory: (serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider))).GetRequiredService<IRouteBEchonetLiteHandlerFactory>(),
+ routeBCredentialProvider: serviceProvider.GetRequiredService<IRouteBCredentialProvider>(),
+ resiliencePipelineProvider: serviceProvider.GetService<ResiliencePipelineProvider<string>>(),
+ logger: serviceProvider.GetService<ILoggerFactory>()?.CreateLogger<SmartMeterDataAggregator>(),
+ loggerFactoryForEchonetClient: serviceProvider.GetService<ILoggerFactory>()
+ )
+ {
+ }
+
+ [CLSCompliant(false)] // ResiliencePipelineProvider is not CLS compliant
+ public SmartMeterDataAggregator(
+ IEnumerable<SmartMeterDataAggregation> dataAggregations,
+ IRouteBEchonetLiteHandlerFactory echonetLiteHandlerFactory,
+ IRouteBCredentialProvider routeBCredentialProvider,
+ ResiliencePipelineProvider<string>? resiliencePipelineProvider,
+ ILogger? logger,
+ ILoggerFactory? loggerFactoryForEchonetClient
+ )
+ : base(
+ echonetLiteHandlerFactory: echonetLiteHandlerFactory,
+ routeBCredentialProvider: routeBCredentialProvider,
+ logger: logger,
+ loggerFactoryForEchonetClient: loggerFactoryForEchonetClient
+ )
+ {
+ DataAggregations = dataAggregations.ToArray();
+
+ foreach (var aggregation in DataAggregations) {
+ aggregation.Aggregator = this;
+ }
+
+ measurementValueAggregations = DataAggregations.OfType<IMeasurementValueAggregation>().ToArray();
+ periodicCumulativeElectricEnergyAggregations = DataAggregations.OfType<PeriodicCumulativeElectricEnergyAggregation>().ToArray();
+
+ shouldAggregateCumulativeElectricEnergyNormalDirection
+ = periodicCumulativeElectricEnergyAggregations.Any(static aggr => aggr.AggregateNormalDirection);
+
+ shouldAggregateCumulativeElectricEnergyReverseDirection
+ = periodicCumulativeElectricEnergyAggregations.Any(static aggr => aggr.AggregateReverseDirection);
+
+ ResiliencePipeline? resiliencePipelineConnectToSmartMeter = null;
+ ResiliencePipeline? resiliencePipelineReconnectToSmartMeter = null;
+ ResiliencePipeline? resiliencePipelineReadSmartMeterPropertyValue = null;
+ ResiliencePipeline? resiliencePipelineWriteSmartMeterPropertyValue = null;
+ ResiliencePipeline? resiliencePipelineAcquirePropertyValuesForAggregatingData = null;
+ ResiliencePipeline? resiliencePipelineUpdatePeriodicCumulativeElectricEnergyBaselineValue = null;
+ ResiliencePipeline? resiliencePipelineRunAggregationTask = null;
+
+ _ = resiliencePipelineProvider?.TryGetPipeline(ResiliencePipelineKeyForSmartMeterConnection, out resiliencePipelineConnectToSmartMeter);
+ _ = resiliencePipelineProvider?.TryGetPipeline(ResiliencePipelineKeyForSmartMeterReconnection, out resiliencePipelineReconnectToSmartMeter);
+ _ = resiliencePipelineProvider?.TryGetPipeline(ResiliencePipelineKeyForSmartMeterPropertyValueReadService, out resiliencePipelineReadSmartMeterPropertyValue);
+ _ = resiliencePipelineProvider?.TryGetPipeline(ResiliencePipelineKeyForSmartMeterPropertyValueWriteService, out resiliencePipelineWriteSmartMeterPropertyValue);
+ _ = resiliencePipelineProvider?.TryGetPipeline(ResiliencePipelineKeyForAcquirePropertyValuesForAggregatingData, out resiliencePipelineAcquirePropertyValuesForAggregatingData);
+ _ = resiliencePipelineProvider?.TryGetPipeline(ResiliencePipelineKeyForUpdatePeriodicCumulativeElectricEnergyBaselineValue, out resiliencePipelineUpdatePeriodicCumulativeElectricEnergyBaselineValue);
+ _ = resiliencePipelineProvider?.TryGetPipeline(ResiliencePipelineKeyForRunAggregationTask, out resiliencePipelineRunAggregationTask);
+
+ this.resiliencePipelineConnectToSmartMeter = resiliencePipelineConnectToSmartMeter ?? ResiliencePipeline.Empty;
+ this.resiliencePipelineReconnectToSmartMeter = resiliencePipelineReconnectToSmartMeter ?? ResiliencePipeline.Empty;
+ ResiliencePipelineReadSmartMeterPropertyValue = resiliencePipelineReadSmartMeterPropertyValue ?? ResiliencePipeline.Empty;
+ ResiliencePipelineWriteSmartMeterPropertyValue = resiliencePipelineWriteSmartMeterPropertyValue ?? ResiliencePipeline.Empty;
+ this.resiliencePipelineAcquirePropertyValuesForAggregatingData = resiliencePipelineAcquirePropertyValuesForAggregatingData ?? ResiliencePipeline.Empty;
+ this.resiliencePipelineUpdatePeriodicCumulativeElectricEnergyBaselineValue = resiliencePipelineUpdatePeriodicCumulativeElectricEnergyBaselineValue ?? ResiliencePipeline.Empty;
+ this.resiliencePipelineRunAggregationTask = resiliencePipelineRunAggregationTask ?? ResiliencePipeline.Empty;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ aggregationTask?.Dispose();
+ aggregationTask = null;
+
+ aggregationTaskStoppingTokenSource?.Dispose();
+ aggregationTaskStoppingTokenSource = null!;
+
+ base.Dispose(disposing);
+ }
+
+ private static readonly TaskFactory DefaultAggregationTaskFactory = new(
+ cancellationToken: default,
+ creationOptions: TaskCreationOptions.LongRunning,
+ continuationOptions: TaskContinuationOptions.None,
+ scheduler: null
+ );
+
+ private async ValueTask ConnectToSmartMeterAsync(CancellationToken cancellationToken)
+ {
+ Logger?.LogDebug("Connecting to the smart meter ...");
+
+ await resiliencePipelineConnectToSmartMeter.ExecuteAsync(
+#pragma warning disable IDE0200
+ async ct => await ConnectAsync(
+ resiliencePipelineForServiceRequest: ResiliencePipelineReadSmartMeterPropertyValue,
+ cancellationToken: ct
+ ).ConfigureAwait(false),
+#pragma warning restore IDE0200
+ cancellationToken: cancellationToken
+ ).ConfigureAwait(false);
+
+ Logger?.LogInformation("Connected to the smart meter.");
+ }
+
+ private async ValueTask ReconnectToSmartMeterAsync(CancellationToken cancellationToken)
+ {
+ ThrowIfDisposed();
+
+ Logger?.LogDebug("Disconnecting from the smart meter ...");
+
+ await DisconnectAsync(cancellationToken).ConfigureAwait(false);
+
+ Logger?.LogInformation("Disconnected from the smart meter and reconnecting ...");
+
+ await resiliencePipelineReconnectToSmartMeter.ExecuteAsync(
+#pragma warning disable IDE0200
+ async ct => await ConnectAsync(
+ resiliencePipelineForServiceRequest: ResiliencePipelineReadSmartMeterPropertyValue,
+ cancellationToken: ct
+ ).ConfigureAwait(false),
+#pragma warning restore IDE0200
+ cancellationToken: cancellationToken
+ ).ConfigureAwait(false);
+
+ Logger?.LogInformation("Reconnected to the smart meter.");
+ }
+
+ /// <summary>
+ /// スマートメーターへ接続し、定期的にスマートメーターからデータ収集を行うタスクを起動します。
+ /// </summary>
+ /// <remarks>
+ /// データ収集のタスクは非同期で動作します。 メソッドはタスク起動後に処理を返します。
+ /// データ収集のタスクを停止する場合は、<see cref="StopAsync"/>を呼び出してください。
+ /// </remarks>
+ /// <seealso cref="StopAsync"/>
+ /// <seealso cref="IsRunning"/>
+ public ValueTask StartAsync(
+ CancellationToken cancellationToken = default
+ )
+ => StartAsync(
+ aggregationTaskFactory: DefaultAggregationTaskFactory,
+ cancellationToken: cancellationToken
+ );
+
+ /// <summary>
+ /// スマートメーターへ接続し、定期的にスマートメーターからデータ収集を行うタスクを起動します。
+ /// </summary>
+ /// <remarks>
+ /// データ収集のタスクは非同期で動作します。 メソッドはタスク起動後に処理を返します。
+ /// データ収集のタスクを停止する場合は、<see cref="StopAsync"/>を呼び出してください。
+ /// </remarks>
+ /// <seealso cref="StopAsync"/>
+ /// <seealso cref="IsRunning"/>
+ public async ValueTask StartAsync(
+ TaskFactory? aggregationTaskFactory,
+ CancellationToken cancellationToken = default
+ )
+ {
+ ThrowIfDisposed();
+
+ if (aggregationTask is not null || aggregationTaskStoppingTokenSource is not null)
+ throw new InvalidOperationException("adready started");
+
+ await ConnectToSmartMeterAsync(cancellationToken).ConfigureAwait(false);
+
+ // set event handler to notify updating of latest value
+ SmartMeter.PropertyValueUpdated += HandleSmartMeterPropertyValueUpdated;
+
+ Logger?.LogDebug("Starting data aggregation.");
+
+ aggregationTaskStoppingTokenSource = new();
+
+ aggregationTask = (aggregationTaskFactory ?? Task.Factory).StartNew(
+ action: async state => {
+ var resilienceContext = ResilienceContextPool.Shared.Get(
+ cancellationToken: (CancellationToken)state!
+ );
+
+ try {
+ try {
+ await resiliencePipelineRunAggregationTask.ExecuteAsync(
+ callback: async (context, state) =>
+ await PerformAggregationAsync(
+ state: state,
+ stoppingToken: context.CancellationToken
+ ).ConfigureAwait(false),
+ context: resilienceContext,
+ state: new AggregationTaskState()
+ ).ConfigureAwait(false);
+ }
+ catch (Exception ex) {
+ if (!HandleAggregationTaskException(ex))
+ throw;
+ }
+ }
+ finally {
+ ResilienceContextPool.Shared.Return(resilienceContext);
+ }
+ },
+ state: aggregationTaskStoppingTokenSource.Token,
+ cancellationToken: aggregationTaskStoppingTokenSource.Token
+ );
+
+ Logger?.LogInformation("Started data aggregation.");
+ }
+
+ /// <summary>
+ /// When overridden in a derived class, returns <see langword="true"/> if the exception has been handled,
+ /// or <see langword="false"/> if the exception should be rethrown and the task for receiving stopped.
+ /// </summary>
+ /// <param name="exception">
+ /// The <see cref="Exception"/> the occurred within the task for receiving and which may stop the task.
+ /// </param>
+ /// <returns><see langword="true"/> if the exception has been handled, otherwise <see langword="false"/>.</returns>
+ protected virtual bool HandleAggregationTaskException(Exception exception)
+ {
+ // log and rethrow unhandled exception
+ Logger?.LogCritical(
+ exception: exception,
+ message: "An unhandled exception occured within the aggregation task."
+ );
+
+ return false;
+ }
+
+ private sealed class AggregationTaskState {
+ public bool IsInitialRun { get; set; } = true;
+ }
+
+ private async ValueTask PerformAggregationAsync(
+ AggregationTaskState state,
+ CancellationToken stoppingToken
+ )
+ {
+ if (state.IsInitialRun) {
+ state.IsInitialRun = false;
+ // no need to reconnect since already connected at this point
+ }
+ else {
+ try {
+ // attempt to reconnect and restart aggregation
+ await ReconnectToSmartMeterAsync(
+ cancellationToken: stoppingToken
+ ).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) {
+ // completes aggreagtion without rethrowing exception since a stop request has been made
+ return;
+ }
+ }
+
+ try {
+ await PerformDataAggregationAsync(stoppingToken: stoppingToken).ConfigureAwait(false);
+
+ if (stoppingToken.IsCancellationRequested)
+ // completes aggreagtion since a stop request has been made
+ return;
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) {
+ // completes aggreagtion without rethrowing exception since a stop request has been made
+ return;
+ }
+ }
+
+#pragma warning disable CS0419
+ /// <summary>
+ /// スマートメーターからデータ収集を行うタスクを停止し、スマートメーターから切断します。
+ /// </summary>
+ /// <seealso cref="StartAsync"/>
+ /// <seealso cref="IsRunning"/>
+#pragma warning restore CS0419
+ public async ValueTask StopAsync(
+ CancellationToken cancellationToken
+ )
+ {
+ ThrowIfDisposed();
+
+ if (aggregationTask is null || aggregationTaskStoppingTokenSource is null)
+ throw new InvalidOperationException("not yet started");
+
+#if SYSTEM_THREADING_CANCELLATIONTOKENSOURCE_CANCELASYNC
+ await aggregationTaskStoppingTokenSource.CancelAsync().ConfigureAwait(false);
+#else
+ aggregationTaskStoppingTokenSource.Cancel();
+#endif
+
+ try {
+ try {
+ await aggregationTask.ConfigureAwait(false); // TODO: timeout
+ }
+ catch (OperationCanceledException ex) when (ex.CancellationToken == aggregationTaskStoppingTokenSource.Token) {
+ // expected exception
+ Logger?.LogInformation("Stopped data aggregation.");
+ }
+ catch (AggregateException) {
+ // uncaught or unexpected exception
+ Logger?.LogWarning("Stopped data aggregation by unexpected exception.");
+
+ throw;
+ }
+ }
+ finally {
+ if (IsConnected)
+ SmartMeter.PropertyValueUpdated -= HandleSmartMeterPropertyValueUpdated;
+
+ aggregationTaskStoppingTokenSource.Dispose();
+ aggregationTaskStoppingTokenSource = null;
+ aggregationTask = null;
+
+ await DisconnectAsync(cancellationToken).ConfigureAwait(false);
+
+ Logger?.LogInformation("Disconnected from the smart meter.");
+ }
+ }
+
+ private void HandleSmartMeterPropertyValueUpdated(object? sender, EchonetPropertyValueUpdatedEventArgs e)
+ {
+ foreach (var measurementValueAggregation in measurementValueAggregations) {
+ if (e.Property.Code == measurementValueAggregation.PropertyAccessor.PropertyCode)
+ measurementValueAggregation.OnLatestValueUpdated();
+ }
+
+ if (e.Property.Code == SmartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min.PropertyCode) {
+ foreach (var periodicalAggregation in periodicCumulativeElectricEnergyAggregations) {
+ periodicalAggregation.OnNormalDirectionLatestValueUpdated();
+ }
+ }
+
+ if (e.Property.Code == SmartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min.PropertyCode) {
+ foreach (var periodicalAggregation in periodicCumulativeElectricEnergyAggregations) {
+ periodicalAggregation.OnReverseDirectionLatestValueUpdated();
+ }
+ }
+ }
+
+#pragma warning disable CA1822
+ private IEnumerable<byte> EnumeratePropertyCodesToGet()
+ {
+ foreach (var aggregation in measurementValueAggregations) {
+ foreach (var propertyCode in aggregation.EnumeratePropertyCodesToAquire()) {
+ yield return propertyCode;
+ }
+ }
+
+ if (periodicCumulativeElectricEnergyAggregations.Count <= 0)
+ yield break;
+
+ // acquire the current date and time for the purpose of determining if the date has
+ // changed on both the smart meter and controller side, in updating the
+ // PeriodicyCumulativeElectricEnergy's baseline values
+ if (
+ SmartMeter.CurrentDateAndTime.BaseProperty.LastUpdatedTime.Date != DateTime.Today ||
+ SmartMeter.CurrentDateAndTime.HasElapsedSinceLastUpdated(TimeSpan.FromMinutes(10)) // XXX: best acquiring interval
+ ) {
+ yield return SmartMeter.CurrentDateAndTime.PropertyCode;
+ }
+
+ // > https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/lvsm/lvsm_aif_ver1.01.pdf
+ // > 低圧スマート電力量メータ・HEMS コントローラ間アプリケーション通信インタフェース仕様書 Version 1.01
+ // > 3.2.1 定時積算電力量計測値(30 分値)通知
+ // > スマート電力量メータは、毎時 0 分、30 分から 5 分以内に最新の定時積算電力量計測値(30 分値)を
+ // > HEMS コントローラに通知する。
+ var now = DateTime.Now;
+ var mostRecentMeasurementTime = new DateTime(
+ year: now.Year,
+ month: now.Month,
+ day: now.Day,
+ hour: now.Hour,
+ minute: 30 <= now.Minute ? 30 : 0,
+ second: 0
+ );
+
+ Logger?.LogTrace(
+ "{Name}: {Value:s}",
+ nameof(mostRecentMeasurementTime),
+ mostRecentMeasurementTime
+ );
+
+ // > https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/lvsm/lvsm_aif_ver1.01.pdf
+ // > 低圧スマート電力量メータ・HEMS コントローラ間アプリケーション通信インタフェース仕様書 Version 1.01
+ // > 3.3.1 定時積算電力量計測値(30 分値)取得
+ // > ① HEMS コントローラは、定時積算電力量計測値(30 分値)を受信出来なかった場合、
+ // > 毎時5 分、35 分以降を目安に「定時積算電力量計測値(正方向計測値)」など、
+ // > 必要となるデータを Get[0x62]で要求する。
+ var shouldAcquireCumulativeElectricEnergy = mostRecentMeasurementTime.AddMinutes(5) <= DateTime.Now;
+
+ var shouldAcquireCumulativeElectricEnergyNormalDirection =
+ shouldAggregateCumulativeElectricEnergyNormalDirection &&
+ shouldAcquireCumulativeElectricEnergy &&
+ SmartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min.HasElapsedSinceLastUpdated(mostRecentMeasurementTime);
+
+ var shouldAcquireCumulativeElectricEnergyReverseDirection =
+ shouldAggregateCumulativeElectricEnergyReverseDirection &&
+ shouldAcquireCumulativeElectricEnergy &&
+ SmartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min.HasElapsedSinceLastUpdated(mostRecentMeasurementTime);
+
+ if (shouldAcquireCumulativeElectricEnergyNormalDirection)
+ yield return SmartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min.PropertyCode;
+
+ if (shouldAcquireCumulativeElectricEnergyReverseDirection)
+ yield return SmartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min.PropertyCode;
+ }
+
+ private async ValueTask AcquirePropertyValuesForAggregatingDataAsync(
+ IReadOnlyCollection<byte> propertyCodesToAcquire,
+ CancellationToken stoppingToken
+ )
+ {
+ if (propertyCodesToAcquire.Count <= 0)
+ return; // nothing to do
+
+ if (stoppingToken.IsCancellationRequested) {
+ Logger?.LogDebug("Stopped processing {Method} due to a stop request.", nameof(AcquirePropertyValuesForAggregatingDataAsync));
+ return;
+ }
+
+ // OPC数1の場合は応答待ちタイマー1、OPC数2以上の場合は応答待ちタイマー2を使用する
+ // > https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/lvsm/lvsm_aif_ver1.01.pdf
+ // > 低圧スマート電力量メータ・HEMS コントローラ間アプリケーション通信インタフェース仕様書 Version 1.01
+ // > 2.4.2 応答待ちタイマー
+ Func<
+ Func<CancellationToken, ValueTask<EchonetServiceResponse>>,
+ string?,
+ CancellationToken,
+ ValueTask<EchonetServiceResponse>
+ >
+ runWithResponseWaitTimerAsync =
+ 1 < propertyCodesToAcquire.Count
+ ? RunWithResponseWaitTimer2Async<EchonetServiceResponse>
+ : RunWithResponseWaitTimer1Async<EchonetServiceResponse>;
+
+ // > https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/lvsm/lvsm_aif_ver1.01.pdf
+ // > 低圧スマート電力量メータ・HEMS コントローラ間アプリケーション通信インタフェース仕様書 Version 1.01
+ // > 2.4.4 処理対象プロパティカウンタ(OPC)数
+ // > スマート電力量メータは OPC 数 6 まで、HEMS コントローラは OPC 数 2 まではサポートしなければならない。
+ const int MaxNumberOfPropertyCodesToBeAcquiredAtOnce = 6;
+
+ foreach (var propertyCodes in propertyCodesToAcquire.Chunk(MaxNumberOfPropertyCodesToBeAcquiredAtOnce)) {
+ try {
+ _ = await runWithResponseWaitTimerAsync(
+ /* asyncAction: */ ct => SmartMeter.ReadPropertiesAsync(
+ readPropertyCodes: propertyCodes,
+ sourceObject: Controller,
+ resiliencePipeline: ResiliencePipelineReadSmartMeterPropertyValue,
+ cancellationToken: ct
+ ),
+ /* messageForTimeoutException: */ $"Timed out while processing Get request. (EPC: {string.Join(", ", propertyCodes.Select(static epc => $"0x{epc:X2}"))})",
+ /* cancellationToken: */ stoppingToken
+ ).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) {
+ Logger?.LogDebug("Stopped processing {Method} due to a stop request.", nameof(AcquirePropertyValuesForAggregatingDataAsync));
+ return;
+ }
+ }
+
+ if (Logger is null || !Logger.IsEnabled(LogLevel.Debug))
+ return;
+
+ if (propertyCodesToAcquire.Contains(SmartMeter.InstantaneousElectricPower.PropertyCode)) {
+ Logger.LogDebug(
+ "{Name}: {Value} ({LastUpdatedTime:s})",
+ nameof(SmartMeter.InstantaneousElectricPower),
+ SmartMeter.InstantaneousElectricPower.Value,
+ SmartMeter.InstantaneousElectricPower.BaseProperty.LastUpdatedTime
+ );
+ }
+
+ if (propertyCodesToAcquire.Contains(SmartMeter.InstantaneousCurrent.PropertyCode)) {
+ Logger.LogDebug(
+ "{Name}: {Value} ({LastUpdatedTime:s})",
+ nameof(SmartMeter.InstantaneousCurrent),
+ SmartMeter.InstantaneousCurrent.Value,
+ SmartMeter.InstantaneousCurrent.BaseProperty.LastUpdatedTime
+ );
+ }
+
+ if (propertyCodesToAcquire.Contains(SmartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min.PropertyCode)) {
+ Logger.LogDebug(
+ "{Name}: {Value} ({LastUpdatedTime:s})",
+ nameof(SmartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min),
+ SmartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min.Value,
+ SmartMeter.NormalDirectionCumulativeElectricEnergyAtEvery30Min.BaseProperty.LastUpdatedTime
+ );
+ }
+
+ if (propertyCodesToAcquire.Contains(SmartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min.PropertyCode)) {
+ Logger.LogDebug(
+ "{Name}: {Value} ({LastUpdatedTime:s})",
+ nameof(SmartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min),
+ SmartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min.Value,
+ SmartMeter.ReverseDirectionCumulativeElectricEnergyAtEvery30Min.BaseProperty.LastUpdatedTime
+ );
+ }
+ }
+
+ private async ValueTask PerformDataAggregationAsync(CancellationToken stoppingToken)
+ {
+ var propertyCodesToAcquire = new HashSet<byte>();
+ var isInitialDataAggregation = true;
+
+ while (!stoppingToken.IsCancellationRequested) {
+ if (isInitialDataAggregation)
+ Logger?.LogInformation("Starting initial data aggregation (this may take a few minutes).");
+
+ propertyCodesToAcquire.Clear();
+ propertyCodesToAcquire.UnionWith(EnumeratePropertyCodesToGet());
+
+ if (stoppingToken.IsCancellationRequested)
+ break;
+
+ await resiliencePipelineAcquirePropertyValuesForAggregatingData.ExecuteAsync(
+ ct => AcquirePropertyValuesForAggregatingDataAsync(
+ propertyCodesToAcquire,
+ ct
+ ),
+ cancellationToken: stoppingToken
+ ).ConfigureAwait(false);
+
+ foreach (var periodicalAggregation in periodicCumulativeElectricEnergyAggregations) {
+ if (stoppingToken.IsCancellationRequested)
+ break;
+
+ await resiliencePipelineUpdatePeriodicCumulativeElectricEnergyBaselineValue.ExecuteAsync(
+ ct => periodicalAggregation.UpdateBaselineValueAsync(
+ logger: Logger,
+ cancellationToken: ct
+ ),
+ cancellationToken: stoppingToken
+ ).ConfigureAwait(false);
+ }
+
+ if (stoppingToken.IsCancellationRequested)
+ break;
+
+ if (isInitialDataAggregation) {
+ Logger?.LogInformation("Continuing periodical data aggregation.");
+ isInitialDataAggregation = false;
+ }
+
+ try {
+ await Task
+ .Delay(TimeSpan.FromSeconds(10), stoppingToken) // TODO: make minimal request interval configurable
+#if SYSTEM_THREADING_TASKS_CONFIGUREAWAITOPTIONS
+ .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
+#else
+ .ConfigureAwait(false);
+#endif
+ }
+ catch (OperationCanceledException) {
+#if SYSTEM_THREADING_TASKS_CONFIGUREAWAITOPTIONS
+ throw; // unexpected exception
+#else
+ if (!stoppingToken.IsCancellationRequested)
+ throw; // unexpected exception
+#endif
+ }
+ }
+
+ if (stoppingToken.IsCancellationRequested)
+ Logger?.LogDebug("Stopped processing {Method} due to a stop request.", nameof(PerformDataAggregationAsync));
+
+ Logger?.LogInformation("Stopped data aggregation.");
+ }
+#pragma warning restore CA1822
+}
diff --git a/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/WeeklyCumulativeElectricEnergyAggregation.cs b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/WeeklyCumulativeElectricEnergyAggregation.cs
new file mode 100644
index 0000000..e202384
--- /dev/null
+++ b/src/Smdn.Net.SmartMeter/Smdn.Net.SmartMeter/WeeklyCumulativeElectricEnergyAggregation.cs
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+
+namespace Smdn.Net.SmartMeter;
+
+/// <summary>
+/// 週間の積算電力量を収集・取得するためのインターフェイスを提供します。
+/// このクラスでは、ローカル時刻で週の開始日の0時ちょうどにおける積算電力量計測値を基準(0kWh)として、現時点までの積算電力量を計算・取得します。
+/// </summary>
+public sealed class WeeklyCumulativeElectricEnergyAggregation : PeriodicCumulativeElectricEnergyAggregation {
+ /// <summary>
+ /// 週の開始日の曜日、つまり1週間における最初の日となる曜日を指定します。
+ /// </summary>
+ public DayOfWeek FirstDayOfWeek { get; }
+
+ /// <summary>
+ /// 基準値となる積算電力量計測値を計測すべき日付を指定します。
+ /// このクラスでは、<see cref="DateTime.Today"/>以前で曜日が<see cref="FirstDayOfWeek"/>である最近の日付を使用します。
+ /// </summary>
+ public override DateTime StartOfMeasurementPeriod {
+ get {
+ var startDayOfThisWeek = DateTime.Today;
+
+ for (var i = 1; i < 7; i++) {
+ if (startDayOfThisWeek.DayOfWeek == FirstDayOfWeek)
+ break;
+
+ startDayOfThisWeek = DateTime.Today.AddDays(-i); // 00:00:00.0 AM on a certain day of the week
+ }
+
+ return startDayOfThisWeek;
+ }
+ }
+
+ /// <summary>
+ /// 積算電力量を収集・計算するための収集期間の長さを指定します。
+ /// このクラスでは、<see cref="TimeSpan.TotalDays"/>が<c>7.0</c>となる<see cref="TimeSpan"/>を使用します。
+ /// </summary>
+ public override TimeSpan DurationOfMeasurementPeriod { get; } = TimeSpan.FromDays(7.0);
+
+ public WeeklyCumulativeElectricEnergyAggregation(
+ bool aggregateNormalDirection,
+ bool aggregateReverseDirection,
+ DayOfWeek firstDayOfWeek
+ )
+ : base(
+ aggregateNormalDirection: aggregateNormalDirection,
+ aggregateReverseDirection: aggregateReverseDirection
+ )
+ {
+ FirstDayOfWeek = firstDayOfWeek;
+ }
+}
Notes
Full Changelog: releases/Smdn.Net.EchonetLite.RouteB-2.0.0...releases/Smdn.Net.SmartMeter-2.0.0