Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/actions/cake/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Run a Cake build target
description: Restore the pinned build tool and run the same compiled target used locally.
inputs:
target:
description: Cake target
required: true
suite:
description: Optional named offline suite
default: ''
framework:
description: Optional framework selector, validated against the suite
default: ''
output:
description: New per-job output directory
default: artifacts/cake-pilot/ci
runs:
using: composite
steps:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore the pinned Cake tool
shell: pwsh
run: dotnet restore build/Build.csproj --locked-mode
- name: Run Cake
shell: pwsh
env:
CAKE_TARGET: ${{ inputs.target }}
CAKE_SUITE: ${{ inputs.suite }}
CAKE_FRAMEWORK: ${{ inputs.framework }}
CAKE_OUTPUT: ${{ inputs.output }}
run: |
$cakeArguments = @('run', '--project', 'build/Build.csproj', '-c', 'Release', '--no-restore', '--', "--target=$env:CAKE_TARGET", "--output=$env:CAKE_OUTPUT")
if ($env:CAKE_SUITE) { $cakeArguments += "--suite=$env:CAKE_SUITE" }
if ($env:CAKE_FRAMEWORK) { $cakeArguments += "--framework=$env:CAKE_FRAMEWORK" }
& dotnet @cakeArguments
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
35 changes: 35 additions & 0 deletions .github/workflows/cake-pilot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Cake Frosting pilot

on:
workflow_dispatch:

permissions:
contents: read

jobs:
offline-pilot:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore the pinned build tool
run: dotnet restore build/Build.csproj --locked-mode
- name: Test artifact comparison safeguards
run: dotnet test build/Tests/Build.Tests.csproj -c Release --logger "trx;LogFileName=build-tests.trx" --results-directory artifacts/cake-build-tests
- name: Build, test, package and verify offline
run: dotnet run --project build/Build.csproj -c Release --no-restore -- --target=Verify --output=artifacts/cake-pilot/ci
- name: Retain pilot results and packages
if: always()
uses: actions/upload-artifact@v4
with:
name: cake-pilot-${{ github.run_id }}-${{ github.run_attempt }}
path: |
artifacts/cake-pilot/ci/*.json
artifacts/cake-pilot/ci/*.log
artifacts/cake-pilot/ci/test-results/*.trx
artifacts/cake-pilot/ci/nuget/*.nupkg
artifacts/cake-pilot/ci/release/*
artifacts/cake-build-tests/*.trx
if-no-files-found: warn
76 changes: 56 additions & 20 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,19 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Test private credential storage and authorization callbacks without live credentials
run: dotnet test TeslaPowerwallLibrary.TestCredentials.Tests/TeslaPowerwallLibrary.TestCredentials.Tests.csproj -c Release --logger "trx;LogFileName=credentials.trx" --results-directory TestResults
- name: Build the Windows setup application
run: dotnet build TeslaPowerwallLibrary.Setup/TeslaPowerwallLibrary.Setup.csproj -c Release
uses: ./.github/actions/cake
with:
target: CredentialTools
suite: credentials
- uses: actions/upload-artifact@v4
if: always()
with:
name: credential-tool-tests
path: TestResults/*.trx
path: |
artifacts/cake-pilot/ci/test-results/*.trx
artifacts/cake-pilot/ci/*.json
artifacts/cake-pilot/ci/*.log
if-no-files-found: warn
test:
runs-on: windows-latest
Expand All @@ -44,18 +45,21 @@ jobs:
project: TeslaPowerwallLibrary.Tests/TeslaPowerwallLibrary.Tests.csproj
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Run unit tests
shell: pwsh
run: dotnet test "${{ matrix.suite.project }}" -c Release -f "${{ matrix.framework }}" --filter "TestCategory!=Live" --logger "trx;LogFileName=tests.trx" --results-directory TestResults
uses: ./.github/actions/cake
with:
target: Suite
suite: ${{ matrix.suite.name }}
framework: ${{ matrix.framework }}
- name: Retain unit-test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-tests-${{ matrix.suite.name }}-${{ matrix.framework }}
path: TestResults/*.trx
path: |
artifacts/cake-pilot/ci/test-results/*.trx
artifacts/cake-pilot/ci/*.json
artifacts/cake-pilot/ci/*.log
if-no-files-found: warn

companion-tests:
Expand All @@ -75,23 +79,55 @@ jobs:
framework: net10.0-windows
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Test companion application offline
run: dotnet test "${{ matrix.project }}" -c Release -f "${{ matrix.framework }}" -p:GenerateDocfxDocumentation=false --logger "trx;LogFileName=tests.trx" --results-directory TestResults
uses: ./.github/actions/cake
with:
target: Suite
suite: ${{ matrix.suite }}
framework: ${{ matrix.framework }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: tests-${{ matrix.suite }}
path: TestResults/*.trx
path: |
artifacts/cake-pilot/ci/test-results/*.trx
artifacts/cake-pilot/ci/*.json
artifacts/cake-pilot/ci/*.log

package-compatibility:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Pack and compare public API with the released baseline
uses: ./.github/actions/cake
with:
target: ReleaseArtifacts
- name: Retain verified local packages and release assets
if: always()
uses: actions/upload-artifact@v4
with:
name: package-compatibility
path: |
artifacts/cake-pilot/ci/nuget/*.nupkg
artifacts/cake-pilot/ci/release/*
artifacts/cake-pilot/ci/*.json
artifacts/cake-pilot/ci/*.log
if-no-files-found: warn

build-tool-tests:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Pack and compare public API with the released baseline
run: dotnet pack TeslaPowerwallLibrary/TeslaPowerwallLibrary.csproj -c Release -p:GenerateDocfxDocumentation=false --output artifacts/nuget
- name: Restore the pinned build tool
run: dotnet restore build/Build.csproj --locked-mode
- name: Test artifact comparison and CI task safeguards
run: dotnet test build/Tests/Build.Tests.csproj -c Release --logger "trx;LogFileName=build-tests.trx" --results-directory artifacts/cake-build-tests
- uses: actions/upload-artifact@v4
if: always()
with:
name: build-tool-tests
path: artifacts/cake-build-tests/*.trx
if-no-files-found: warn
13 changes: 13 additions & 0 deletions DEVELOPMENT-HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Development and validation history

## Cake CI integration — 31 October 2026

- The existing `Unit tests` workflow delegates its original six offline desktop runs to named Cake targets, retaining its job IDs and matrix labels. The credential job retains the setup-application build. Package compatibility now also exercises the five application bundles through an artifact-only target, and a separate job checks build-tool safeguards.
- A local composite action supplies the .NET SDK, locked Cake restore and argument-safe invocation. GitHub still owns triggers, runners, parallelism, permissions and artifacts. The public release workflow, shared validation policy, processor checks and publication locks remain unchanged; the artifact-only Cake target runs no desktop tests and publishes nothing.
- Local CI-selector, zero-test and dependency-graph safeguards passed 19 cases. Hosted validation is recorded below after execution.

## Cake Frosting build pilot — 31 October 2026

- Added an optional compiled build project under `build/`, with pinned Cake.Frosting 6.3.0, for the existing solution, six offline desktop test runs, NuGet package/API validation and five application bundles. Runtime project dependencies, versions and the public release workflow are unchanged.
- The manual-only pilot workflow invokes the same entry point as a local build. It retains command logs, test reports and artifact inventories. Processor reservations, release-validation reuse, publication locking and external publication remain in their existing workflows and tools.
- Optional reference comparison checks every release file and uncompressed archive payload while accounting for ZIP container metadata and generated NuGet OPC identifiers. Ten comparison/version safeguards passed. The final Cake run passed all 1,309 offline cases, SDK package/API validation and five application bundles; all 228 manifest payload entries matched local reference assets generated with the existing release commands after a solution clean. The successful warm-build pipeline took 52.5 seconds on .NET SDK 10.0.403. Evidence is retained under `artifacts/cake-pilot/verified-20261031/` and `artifacts/cake-reference-20261031-clean/`; no hosted CI, live hardware or processor run is claimed.
- Initial comparisons detected differing application dependency metadata. Matching the workflow's unversioned restore removed one cause; an exact publish-command probe and a clean reference build identified cached files in the original warm reference. Earlier failed comparisons remain preserved. No dependency-file differences were normalized away. See `build/README.md` for commands and the comparison limits.

## 2.3.2 release preparation — 30 October 2026

- Implemented directly in the maintained `C:\Projects\TeslaPowerwallLibrary` checkout. Matched the bounded solar/load correction in pypowerwall commit `6133761da20784fb0fe390c00525cef8fb1c6e92`: exact alert, missing/0–10 W controller solar, positive scaled remote solar total and timezone-aware samples within ±60 seconds of gateway time. There is no new last-good-value cache. Complete enabled CTs and unique meters are required; mixed meter families cannot supply a partial solar total.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

For shipped changes, see the [changelog](CHANGELOG.md). Test, CI and build history is recorded separately in [development and validation history](DEVELOPMENT-HISTORY.md).

An optional [Cake Frosting pilot](build/README.md) runs the offline build, tests and packaging locally or through a manual CI workflow.


A .NET client library for the Tesla™ Powerwall™ Energy Gateway, providing classic gateway and TEDAPI local access alongside Tesla Owner and Fleet cloud APIs to status, power flow, energy history, and control operations.

Expand Down
85 changes: 85 additions & 0 deletions build/ArtifactVerification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Neil Colvin. MIT License; see ../TeslaPowerwallLibrary/LICENSE.
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;

public static class ArtifactVerification
{
private static string Hash(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
public static SortedDictionary<string, string> Inventory(string root)
{
var result = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (string folder in new[] { "nuget", "release" })
{
string directory = Path.Combine(root, folder);
if (!Directory.Exists(directory)) throw new DirectoryNotFoundException(directory);
var files = Directory.GetFiles(directory);
if (files.Length == 0) throw new InvalidDataException("Empty artifact directory: " + directory);
foreach (string file in files.Order(StringComparer.Ordinal))
{
string key = folder + "/" + Path.GetFileName(file);
if (file.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))
foreach (var entry in Archive(file)) result.Add(key + "!/" + entry.Key, entry.Value);
else result.Add(key, Hash(File.ReadAllBytes(file)));
}
}
return result;
}
public static SortedDictionary<string, string> Archive(string path)
{
using var archive = ZipFile.OpenRead(path);
var result = new SortedDictionary<string, string>(StringComparer.Ordinal);
var metadata = archive.Entries.Where(e => e.FullName.StartsWith("package/services/metadata/core-properties/", StringComparison.Ordinal) && e.FullName.EndsWith(".psmdcp", StringComparison.Ordinal)).ToArray();
bool nuget = path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase);
if (nuget && metadata.Length != 1) throw new InvalidDataException("Expected one NuGet core-properties part.");
foreach (var entry in archive.Entries.Where(e => e.Name != ""))
{
string name = entry.FullName.Replace('\\', '/');
using var input = entry.Open(); using var buffer = new MemoryStream(); input.CopyTo(buffer);
byte[] bytes = buffer.ToArray();
// NuGet generates OPC relationship IDs and sometimes the metadata filename.
// Retain every relationship's type/target and every core-properties byte.
if (nuget && name == "_rels/.rels")
{
var xml = XDocument.Parse(Encoding.UTF8.GetString(bytes));
foreach (var relation in xml.Root!.Elements())
{
relation.Attribute("Id")?.Remove();
var target = relation.Attribute("Target");
if (target?.Value.TrimStart('/') == metadata[0].FullName) target.Value = "/package/services/metadata/core-properties/nuget.psmdcp";
}
bytes = Encoding.UTF8.GetBytes(xml.ToString(SaveOptions.DisableFormatting));
}
if (nuget && name == metadata[0].FullName) name = "package/services/metadata/core-properties/nuget.psmdcp";
result.Add(name, Hash(bytes));
}
return result;
}
public static string[] Compare(IReadOnlyDictionary<string, string> baseline, IReadOnlyDictionary<string, string> candidate) =>
baseline.Keys.Union(candidate.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal)
.Where(key => !baseline.TryGetValue(key, out string? before) || !candidate.TryGetValue(key, out string? after) || before != after).ToArray();
public static void VerifyPackage(string path, string version, string root)
{
using var archive = ZipFile.OpenRead(path);
var nuspecEntry = archive.GetEntry("TeslaPowerwallLibrary.nuspec") ?? throw new InvalidDataException("Missing nuspec.");
using var input = nuspecEntry.Open(); var nuspec = XDocument.Load(input);
var metadata = nuspec.Root!.Elements().Single(e => e.Name.LocalName == "metadata");
if (metadata.Elements().Single(e => e.Name.LocalName == "id").Value != "TeslaPowerwallLibrary" || metadata.Elements().Single(e => e.Name.LocalName == "version").Value != version)
throw new InvalidDataException("Unexpected package identity.");
if (metadata.Descendants().Where(e => e.Name.LocalName == "dependency").Any(e => ((string?)e.Attribute("id"))?.StartsWith("Cake.", StringComparison.OrdinalIgnoreCase) == true))
throw new InvalidDataException("Build dependency leaked into the runtime package.");
void Exact(string entryName, string source)
{
using var stream = (archive.GetEntry(entryName) ?? throw new InvalidDataException("Missing " + entryName)).Open();
using var buffer = new MemoryStream(); stream.CopyTo(buffer);
if (!buffer.ToArray().SequenceEqual(File.ReadAllBytes(source))) throw new InvalidDataException("Package bytes differ: " + entryName);
}
foreach (string framework in new[] { "net472", "net10.0" })
foreach (string extension in new[] { "dll", "xml" })
Exact($"lib/{framework}/TeslaPowerwallLibrary.{extension}", Path.Combine(root, "TeslaPowerwallLibrary/bin/Release", framework, "TeslaPowerwallLibrary." + extension));
foreach (string document in BuildContext.Documents.Concat(["MIGRATION-SystemTextJson.md"])) Exact(document, Path.Combine(root, document));
Exact("licenses/pypowerwall.txt", Path.Combine(root, "TeslaPowerwallLibrary/Tedapi/Protocol/UPSTREAM-LICENSE.txt"));
// SDK package/API validation remains enabled in the unchanged library project.
}
}
16 changes: 16 additions & 0 deletions build/Build.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cake.Frosting" Version="6.3.0" />
<!-- Cake 6.3.0 requests PKCS 10.0.12; 10.0.13 fixes its published DoS advisories. -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" PrivateAssets="all" />
<Compile Remove="Tests/**/*.cs" />
</ItemGroup>
</Project>
Loading
Loading