From 25892f0df3f1b309eec4f5e2c912a8ebf997f4e9 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 16:21:14 +0200 Subject: [PATCH 01/25] Bring in appleApp and the Apple workflow from #5, drop iosApp Taken as they stand from claude/native-swift-ftw-app-d7j9w4 (dbfce65): appleApp/, .github/workflows/apple.yml, .gitignore and the KMP change that removes the iOS targets. iosApp/ and shared/src/iosMain go, as in #5. protocol/registry.yaml stays as it is on main. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/apple.yml | 65 ++ .gitignore | 7 + .../FTW-iOS.entitlements | 0 appleApp/FTW-macOS.entitlements | 19 + appleApp/FTW.xcodeproj/project.pbxproj | 322 ++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/xcschemes/FTW.xcscheme | 18 +- .../AccentColor.colorset/Contents.json | 38 + .../AppIcon.appiconset/Contents.json | 74 ++ .../AppIcon.appiconset/icon-ios-1024.png | Bin 0 -> 11194 bytes .../AppIcon.appiconset/icon-mac-128@1x.png | Bin 0 -> 3895 bytes .../AppIcon.appiconset/icon-mac-128@2x.png | Bin 0 -> 5938 bytes .../AppIcon.appiconset/icon-mac-16@1x.png | Bin 0 -> 553 bytes .../AppIcon.appiconset/icon-mac-16@2x.png | Bin 0 -> 1080 bytes .../AppIcon.appiconset/icon-mac-256@1x.png | Bin 0 -> 5938 bytes .../AppIcon.appiconset/icon-mac-256@2x.png | Bin 0 -> 11672 bytes .../AppIcon.appiconset/icon-mac-32@1x.png | Bin 0 -> 1080 bytes .../AppIcon.appiconset/icon-mac-32@2x.png | Bin 0 -> 2044 bytes .../AppIcon.appiconset/icon-mac-512@1x.png | Bin 0 -> 11672 bytes .../AppIcon.appiconset/icon-mac-512@2x.png | Bin 0 -> 31447 bytes appleApp/FTW/Assets.xcassets/Contents.json | 6 + appleApp/FTW/FTWApp.swift | 77 ++ appleApp/FTW/Platform/KeychainStore.swift | 67 ++ appleApp/FTW/Platform/NetworkWatch.swift | 29 + appleApp/FTW/Platform/Passkeys.swift | 134 +++ appleApp/FTW/Platform/QRScanner.swift | 211 +++++ appleApp/FTW/UI/BoxView.swift | 353 ++++++++ appleApp/FTW/UI/EVSheet.swift | 584 +++++++++++++ appleApp/FTW/UI/FlowDiagram.swift | 174 ++++ appleApp/FTW/UI/GlanceView.swift | 61 ++ appleApp/FTW/UI/HistoryView.swift | 311 +++++++ appleApp/FTW/UI/LiveSheet.swift | 101 +++ appleApp/FTW/UI/NowView.swift | 285 +++++++ appleApp/FTW/UI/PairView.swift | 204 +++++ appleApp/FTW/UI/PlanView.swift | 204 +++++ appleApp/FTW/UI/PriceChart.swift | 125 +++ appleApp/FTW/UI/RootView.swift | 213 +++++ appleApp/FTW/UI/Theme.swift | 207 +++++ appleApp/FTWKit/Package.resolved | 24 + appleApp/FTWKit/Package.swift | 38 + .../Sources/FTWKit/Carrier/Carrier.swift | 64 ++ .../Sources/FTWKit/Carrier/NoiseCarrier.swift | 233 ++++++ .../Sources/FTWKit/Carrier/RelayCarrier.swift | 235 ++++++ .../Sources/FTWKit/Carrier/Rendezvous.swift | 28 + .../FTWKit/Carrier/URLSessionWebSocket.swift | 114 +++ .../FTWKit/Sources/FTWKit/Crypto/Noise.swift | 337 ++++++++ .../Sources/FTWKit/Crypto/Primitives.swift | 137 ++++ .../Sources/FTWKit/Crypto/Transport.swift | 98 +++ .../Sources/FTWKit/Demo/SimulatedBox.swift | 709 ++++++++++++++++ .../Sources/FTWKit/Demo/SimulatedHouse.swift | 197 +++++ .../FTWKit/Sources/FTWKit/Format/EV.swift | 405 +++++++++ .../Sources/FTWKit/Format/Explanation.swift | 66 ++ .../FTWKit/Sources/FTWKit/Format/Flow.swift | 181 ++++ .../Sources/FTWKit/Format/Freshness.swift | 104 +++ .../Sources/FTWKit/Format/PlanText.swift | 296 +++++++ .../FTWKit/Sources/FTWKit/Format/Power.swift | 124 +++ .../Sources/FTWKit/Format/PriceStrip.swift | 83 ++ .../Sources/FTWKit/Identity/BoxCode.swift | 70 ++ .../Sources/FTWKit/Identity/Enrollment.swift | 150 ++++ .../Sources/FTWKit/Identity/Escrow.swift | 296 +++++++ .../Sources/FTWKit/Identity/Origin.swift | 24 + .../FTWKit/Sources/FTWKit/Identity/PRF.swift | 106 +++ .../Sources/FTWKit/Identity/Pairing.swift | 91 ++ .../FTWKit/Identity/RecoveryBlob.swift | 143 ++++ .../Sources/FTWKit/Identity/Vault.swift | 317 +++++++ .../FTWKit/Sources/FTWKit/Protocol/CBOR.swift | 416 ++++++++++ .../Sources/FTWKit/Protocol/Contract.swift | 108 +++ .../Sources/FTWKit/Protocol/Frame.swift | 139 ++++ .../FTWKit/Protocol/HistoryGeometry.swift | 184 +++++ .../Sources/FTWKit/Protocol/Messages.swift | 531 ++++++++++++ .../Sources/FTWKit/Protocol/Session.swift | 775 ++++++++++++++++++ .../Sources/FTWKit/State/AppModel.swift | 270 ++++++ .../FTWKit/Sources/FTWKit/State/BoxAPI.swift | 158 ++++ .../Sources/FTWKit/State/BoxModels.swift | 335 ++++++++ .../Sources/FTWKit/State/EnergyHistory.swift | 288 +++++++ .../FTWKit/State/LoadpointsModel.swift | 347 ++++++++ .../Sources/FTWKit/State/NowModels.swift | 295 +++++++ .../Sources/FTWKit/State/PairModel.swift | 134 +++ .../Sources/FTWKit/State/PlanModel.swift | 152 ++++ .../FTWKit/State/SealedCopyModel.swift | 64 ++ .../Sources/FTWKit/State/SiteModel.swift | 330 ++++++++ .../Sources/FTWKit/Store/SealedFiles.swift | 138 ++++ .../Sources/FTWKit/Store/SecureStore.swift | 40 + .../FTWKit/Sources/FTWKit/Store/Sites.swift | 99 +++ .../Sources/FTWKit/Support/Base64url.swift | 84 ++ .../FTWKit/Sources/FTWKit/Support/Bytes.swift | 82 ++ .../FTWKit/Sources/FTWKit/Support/JSON.swift | 233 ++++++ .../Sources/FTWKit/Support/Scheduler.swift | 103 +++ .../Tests/FTWKitTests/AppModelTests.swift | 331 ++++++++ appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift | 83 ++ .../Tests/FTWKitTests/FormatTests.swift | 200 +++++ .../FTWKit/Tests/FTWKitTests/FrameTests.swift | 120 +++ .../Tests/FTWKitTests/IdentityTests.swift | 350 ++++++++ .../Tests/FTWKitTests/LiveBoxTests.swift | 99 +++ .../FTWKit/Tests/FTWKitTests/Loopback.swift | 179 ++++ .../FTWKit/Tests/FTWKitTests/NoiseTests.swift | 182 ++++ .../Tests/FTWKitTests/PlainWebSocket.swift | 181 ++++ .../Resources/identity-vectors.json | 64 ++ .../FTWKitTests/Resources/webapp-vectors.json | 163 ++++ .../Tests/FTWKitTests/SessionTests.swift | 369 +++++++++ .../FTWKit/Tests/FTWKitTests/Support.swift | 69 ++ appleApp/scripts/demo-screenshots.sh | 37 + iosApp/iosApp.xcodeproj/project.pbxproj | 296 ------- iosApp/iosApp/AppModel.swift | 136 --- iosApp/iosApp/FTWApp.swift | 43 - iosApp/iosApp/Info.plist | 36 - iosApp/iosApp/KeychainStore.swift | 59 -- iosApp/iosApp/NowView.swift | 42 - iosApp/iosApp/PairView.swift | 66 -- iosApp/iosApp/PasskeyHost.swift | 150 ---- iosApp/iosApp/QRScanner.swift | 55 -- shared/build.gradle.kts | 13 +- .../kotlin/energy/ftw/carrier/IosSockets.kt | 75 -- .../kotlin/energy/ftw/carrier/Retry.ios.kt | 11 - .../kotlin/energy/ftw/carrier/Time.ios.kt | 6 - .../kotlin/energy/ftw/crypto/Random.ios.kt | 16 - 116 files changed, 16291 insertions(+), 1011 deletions(-) create mode 100644 .github/workflows/apple.yml rename iosApp/iosApp/iosApp.entitlements => appleApp/FTW-iOS.entitlements (100%) create mode 100644 appleApp/FTW-macOS.entitlements create mode 100644 appleApp/FTW.xcodeproj/project.pbxproj create mode 100644 appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme => appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme (82%) create mode 100644 appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png create mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png create mode 100644 appleApp/FTW/Assets.xcassets/Contents.json create mode 100644 appleApp/FTW/FTWApp.swift create mode 100644 appleApp/FTW/Platform/KeychainStore.swift create mode 100644 appleApp/FTW/Platform/NetworkWatch.swift create mode 100644 appleApp/FTW/Platform/Passkeys.swift create mode 100644 appleApp/FTW/Platform/QRScanner.swift create mode 100644 appleApp/FTW/UI/BoxView.swift create mode 100644 appleApp/FTW/UI/EVSheet.swift create mode 100644 appleApp/FTW/UI/FlowDiagram.swift create mode 100644 appleApp/FTW/UI/GlanceView.swift create mode 100644 appleApp/FTW/UI/HistoryView.swift create mode 100644 appleApp/FTW/UI/LiveSheet.swift create mode 100644 appleApp/FTW/UI/NowView.swift create mode 100644 appleApp/FTW/UI/PairView.swift create mode 100644 appleApp/FTW/UI/PlanView.swift create mode 100644 appleApp/FTW/UI/PriceChart.swift create mode 100644 appleApp/FTW/UI/RootView.swift create mode 100644 appleApp/FTW/UI/Theme.swift create mode 100644 appleApp/FTWKit/Package.resolved create mode 100644 appleApp/FTWKit/Package.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/Rendezvous.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/URLSessionWebSocket.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Crypto/Noise.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Crypto/Primitives.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedHouse.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/EV.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/Power.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/PriceStrip.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Protocol/CBOR.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Protocol/Frame.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Support/Base64url.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Support/Bytes.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Support/Scheduler.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/FrameTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Resources/identity-vectors.json create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Resources/webapp-vectors.json create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Support.swift create mode 100755 appleApp/scripts/demo-screenshots.sh delete mode 100644 iosApp/iosApp.xcodeproj/project.pbxproj delete mode 100644 iosApp/iosApp/AppModel.swift delete mode 100644 iosApp/iosApp/FTWApp.swift delete mode 100644 iosApp/iosApp/Info.plist delete mode 100644 iosApp/iosApp/KeychainStore.swift delete mode 100644 iosApp/iosApp/NowView.swift delete mode 100644 iosApp/iosApp/PairView.swift delete mode 100644 iosApp/iosApp/PasskeyHost.swift delete mode 100644 iosApp/iosApp/QRScanner.swift delete mode 100644 shared/src/iosMain/kotlin/energy/ftw/carrier/IosSockets.kt delete mode 100644 shared/src/iosMain/kotlin/energy/ftw/carrier/Retry.ios.kt delete mode 100644 shared/src/iosMain/kotlin/energy/ftw/carrier/Time.ios.kt delete mode 100644 shared/src/iosMain/kotlin/energy/ftw/crypto/Random.ios.kt diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml new file mode 100644 index 0000000..1de09e0 --- /dev/null +++ b/.github/workflows/apple.yml @@ -0,0 +1,65 @@ +name: Apple + +on: + push: + branches: [main] + paths: ['appleApp/**', 'protocol/**', '.github/workflows/apple.yml'] + pull_request: + paths: ['appleApp/**', 'protocol/**', '.github/workflows/apple.yml'] + +concurrency: + group: apple-${{ github.ref }} + cancel-in-progress: true + +jobs: + kit-linux: + name: FTWKit on Linux + runs-on: ubuntu-latest + container: swift:6.1 + steps: + - uses: actions/checkout@v4 + - name: Test + working-directory: appleApp/FTWKit + run: swift test + + apple: + name: FTWKit and the app on macOS + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Use the newest Xcode on the runner + run: | + latest=$(ls -d /Applications/Xcode_*.app | sort -V | tail -1) + sudo xcode-select -s "$latest" + xcodebuild -version + - name: Test FTWKit with CryptoKit + working-directory: appleApp/FTWKit + run: swift test + # Unsigned builds: they prove the app compiles for both platforms. + # Signing, passkeys and the camera need a team and a device. + - name: Build the app for the iOS Simulator + working-directory: appleApp + run: >- + xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW + -destination 'generic/platform=iOS Simulator' + -derivedDataPath build/ios CODE_SIGNING_ALLOWED=NO + - name: Build the app for macOS + working-directory: appleApp + run: >- + xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW + -destination 'generic/platform=macOS' + -derivedDataPath build/macos CODE_SIGNING_ALLOWED=NO + # For whoever reviews the screens: the demo, photographed per tab in + # the simulator. Evidence, not a gate, so a flaky simulator does not + # turn the build red. + - name: Photograph the demo in the iOS Simulator + continue-on-error: true + working-directory: appleApp + run: scripts/demo-screenshots.sh build/screenshots + - name: Keep the screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: demo-screenshots + path: appleApp/build/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 6bfa9df..5373ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,10 @@ iosApp/Pods/ # Secrets *.jks keystore.properties + +# Swift package +appleApp/FTWKit/.build/ +appleApp/FTWKit/.swiftpm/ +appleApp/build/ +appleApp/FTW.xcodeproj/project.xcworkspace/xcuserdata/ +appleApp/FTW.xcodeproj/xcuserdata/ diff --git a/iosApp/iosApp/iosApp.entitlements b/appleApp/FTW-iOS.entitlements similarity index 100% rename from iosApp/iosApp/iosApp.entitlements rename to appleApp/FTW-iOS.entitlements diff --git a/appleApp/FTW-macOS.entitlements b/appleApp/FTW-macOS.entitlements new file mode 100644 index 0000000..fb1a0a4 --- /dev/null +++ b/appleApp/FTW-macOS.entitlements @@ -0,0 +1,19 @@ + + + + + com.apple.developer.associated-domains + + webcredentials:app.ftw.energy + applinks:app.ftw.energy + + com.apple.security.app-sandbox + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + + diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj new file mode 100644 index 0000000..31c513a --- /dev/null +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -0,0 +1,322 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + F7A000000000000000000014 /* FTWKit in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000016 /* FTWKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + F7A000000000000000000005 /* FTW.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FTW.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000006 /* FTW-iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "FTW-iOS.entitlements"; sourceTree = ""; }; + F7A000000000000000000007 /* FTW-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "FTW-macOS.entitlements"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F7A000000000000000000003 /* FTW */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTW; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F7A000000000000000000012 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000014 /* FTWKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F7A000000000000000000002 = { + isa = PBXGroup; + children = ( + F7A000000000000000000003 /* FTW */, + F7A000000000000000000006 /* FTW-iOS.entitlements */, + F7A000000000000000000007 /* FTW-macOS.entitlements */, + F7A000000000000000000004 /* Products */, + ); + sourceTree = ""; + }; + F7A000000000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + F7A000000000000000000005 /* FTW.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F7A000000000000000000010 /* FTW */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A000000000000000000023 /* Build configuration list for PBXNativeTarget "FTW" */; + buildPhases = ( + F7A000000000000000000011 /* Sources */, + F7A000000000000000000012 /* Frameworks */, + F7A000000000000000000013 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000003 /* FTW */, + ); + name = FTW; + packageProductDependencies = ( + F7A000000000000000000016 /* FTWKit */, + ); + productName = FTW; + productReference = F7A000000000000000000005 /* FTW.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F7A000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + F7A000000000000000000010 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F7A000000000000000000002; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = F7A000000000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F7A000000000000000000010 /* FTW */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F7A000000000000000000013 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F7A000000000000000000011 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + F7A000000000000000000021 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F7A000000000000000000022 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + F7A000000000000000000024 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "FTW-iOS.entitlements"; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "FTW-macOS.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; + PRODUCT_NAME = FTW; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F7A000000000000000000025 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "FTW-iOS.entitlements"; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "FTW-macOS.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; + PRODUCT_NAME = FTW; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000021 /* Debug */, + F7A000000000000000000022 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7A000000000000000000023 /* Build configuration list for PBXNativeTarget "FTW" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000024 /* Debug */, + F7A000000000000000000025 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = FTWKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + F7A000000000000000000016 /* FTWKit */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = F7A000000000000000000001 /* Project object */; +} diff --git a/appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme similarity index 82% rename from iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme rename to appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme index 3657485..a66be0e 100644 --- a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme +++ b/appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme @@ -14,10 +14,10 @@ buildForAnalyzing = "YES"> + BlueprintName = "FTW" + ReferencedContainer = "container:FTW.xcodeproj"> @@ -44,10 +44,10 @@ runnableDebuggingMode = "0"> + BlueprintName = "FTW" + ReferencedContainer = "container:FTW.xcodeproj"> @@ -61,10 +61,10 @@ runnableDebuggingMode = "0"> + BlueprintName = "FTW" + ReferencedContainer = "container:FTW.xcodeproj"> diff --git a/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json b/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..fdca332 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0xDA", + "green": "0x7F", + "blue": "0x00", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0xFF", + "green": "0xAC", + "blue": "0x41", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..592f5b9 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,74 @@ +{ + "images": [ + { + "filename": "icon-ios-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "filename": "icon-mac-16@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "icon-mac-16@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "icon-mac-32@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "icon-mac-32@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "icon-mac-128@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "icon-mac-128@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "icon-mac-256@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "icon-mac-256@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "icon-mac-512@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "icon-mac-512@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..91a152afe650065ad54ba85c8123bc094cc7b8ce GIT binary patch literal 11194 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 F{{tiiD*ONd literal 0 HcmV?d00001 diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..e548c7ee15d7cc0389792482075b88bfd10ac926 GIT binary patch literal 3895 zcmV-756JL|P) zdyFL2b%(!mZ*|Y3yJy&ChFSK(4hU8luR#P%lsLl1!NGQd!Aekwl7I~)Q5-pzu}z%F zRo`P>^FOL%`=^ja6zO8_Q1*NV}%Z{I#}@ZiC5YwZ^2+=aj< z5m8l5v^R*aQB@npm}h~5g+k$_si~=2(`HBAuQmHV>u`KSbRmU8;S6KUuLD;DmjK&D z#IEso*wCmdz#G8xz*D~O|Day4zZLy=+&!+DM_5Y%SPmI}w=w2(z||r$1}hg1>+JG2 z7;#G%kx zk=VEjcuit>6!>bnTz)3n&d}w?((RkS@&${<;w{FQJAgAH!}}ZxbTW>Q65c}*5u>U; za7VdZz5{5`uR;y6kQZSw~S%{ zH#|JN$vO8k5xGoN0}l0Z8J(j*AR?ZsPKn4h3kwVXO_xN_4dXN${}B;+zp6HXTo;a= zaR@&W!JLR()h!XMRsaH+v9YoKTCMhYWPBg+R(afvV-pY>ea;wj$^88MURD_xnpJ)w z0|eD-_3oyYpBaDcu!P;gCg!sUeB zKwI``R{$13M1Dg=M#x(KM!*4Lt^HIdJGU!9dw-R{+e-&7C76qjZ>d zY;>GL-V;VjIsD!cfdi~D<`<&(E!(wi0RUrB^q;scz*?bF54hp-QFd+VXK~pfy`caF z5RtRm-LY)}7-QboeA1*~1nNrx=bqWmXFoc|D^qp)a;8@UW*obt-lBI>kP`u}FuWiB95SR+(x0e^G- z4$eD!5K{@*Gg$}IbMC(*QeC`ZjZm)oeBgp1KK+p~v=-0+CT5oKJV{oNa~x9b0RpGw zbB23v9>?uE`Gru;*MHdXQT=gMu*tdU)xmti{ z)6oT_QUJyX)y06_XBYU(>vs^;0>lUyVb5e8^)-B-0#YdeP#V7Sts8ez9P*&y2<@%B z^3Ebk&kO;tn$!uvv%-8O;D!&5aK)vY2r53-N|^Ke%AU8=lL0`=1Rw&7%Z{y^bNt=! z??f98kT{r9uKWnmZ zSlKAN_*M<|9VR6i0Hj0!)(DlF&sRRVm2=N25Y&Rhj4V+Z&?}Sm1eUm*n8XXf8lhYZ zxcHm`U$}NFuI3-r!o>)#;du3(MI=S;A5tIyRmB*%>xP}=`Yjq9KFg;X4h+>QVFu9W z8C;4KkZ1vTR+umQ+;qh#AG&OF82BC0`W3(vCTEwKDf_7k03pEw2ur*iJIDIC{kM1E z>cKIGcjF9T-+?9O>kcomcjVY4_Hspp<%Z*%H;ywl){id7!@Y;23K-$VH)}}XQZok- zlSl!?C0*T}HPU7y++&g`9FT+bM2 zEIIZaSi+=e1TZFX0FMQ zn@`v~r#;xXc#h$Y8u*KXss>vqtx1syP8dG*x_zxyZ*xvP+!r8&q*HzQe9cf+X{TN96`{7&7U3@ zz)>Y$%dVs6`9xZL4p4+3P;x^#?t6HW$9_J~rcw^SM~x3Xo(5VG>Wl8E60fzwDVRRP zf$2qV|G`@f^qZDrhK)+k3cy-nuH4|ti-x)RV_RsH8z5m?=9Ck^oOTqjsVb5;eCazA zOwKmw&zqhM{F>>>Em3G#vQaas^NjD0V2vkDoli15eCw zTG8ui*B{f90swHT^ye*K`tBPv8m%woVnry|13r53Ft`2Y_LjO0RUl`m%s2Rphu$Qg z>)pKnNP1ELs^A%6w&L^SXJ(`25?kBX^8|mf!LPih1bqfq4^B8-3}KgHOCDc)aDta7 zYHS+r?ZB^@o)iE?Kd)tk(~9j~f;Di^!KLREAUOFV5Y8#K;PLa97r6J|rYR-F^~dzA z03iA?9oL>Q0tfk=uydp@6u?Q2uO?hO=uf`Ak7Zve=1kk?+{pB77SIXt^8q78kMYqy z@b!fGzY9XwfA__Bsk+6%$2f-e{`X`87 z{|}#<;U~|{B_`)T7O4_|Iz_Ev_l`k0&N6ULLGp&_=|w*Oz`m&EC)41#rYRfm3YJiii=GF%%s4Zqo#3YV506e{DdC#`~!!m#%2m`sCubNg+QjGl$08L12!G#B*tMk}kZ@!#XQ(Xr{OJP|m(!Q@f^d>Lvty1U@2Zqu};sp?A0GkFa<7f22vLm1160deifdIlHkh917 z*m8Ov4HVzku*9q3j#@P=Z6sd+gwGJ{9O)yUGsvLjpC6s(>6aHMr7Y(^7ReVtoB?bd zz!fd8?yd3X54}ktC1d?3Bk=-I4VQx5b!I=t2%mpoAMY+Cx5TSmIuSrmb(*S*82H6= zi#+!D0e<>?qD#Ei{)Zz)WVzjaP8A}s>?_+g=gE1(AK$-^d_K`7UTxc^9RVnS@B7bn zu#{{j+Ian_6U`&nB_NE54%tiT<{1o3@_}Qq@@?Xy+uS3RURO8(w*5 zkr(z=DGu14UCELqkI^tLhYC1KZGdGk$(>rrcoJ*EL-{;uK?nAP9n| zqW3K?cGqqfV5g_2tH5*7e0rO!>a9QFu)nPQI8*{rKvZ?QR4VOh`m80|b>ndxda~UU zol2A*`J$Z>h=_{Fqfz6utf2q`0N?k&@0?p`_hcDsf+6$(|Jv!!?Vy0I*XxtUn5QM| z2R3{+kYF4j&bimiKDOtDye(irngSy!+w zIKq2>z&UqewOXAf>XJJ-*lN0fIK6JD>b0u6pH(O^qa~V+d~IZW%PKOl)sE*oz*MW% z{UUNTFh?eWwZZ`+B1TpJs9Y}p7hSm5-I&mEfL&NvcveKN0%oI_!0*DbGeY9Yx4=2~ zhm}g@KEjE-RX4P5PLEh1D3wa*0pAso_p55`nl}8*;RZ!H6!1-{>UEV$O3xKe?4o;(;4W*xKXI_o(XkoOAb9tJVD+x<6eXY+LIa->g_WG&J;K;CEE@SAnzA zum(>nL=EB%RsDAn`BAxCegs&G{(DWcs`aoyvo`=>Y;3Ghuh%b7)!ia;ma1L|Y$6@r zATHo&h{&_RjEMYap-^~fYHF(1v|XIvujvr`dfx0<2#}2v9FKSubw3Sw^JUI*-@o%a_c_n~oaay+j#wjw<%IzN$U_G$j{*RL ziZCFAfd1UVN__#4Sv_QFPKdfQI~HSjebz!a9I9Ct@KfwGa~+qs@F@gcL~}=$4B?KB9oK12({Vd z-`aBIz2P&-*iQ}B7nlvh(edlvzOm<(Y-D8QJ*s;#)j7FKgs@x!3p_d?2XcJY z@!O5DVJNyeQ7NS>3H+u`PXOgP_8LxkEmXb!aUh1E3&s;jf$w_XzODVU&Lv{Q%BfsM z@*@x^vyVIITs}EtyJ@1Lfoef)0KWmv!*VXjAdlVC+q*_lITxBe`xt`hahq>(-6vk15*iXaXPGB$%k6_KPUr^=DDmSu#^ zz$s{Lg3`r2}HOVeL;k5NuNe0nqyIfh(hW}8I`Al5sk;J$0{m5C_rD2 zMzx6;sDWF#xz8;u%`~vVQdvjzrY5%M!Rmg7$7EOUSHn6CoJOzO1JdBk@*E|;$DA@K z)GZGXj*Ca^?F*TPJ%uSu}<31UWV2 z(de1FFCCidY7twXtp!;SiN1UqAX3f9_^OKzFW2|2`75zi0)wciyxpP#gRVq?qBm*f$PZ8g9StQgrE z)>u9`V=7bx^Sm{qW=ts-Btz&Q;qFhUJWcGmuhYj8(u;G;#2N4=7D-GXcvDK_;W<7` zbOAe$`p1LP!b(gkOnF5d0AzJ+;cU3kUatpPnv)lvWr_>;F@o#97^mwtNaV+7!BaIN z19R}i;H5a;0jcn94s8IV!uk2D?D1vn`-2zpWB#qhNNOztmoa|tsekG8LCeUPf;y2g z(IST#XDfM^1v6en29n!qa@^97o_@r1Waw-iF5!l^lki=Qq}QOb&!6`K7;HJKu4ycQ zN=^sA+B?&F__Xd z&_^YCIayPVN~wtGpt5p)$Gqsw>Ms%bRx9EO_-7;6*u^a#rT!N+PA7(#zdJTyuhw}o z!DQ|Oc<1z}b^x=~jD3n0Fmt-CVPzhU7>jt}&N@3;xg)bv6c0*uzwb)dWflRE&6fMn zz`uN50UYb{((+LP2s|TtzXuP0W1z$*C(=TXNde zG%?~4r7N#;@td$^O{?YTQI6Ba8=l)b;XY`8JdpR`Q3Sht#d(D}0FjjbrEHC-0EYP_+(P&U#@W=3# zKjij<-rue{Ql_F7Tvi`UJ(Wl$FC>ok9vFPStbW3^Q7pu&VR$4>)c5s&f@DycXAraq z+~ne#aLzoN!&^A}%g`4E1@hsH#i?h6P8%5*`N4w+srRb6QB%Q(DBvEx%7kzkU#|sp zI~uptyHCrJH#XF3RQv~7HqYiDKjx~Gs}a&|cOA?|G(pQv^5nx9g6FpWQ1o-vKfSH7 zYQp(OYKiSzQ^mQ9iU!XV)A+vW?KEB3bS^h7w+5~{GVURFaxw1M?yKk(5w*te-D2^} z-}2_?_oUwP7rJI*DxuBq9y|A(8FMhYZr>w;=XP?#;&v{c(stG%U(&x+@w!k~IB5B+ z_+0QGMR4tLt--Qn(o-SeO!O<}eHSgVbhBo#t!WDpu2t*ZV#-l9;#I<9eE84f#}bYE zmXG!T{;|Qh2;e49B*!wZU(`)(=>x_)1IN6ZBVj*P=@Y%$mzErxQvOT9?e2ATcW=@Q zb=5q|lMq9Xy~1tX0&ZSO!I54IOuaE6--G@B>A^2r!jn53_jTSiYRd45n&DT4@mU_F}~Ni0b@dPKuD=^TlKIP4cuZU z-KThgcbZqxqB4A(cBCw$Si}{heU<#MMY31hD;`BZ4R_cs3XD2*`A9mgJI;sCD-52s zdZ*HYfu%9B)>G*RH(%aCCbohiZ@x!o3`EVAuck5#YzeC{^0>8NhDD1Scyt+t2Tn%3 z(fY_8Pd@jV>j>3xv3zM#8740gMJX6iLtSeAaajbV#jn%nDQjJ6DD(*sq~K(y2=43< zLZ=2r5wkdd5sAN~3;cT`_^mmcN4V2(qh3DRt2Tcno0O2y9%cvwj)J8-M_E@S=wwe{%8%_J73~0AeR7Ez>vVwekGm4&tI@`4bgO z_NV?9Zy&E%Qt3n#DPw?swL24c6^S?10sfPg`Ml3i93^ z2ppoBPm)j||M3JL=&y`;p@CpJ3FZ)5@_scVRe@|OD3g!{>FFg(7B(K}A>9!I#Mlcg zvIo;G^j{N?*6hb0d8UdzbPsiew<8_q;8-%cdWG;Z_oW4nzOf?R`jxii?o)v9?nfl| zkT7`S$+N#1+NBZ-s~pDw>2RZb9*qWzTI{r$+IfN&dxaqHF_N-xxQFES75t3%MYke~ z79rfZ`h1=t8aP$?@vzsun>U)mw*902QdJ-uc>+RPE;PU^1mlnasHKC_;#VYcz7B_w=hX9W8~(c@9-`z#n|ps_LtufN15DD_xJp>5HUgjUVBqiYd-t*g z@c}l~OZG>>xUiVA@_3g88EKc0^|J{XC8ZO%*akE-I)!4mLWNOlmuOS1GKq7ObzI;{ z^pZz3u(dt8^N#dTV7$<9JdbbB9x=t26)L?V@FMd2pPWuPwC9z{zjEbFz1xK@IE00EsxXLV^6kAJehZnG<%g^K z9gpNjJA(J9ui=CJw^(9*ftZi+0xh8H_-*E@tT{?Uu_B@3>vp@0)fB! z-mDiFOa=0Y^*NME)69=~a$%#2RC((J2s@QV_YeyCvD&{fpV!-&jZ1XI@A+gTuhxb2 zdrUMeNX=`Wmx{#I(tDc87_iTgsHfE8H&UyV*Heh#pflT>nt`Ez!s%2!w*IHp95`H6 z5KCV?ST%bGF-Llw5o|JEk=8CH_yEnA%5IO?HI(e!NP{={9LwGGcwn_o;qiJ(GAt8L zEczi-V7j|aC7xSG5M0+p;8Bd}R6p|NO*0)*V)``+t+_#rXB9s?_nv1PF7MJo;Eza+ zHLW~MPq&@h#rcH0vnEcSSSQh27Vr#Tk#oy`(XX*nh&xa~{4x{00zq?ebZLZyR>UB0*_3-81&M&xBc0<+mb;bs(|zyBCD1o$oZtz|1B|B zb5tN#+%^m!6AtNtpPWaK34~tr2cK4*A;TEo*0&LGCjx)Tu6QG05ag42mKy;-Ljfb7{SD&{ zg~>I5$TJBr2QR<=4T>N{$^UNy2>G&fguw<7vSk9{D{zawy-|ySMu%#FqcPKeJE7`z zEXRzV6I2MB{2&iy1-VzLMy)Unk@GhH#73wBD3sJLP+&a47}r)0hLRkVg%>6A({&sI z^MgE>0^#y^Xd7ulCL}C@*2<8Q4a#l(l_rUx8*PI4BM^TJA6;`NE?7R~+eMLRC}qgw z&As5Nv|p00M?q`zg9WbDW0o9BR#vgsPkVx9Z~sQ4aak3U$@F zcRT3=1<`jpE8T-bt2u7dyG~5+0Yqt#Uc)Mx8%Hpn2K84eDE@+0)qHwl+bqaDQc|sf zT`N|5zv6C7@uw-wA9H=^>8lv~EA=PG<}fUox-+G#sV%7N;2t1F{`sXY7){?p`Jy2} z71mD-*!iC~OFZUNgt#$JLXvpW-yp9nBk!>cY;~t?)nZrd&(Ooxsg(D`V&S%v=VdctX@vy(8uF8YL<4(MKsL}Hpn*r3uA+_#X1 zPLs-}AyORx$^OtJdR=%nYwGET1|S0-*Jii|to5xgl;`phi-u4%F+tKRIba}^c%^uz ztm!s%X6DO;zH}tf(btKd^@NrdkF&ybmw-6*s(W~z-I7?f3p@m*Sx)BlEfYwaizQNJ zfeMSgW}UgnrzG+XBdIL7*X(u z%9A>|i}xG|kv6GM>#3xDgwYi6HMgy3Jl-wV+NhP`y7>F1{>LQ~g{eD_3rdvx-b}wKpG(Uc^glE>rVtep=VuszzPLj7c4m5X<#ZywcQj#&=P2?Nx&2M}=jtk<+^Y0iH zWz)mhadH5R;2}eH9O$%~yw(*mio3~ljAl6xpg##Y#FZsufXYl{gLURhmo7D+1v=U5 zrr>OP0Z_(RMtEDbQJlCe3&Z8!Sj9rHk0hx z0qw@{hT)gk#il75K!AbvgO+Z@_3-9V9-=r>D?>1wz4^HqnZDGxi_(=>tPhL#)+Oeq zi~U(OuO5p1#^G@ITGKst&Ui)(l2ti#d8DRi@o2p|MeTgT5D~$R?mD)5ai;S1u$NKYK3E)@wX;A+cFd|tlm_|nx`BT_y z!OL;H$css&>+B>=3joF>`{v_#OVpU^WIYG6`js;lm-`qzzftyRI`n292eigP(D57` zC}7S?WaYa_-4{--~#g=vH?1@DlClOaY(ci!5f3R zMdOR2_5jsbTZES>rk6i)>KWF|y|^|lcvbu!S+G$$2mu}wwr-GNUnR=#bks>o=VIMv z&l8tztdu$Us=CrnU%uJM3njX~IhRLcT6$4M#lpZ4n|P*{H{3USNs)us^#i(AD;Mk6 z<9P<4LNG-2HnMWt{bD&Qmfh%yhCT8eF=3OHgj2mBTM?K)8n}Hp|L2cx*agIw6qiZd+-yW*^UVD8XIQ2@s z8M?cvkj-L)D7#--HuS^yP*=>Qa8Vd$0 zKSshhLiD)4A`;l4aHDp_^?`FOIG9rM=h5%CM)^JaK7QU|HR(@$aoExwFfLK2VYha7 z)GS6-UPlsW!0K-DP2*EqiS}KzWbO-o1k- zTwHFQIB~+?-p=kth~Bt0nvgY9FOaYeVKyKaf#AJBZ$h@VdL zQQaaNR~kRgHs`pPa>y#qoxsH=43^n@sCKI?*I)k=CHepT)3E1>&*t2D(uT5Fgzmz@ NA*&;nPb@qW{|5&3xOe~n literal 0 HcmV?d00001 diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dd8790bd5ec621054fb30d5ae94212afbdb868c1 GIT binary patch literal 553 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 NA*&;nPb@qW{|5&3xOe~n literal 0 HcmV?d00001 diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..42deceacdc1dd49e4d9eed75f7adf9b183c4b949 GIT binary patch literal 11672 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 a5B~+jNiku(f?u%!0000 literal 0 HcmV?d00001 diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..42deceacdc1dd49e4d9eed75f7adf9b183c4b949 GIT binary patch literal 11672 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

bIY$l_&BjY+_re0J0g~UnbZ$jUw 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 o Bool { + #if os(iOS) + return phase == .active + #else + return phase != .background + #endif + } +} diff --git a/appleApp/FTW/Platform/KeychainStore.swift b/appleApp/FTW/Platform/KeychainStore.swift new file mode 100644 index 0000000..a4b98a4 --- /dev/null +++ b/appleApp/FTW/Platform/KeychainStore.swift @@ -0,0 +1,67 @@ +import Foundation +import FTWKit +import Security + +/// The secure store on Apple platforms: generic passwords, readable after +/// first unlock, never synced and never migrated to another device. Cold +/// start reads it without Face ID, which is what lets the cache paint first. +@MainActor +final class KeychainStore: SecureStore { + private let service = "energy.ftw.app" + /// The data protection keychain on the Mac, the one iOS always uses. A + /// build without a signing team cannot reach it, and falls back to the + /// login keychain rather than failing to remember anything. + private var dataProtection = true + + func get(_ key: String) -> Bytes? { + var query = base(key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = run { SecItemCopyMatching(query.merging(self.flavour) { $1 } as CFDictionary, &result) } + guard status == errSecSuccess, let data = result as? Data else { return nil } + return data.byteArray + } + + func put(_ key: String, _ value: Bytes) { + let query = base(key) + let update: [String: Any] = [kSecValueData as String: Data(value)] + let status = run { SecItemUpdate(query.merging(self.flavour) { $1 } as CFDictionary, update as CFDictionary) } + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = Data(value) + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + _ = run { SecItemAdd(item.merging(self.flavour) { $1 } as CFDictionary, nil) } + } + } + + func remove(_ key: String) { + let query = base(key) + _ = run { SecItemDelete(query.merging(self.flavour) { $1 } as CFDictionary) } + } + + private func base(_ key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } + + private var flavour: [String: Any] { + #if os(macOS) + return dataProtection ? [kSecUseDataProtectionKeychain as String: true] : [:] + #else + return [:] + #endif + } + + private func run(_ call: () -> OSStatus) -> OSStatus { + let status = call() + if status == errSecMissingEntitlement, dataProtection { + dataProtection = false + return call() + } + return status + } +} diff --git a/appleApp/FTW/Platform/NetworkWatch.swift b/appleApp/FTW/Platform/NetworkWatch.swift new file mode 100644 index 0000000..471a600 --- /dev/null +++ b/appleApp/FTW/Platform/NetworkWatch.swift @@ -0,0 +1,29 @@ +import Foundation +import Network + +/// Tells the app when a network path comes back, so a stream replaces its +/// dead socket at once instead of waiting out a timeout. Lives as long as +/// the app does. +@MainActor +final class NetworkWatch { + private let monitor = NWPathMonitor() + private let onOnline: @MainActor () -> Void + private var wasOnline = true + + init(onOnline: @escaping @MainActor () -> Void) { + self.onOnline = onOnline + // Sendable on purpose: the monitor calls this on its own queue, and a + // closure left to infer main-actor isolation would trap there. + monitor.pathUpdateHandler = { @Sendable [weak self] path in + guard let self else { return } + let online = path.status == .satisfied + Task { @MainActor in self.update(online) } + } + monitor.start(queue: DispatchQueue(label: "energy.ftw.network")) + } + + private func update(_ online: Bool) { + if online, !wasOnline { onOnline() } + wasOnline = online + } +} diff --git a/appleApp/FTW/Platform/Passkeys.swift b/appleApp/FTW/Platform/Passkeys.swift new file mode 100644 index 0000000..6bfc505 --- /dev/null +++ b/appleApp/FTW/Platform/Passkeys.swift @@ -0,0 +1,134 @@ +import AuthenticationServices +import CryptoKit +import FTWKit +import Foundation + +/// The passkey ceremony, with PRF, against the web app's relying party. The +/// same passkey and the same salt give the same PRF output on the web and +/// here, which is what lets one passkey open a home in both apps. +@MainActor +final class Passkeys: PasskeyAuthenticator { + private var pending: Ceremony? + + var isAvailable: Bool { true } + + func register(label: String, userHandle: Bytes, excludeCredentialIDs: [String]) async throws -> PasskeyOutcome { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Origin.rpID) + let request = provider.createCredentialRegistrationRequest(challenge: Data(randomBytes(32)), name: label, userID: Data(userHandle)) + request.userVerificationPreference = .required + request.excludedCredentials = descriptors(excludeCredentialIDs) + request.prf = .inputValues(.init(saltInput1: Data(PRF.vaultSalt))) + let answer = try await perform(request) + return PasskeyOutcome(credentialID: Base64url.encode(answer.credentialID.byteArray), prfOutput: answer.prf?.byteArray, prfEnabled: answer.prfSupported) + } + + func assert(credentialIDs: [String]) async throws -> PasskeyOutcome { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Origin.rpID) + let request = provider.createCredentialAssertionRequest(challenge: Data(randomBytes(32))) + request.userVerificationPreference = .required + request.allowedCredentials = descriptors(credentialIDs) + request.prf = .inputValues(.init(saltInput1: Data(PRF.vaultSalt))) + let answer = try await perform(request) + return PasskeyOutcome(credentialID: Base64url.encode(answer.credentialID.byteArray), prfOutput: answer.prf?.byteArray) + } + + private func descriptors(_ ids: [String]) -> [ASAuthorizationPlatformPublicKeyCredentialDescriptor] { + ids.compactMap { try? Base64url.decode($0) }.map { ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: Data($0)) } + } + + private func perform(_ request: ASAuthorizationRequest) async throws -> CeremonyAnswer { + // One sheet at a time; a second ask while one is up is the same ask. + pending?.cancel() + let ceremony = Ceremony(request) + pending = ceremony + defer { if pending === ceremony { pending = nil } } + return try await ceremony.run() + } +} + +/// The platform answered with a credential of a kind this app never asked for. +private struct UnexpectedCredential: Error {} + +/// What a ceremony produced, copied out of the platform's credential on the +/// main actor so only plain values cross back to the caller. +private struct CeremonyAnswer: Sendable { + let credentialID: Data + let prf: Data? + /// At registration: the platform will evaluate PRF on an assertion even + /// when it gave no output yet. + let prfSupported: Bool + + init(_ credential: ASAuthorizationCredential) throws { + if let created = credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { + credentialID = created.credentialID + prf = created.prf?.first.map(Self.data) + prfSupported = created.prf?.isSupported ?? false + } else if let asserted = credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { + credentialID = asserted.credentialID + prf = asserted.prf.map { Self.data($0.first) } + prfSupported = asserted.prf != nil + } else { + throw UnexpectedCredential() + } + } + + private static func data(_ key: SymmetricKey) -> Data { + key.withUnsafeBytes { Data($0) } + } +} + +/// One authorization controller and the continuation it answers. +@MainActor +private final class Ceremony: NSObject { + private let controller: ASAuthorizationController + private var continuation: CheckedContinuation? + + init(_ request: ASAuthorizationRequest) { + controller = ASAuthorizationController(authorizationRequests: [request]) + super.init() + controller.delegate = self + controller.presentationContextProvider = self + } + + func run() async throws -> CeremonyAnswer { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + controller.performRequests() + } + } + + func cancel() { + controller.cancel() + } + + fileprivate func finish(_ result: Result) { + continuation?.resume(with: result) + continuation = nil + } +} + +extension Ceremony: ASAuthorizationControllerDelegate { + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + finish(Result { try CeremonyAnswer(authorization.credential) }) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + // A dismissed sheet is an answer, not a fault, and gets its own type. + if let e = error as? ASAuthorizationError, e.code == .canceled { + finish(.failure(PasskeyCancelled())) + } else { + finish(.failure(error)) + } + } +} + +extension Ceremony: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + #if os(iOS) + let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows) + return windows.first(where: \.isKeyWindow) ?? windows.first ?? ASPresentationAnchor() + #else + return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor() + #endif + } +} diff --git a/appleApp/FTW/Platform/QRScanner.swift b/appleApp/FTW/Platform/QRScanner.swift new file mode 100644 index 0000000..5b09354 --- /dev/null +++ b/appleApp/FTW/Platform/QRScanner.swift @@ -0,0 +1,211 @@ +import AVFoundation +import CoreImage +import SwiftUI + +/// Reads a pairing QR through the camera. The first code that looks like an +/// FTW link is handed over once; the camera stops at that moment. +struct QRScanner: View { + var onCode: (String) -> Void + @State private var allowed: Bool? + + var body: some View { + Group { + switch allowed { + case .some(true): + CameraView(onCode: onCode) + case .some(false): + Text("FTW needs the camera to read the pairing code. Allow it in Settings, then try again.") + .font(.footnote) + .foregroundStyle(Theme.fgDim) + .padding() + case .none: + Color.black + } + } + .task { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: allowed = true + case .notDetermined: allowed = await AVCaptureDevice.requestAccess(for: .video) + default: allowed = false + } + } + } +} + +/// QR codes in a still picture, for a Mac reading a screenshot of the code. +enum QRImage { + static func looksLikePairing(_ text: String) -> Bool { + text.contains("ftw.energy") || text.contains("/p#") + } + + static func codes(in image: CIImage) -> [String] { + let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) + return (detector?.features(in: image) ?? []).compactMap { ($0 as? CIQRCodeFeature)?.messageString } + } + + static func pairingCode(at url: URL) -> String? { + guard let image = CIImage(contentsOf: url) else { return nil } + return codes(in: image).first(where: QRImage.looksLikePairing) + } +} + +/// The capture session, started and stopped off the main thread as +/// AVFoundation asks. Held in a box so the closures that move it are honest +/// about crossing threads. +private final class CaptureBox: @unchecked Sendable { + let session = AVCaptureSession() + private let queue = DispatchQueue(label: "energy.ftw.camera") + + func start() { queue.async { self.session.startRunning() } } + func stop() { queue.async { self.session.stopRunning() } } +} + +#if os(iOS) +private struct CameraView: UIViewRepresentable { + var onCode: (String) -> Void + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + context.coordinator.attach(view) + return view + } + + func updateUIView(_ view: PreviewView, context: Context) { + context.coordinator.onCode = onCode + } + + static func dismantleUIView(_ view: PreviewView, coordinator: Coordinator) { + coordinator.capture.stop() + } + + func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } + + final class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var preview: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } + } + + @MainActor + final class Coordinator: NSObject { + let capture = CaptureBox() + var onCode: (String) -> Void + private var done = false + + init(onCode: @escaping (String) -> Void) { + self.onCode = onCode + } + + func attach(_ view: PreviewView) { + let session = capture.session + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + view.preview.session = session + view.preview.videoGravity = .resizeAspectFill + capture.start() + } + + func found(_ text: String) { + guard !done, QRImage.looksLikePairing(text) else { return } + done = true + capture.stop() + onCode(text) + } + } +} + +extension CameraView.Coordinator: @preconcurrency AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + for object in metadataObjects { + if let text = (object as? AVMetadataMachineReadableCodeObject)?.stringValue { found(text) } + } + } +} +#else +private struct CameraView: NSViewRepresentable { + var onCode: (String) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + context.coordinator.attach(view) + return view + } + + func updateNSView(_ view: NSView, context: Context) { + context.coordinator.onCode = onCode + } + + static func dismantleNSView(_ view: NSView, coordinator: Coordinator) { + coordinator.capture.stop() + } + + func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } + + /// The Mac has no metadata output, so frames go through Core Image. + @MainActor + final class Coordinator: NSObject { + let capture = CaptureBox() + var onCode: (String) -> Void + private var done = false + private let frames = DispatchQueue(label: "energy.ftw.frames") + private var reader: FrameReader? + + init(onCode: @escaping (String) -> Void) { + self.onCode = onCode + } + + func attach(_ view: NSView) { + let session = capture.session + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureVideoDataOutput() + output.alwaysDiscardsLateVideoFrames = true + guard session.canAddOutput(output) else { return } + session.addOutput(output) + let reader = FrameReader(owner: self) + self.reader = reader + output.setSampleBufferDelegate(reader, queue: frames) + let preview = AVCaptureVideoPreviewLayer(session: session) + preview.videoGravity = .resizeAspectFill + preview.frame = view.bounds + preview.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + view.layer?.addSublayer(preview) + capture.start() + } + + func found(_ text: String) { + guard !done, QRImage.looksLikePairing(text) else { return } + done = true + capture.stop() + onCode(text) + } + } + + /// Decodes on the frame queue and hands text to the main actor. + final class FrameReader: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, @unchecked Sendable { + private weak var owner: Coordinator? + + init(owner: Coordinator) { + self.owner = owner + } + + func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + guard let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + guard let text = QRImage.codes(in: CIImage(cvPixelBuffer: pixels)).first(where: QRImage.looksLikePairing) else { return } + let target = owner + DispatchQueue.main.async { + MainActor.assumeIsolated { target?.found(text) } + } + } + } +} +#endif diff --git a/appleApp/FTW/UI/BoxView.swift b/appleApp/FTW/UI/BoxView.swift new file mode 100644 index 0000000..9bfaa04 --- /dev/null +++ b/appleApp/FTW/UI/BoxView.swift @@ -0,0 +1,353 @@ +import CoreImage +import CoreImage.CIFilterBuiltins +import FTWKit +import SwiftUI + +/// The box this phone is paired to: what it is, who else it trusts, what it +/// tells phones, the spare key, and leaving. +struct BoxView: View { + let app: AppModel + let home: HomeModels + @State private var leaving = false + + private var site: SiteModel { home.site } + private var stored: StoredSite? { site.siteId.flatMap { app.sites.get($0) } } + + var body: some View { + Group { + Title("Your FTW Box") + if let stored { + Text(SiteList.fingerprint(stored.boxStaticKey.byteArray)) + .font(Theme.number(17)) + .foregroundStyle(Theme.fgDim) + } + Card { + Row("App version", "\(AppInfo.version) (\(AppInfo.buildNumber))", number: true) + if let build = site.session.box?.build { Row("Software", build, number: true) } + if let zone = Self.timeZone(site.session.box?.tz) { Row("Time zone", zone) } + if let stored { Row("Paired", Clock.day(stored.addedAtMs)) } + if let id = app.vault.deviceIDOnBox { Row("This phone", id, number: true) } + } + + AccessSection(site: site, access: home.access) + NotificationsSection(site: site, notify: home.notify) + RestartSection(site: site, restart: home.restart) + if let copy = app.sealedCopy { + SealedCopySection(copy: copy) + } + signOut + } + .onAppear { + home.access.activate() + home.notify.activate() + app.sealedCopy?.reload() + } + .onDisappear { + home.access.deactivate() + home.notify.deactivate() + } + } + + @ViewBuilder private var signOut: some View { + Divider().overlay(Theme.line) + if leaving { + Text("Sign out on this phone?").font(.title3.weight(.semibold)) + Text("This phone stops showing your home and forgets its key. Nothing is removed from your box. It keeps running and keeps every reading.") + if app.sealedCopy?.kept == true { + Text("The sealed copy stays, so you can open this home again with your passkey. To remove it instead, turn off the sealed copy above before you sign out.") + } else { + Text("To come back, open the box's local dashboard and use Settings → FTW app → Show pairing code.") + } + Text("Signing out here does not remove this phone from your box. If you are handing the phone on, remove it there too: Settings, then FTW app\(app.vault.deviceIDOnBox.map { ", looking for \($0)" } ?? "").") + Button("Sign out") { app.leave() }.buttonStyle(.danger) + Button("Cancel") { leaving = false }.buttonStyle(.quiet) + } else { + Text("Sign out").font(.title3.weight(.semibold)) + Text("Clears this home from this phone. Your box and its readings are not touched.") + Button("Sign out") { leaving = true }.buttonStyle(.outline) + } + } + + /// A box that says "local" means the zone it runs in, which is almost + /// always the phone's own. + static func timeZone(_ zone: String?) -> String? { + guard let named = zone?.trimmingCharacters(in: .whitespaces), !named.isEmpty else { return nil } + return named.lowercased() == "local" ? TimeZone.current.identifier : named + } +} + +private struct Row: View { + let label: String + let value: String + var number = false + + init(_ label: String, _ value: String, number: Bool = false) { + self.label = label + self.value = value + self.number = number + } + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(label).foregroundStyle(Theme.fgDim) + Spacer() + Text(value) + .font(number ? Theme.number(14, weight: .regular) : .body) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) + } + .font(.callout) + } +} + +// MARK: Who can see this home + +private struct AccessSection: View { + let site: SiteModel + let access: AccessModel + @State private var confirming: String? + + var body: some View { + Divider().overlay(Theme.line) + Text("Who can see this home").font(.title3.weight(.semibold)) + if !site.heardFromBox { + Text("Reaching your box…") + } else if !site.canConfigure { + Text("You have view-only access. You can see this home's readings; changing anything, and who else can see it, belongs to its owner.") + } else if !site.hasPassthrough { + Text("Sharing needs newer software on your box.") + } else { + members + invite + if let error = access.error { Problem(error) } + } + } + + @ViewBuilder private var members: some View { + if access.members.isEmpty { + Hint(access.loading ? "Reading your box…" : access.loaded ? "No phones are paired with this box." : "The list has not come through yet. Still asking.") + } + ForEach(access.members) { member in + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text(member.id).font(Theme.number(15)) + Text("\(AccessModel.roleLabel(member.role)) · \(access.seen(member.lastSeenMs))\(member.isThisPhone ? " · this phone" : "")") + .font(.caption) + .foregroundStyle(Theme.fgDim) + } + Spacer() + if member.isThisPhone { + Text("use Sign out").font(.caption).foregroundStyle(Theme.fgMuted) + } else if member.role == Contract.roleOwner, access.owners <= 1 { + Text("last owner").font(.caption).foregroundStyle(Theme.fgMuted) + } else if confirming == member.id { + Button("Remove") { + Task { if await access.revoke(member.id) { confirming = nil } } + } + .buttonStyle(.danger) + .fixedSize() + .disabled(access.busy == .revoking) + Button("Cancel") { confirming = nil }.buttonStyle(.quiet) + } else { + Button("Remove") { confirming = member.id }.buttonStyle(.outline) + } + } + .padding(.vertical, 4) + } + } + + @ViewBuilder private var invite: some View { + if let invite = access.invite { + Text("Let the person joining point their camera at this. It lets them see this home and nothing else, and it works once\(invite.expiresAtMs > 0 ? ", until \(Clock.time(invite.expiresAtMs))" : "").") + // A square and never text: what is in it is everything it takes + // to become a phone this house trusts. + QRCodeView(text: invite.url) + .frame(width: 240, height: 240) + .frame(maxWidth: .infinity) + .accessibilityLabel("Pairing code for this home") + Button("Done") { access.dismissInvite() }.buttonStyle(.quiet) + } else { + Hint("A viewer sees your readings and can change nothing. Making an invitation cancels any pairing code already showing on your box.") + Button(access.busy == .inviting ? "Asking your box" : "Invite someone to view") { + Task { await access.inviteViewer() } + } + .buttonStyle(.outline) + .disabled(access.busy == .inviting) + } + } +} + +/// A QR drawn on the device, crisp at any size. +struct QRCodeView: View { + let text: String + + var body: some View { + if let image = Self.render(text) { + Image(decorative: image, scale: 1) + .interpolation(.none) + .resizable() + .scaledToFit() + .padding(12) + .background(Color.white, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + } else { + Hint("The code didn't draw. Open this screen again to draw it.") + } + } + + static func render(_ text: String) -> CGImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(text.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + return CIContext().createCGImage(output, from: output.extent) + } +} + +// MARK: Notifications + +private struct NotificationsSection: View { + let site: SiteModel + let notify: NotifyModel + + var body: some View { + Divider().overlay(Theme.line) + Text("Notifications").font(.title3.weight(.semibold)) + if !site.heardFromBox { + Text("Reaching your box…") + } else if !site.canConfigure { + Text("Notifications are turned on by this home's owner.") + } else if !site.hasPassthrough { + Text("Notifications need newer software on your box.") + } else if notify.oldBox { + Text("Your box doesn't have that yet. It may be running older software.") + } else { + // The box reaches phones through web push today. This app has no + // push channel of its own, and says so rather than offering a + // switch that could not deliver. + Text("This app does not receive notifications yet. Your box sends them to phones that turned them on in the FTW web app, and the choices below apply to every one of them.") + if notify.boxEnabled { + rules + } else { + Hint("No phone has turned notifications on for this box. Turn them on from the FTW web app on your phone.") + } + if let error = notify.error { Problem(error) } + } + } + + @ViewBuilder private var rules: some View { + ForEach(NotifyModel.ruleKinds.filter { notify.availableKinds.contains($0) }, id: \.self) { kind in + Toggle(NotifyModel.labels[kind] ?? kind, isOn: Binding( + get: { notify.rules[kind] ?? false }, + set: { on in + var next = notify.rules + next[kind] = on + Task { await notify.save(next) } + } + )) + .disabled(notify.busy != .none) + } + Hint("\(NotifyModel.labels["box.unreachable"] ?? "") is always on while notifications are on.") + if notify.busy == .saving { Hint("Saving…") } + Button(notify.busy == .testing ? "Asking your box…" : "Send a test") { + Task { await notify.sendTest() } + } + .buttonStyle(.outline) + .disabled(notify.busy != .none) + if notify.testSent { + Hint("Sent. It shows up on the phones your box can reach in a moment.") + } + if !notify.history.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(notify.history) { row in + HStack { + Text(row.title).font(.callout) + Spacer() + Text(notify.when(row.atMs)).font(.caption).foregroundStyle(Theme.fgMuted) + } + } + } + } + } +} + +// MARK: Restart + +private struct RestartSection: View { + let site: SiteModel + let restart: RestartModel + + var body: some View { + if restart.canAsk { + Divider().overlay(Theme.line) + switch restart.stage { + case .confirming: + Text("Restart this box?").font(.title3.weight(.semibold)) + Text("The software restarts. Devices keep running on their own until it comes back, usually within a minute. This phone reconnects by itself.") + Button("Restart now") { Task { await restart.restart() } }.buttonStyle(.danger) + Button("Cancel") { restart.stage = .idle }.buttonStyle(.quiet) + case .restarting: + Text("Restarting").font(.title3.weight(.semibold)) + Text("Your box is coming back on its own. This usually takes a minute.") + case .idle: + Text("Restart").font(.title3.weight(.semibold)) + Text("Restarts the software on this box. Use it when a device is stuck and will not come back on its own.") + Button("Restart this box") { restart.stage = .confirming }.buttonStyle(.outline) + } + if let error = restart.error { Problem(error) } + } + } +} + +// MARK: The spare key + +private struct SealedCopySection: View { + let copy: SealedCopyModel + + var body: some View { + Divider().overlay(Theme.line) + Text("If you lose this phone").font(.title3.weight(.semibold)) + if copy.kept { + Text("Sourceful holds a sealed copy it cannot open, with an opaque id and nothing beside it. A new phone gets this home back with your passkey alone.") + Text("Which also means your passkey is enough to open this home, on any device that can pass Face ID for it.") + Button(copy.stage == .working ? "Removing the copy" : "Remove the copy") { Task { await copy.set(false) } } + .buttonStyle(.outline) + .disabled(copy.stage == .working) + } else { + Text("No sealed recovery copy is saved for this home right now. FTW normally makes one when you pair on a phone that supports passkey recovery. Sourceful can hold that copy without opening it, with an opaque id and nothing beside it, so a new phone gets this home back with your passkey.") + Text("The cost: your passkey is then enough to open this home, on any device that can pass Face ID for it. Nothing is saved unless you ask.") + Button(copy.stage == .working ? "Saving a sealed copy" : "Keep a sealed copy") { Task { await copy.set(true) } } + .buttonStyle(.outline) + .disabled(copy.stage == .working) + } + if copy.stage == .failed, let problem = copy.problem { Problem(problem) } + } +} + +// MARK: The demo's Box screen + +struct DemoBoxView: View { + let site: SiteModel + let exit: () -> Void + + var body: some View { + Title("Demo home") + Text("Simulated FTW box").font(Theme.number(15)).foregroundStyle(Theme.fgDim) + Text("This is the same app and protocol as a connected home. The readings and changes stay in this demo and reset when you leave.") + Card { + Row("App version", "\(AppInfo.version) (\(AppInfo.buildNumber))", number: true) + if let build = site.session.box?.build { Row("Software", build, number: true) } + if let zone = BoxView.timeZone(site.session.box?.tz) { Row("Time zone", zone) } + Row("Data", "Simulated") + } + Divider().overlay(Theme.line) + Text("Access").font(.title3.weight(.semibold)) + Text("On a connected home, this screen lists owner and viewer phones. An owner can invite a viewer or remove access.") + Divider().overlay(Theme.line) + Text("Notifications").font(.title3.weight(.semibold)) + Text("A connected home can tell its phones about useful events, such as a finished EV charge, a device that stops answering, or a box that went quiet.") + Divider().overlay(Theme.line) + Text("Passkey recovery").font(.title3.weight(.semibold)) + Text("A real home can keep a sealed recovery copy for its passkey. Sourceful cannot open it, and the owner can remove it here.") + Button("Exit demo and connect your box", action: exit).buttonStyle(.primary) + } +} diff --git a/appleApp/FTW/UI/EVSheet.swift b/appleApp/FTW/UI/EVSheet.swift new file mode 100644 index 0000000..b7e0c12 --- /dev/null +++ b/appleApp/FTW/UI/EVSheet.swift @@ -0,0 +1,584 @@ +import FTWKit +import SwiftUI + +/// The charger sheet. Everything on it is a fact the box served; every +/// control expresses intent with an expiry, and the box decides. After any +/// outcome the charger is read again, because the box's account is the +/// truth to repaint from. Controls are hidden from viewers as presentation; +/// the box's refusal is the actual gate. +struct EVSheet: View { + let site: SiteModel + let model: LoadpointsModel + let loadpointID: String? + @Environment(\.dismiss) private var dismiss + + struct GoalDraft: Equatable { + var loadpointID: String + var time: Date + var recurring: Bool + var days: Int + var socPct: Double + var surplusUnlockPct: Double + } + + struct BoostDraft: Equatable { + var loadpointID: String + var reservePct: Int + var durationS: Int + } + + @State private var goal: GoalDraft? + @State private var goalRevision = 0 + @State private var saving = false + @State private var saveError: String? + @State private var scheduleNote = "Changes apply as you make them." + @State private var justSavedGoal: String? + @State private var removedGoal: String? + @State private var boost: BoostDraft? + @State private var capacityDraft: [String: String] = [:] + @State private var capacityNote: [String: String] = [:] + @State private var capacityFailed: [String: Bool] = [:] + @State private var capacityBusy: String? + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 14) { content } + .padding(20) + .frame(maxWidth: 560, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + .navigationTitle("EV charger") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Close") { dismiss() } + } + } + } + #if os(macOS) + .frame(minWidth: 460, minHeight: 560) + #endif + .onAppear { model.activate() } + .onDisappear { model.deactivate() } + .onChange(of: goal) { old, new in + // An edit, not the editor opening or closing. + guard let old, let new, old.loadpointID == new.loadpointID else { return } + goalRevision += 1 + saveError = nil + scheduleNote = "Applying schedule…" + } + .task(id: goalRevision) { + guard goalRevision > 0 else { return } + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + await saveGoal() + } + } + + private var sending: Bool { model.command.isSending } + private var stale: Bool { model.stale } + + @ViewBuilder private var content: some View { + if site.session.phase == .streaming, !site.hasPassthrough { + Hint("Charging controls are not available from this box yet. Open the box's own page to manage charging.") + } else { + if site.session.phase != .streaming { + Hint("Connecting to your box. Charging status will appear here when it answers.") + } + if let error = model.error { Hint(error) } + if !model.loaded, model.error == nil { + if site.session.phase == .streaming { Hint("Reading your box…") } + } else { + if model.loaded, model.error == nil, model.points.isEmpty { + Hint("\(site.canConfigure ? "Connect your first charger on your box: open Settings → Chargers, then choose Connect a charger." : "Ask an owner to connect the first charger on the box, under Settings → Chargers.") Once connected and added there, it appears here too.") + } + let shown = model.points.filter { loadpointID == nil || $0.id == loadpointID } + ForEach(shown) { lp in + charger(lp) + if shown.count > 1 { Divider().overlay(Theme.line) } + } + if shown.isEmpty, model.loaded, model.error == nil, !model.points.isEmpty { + Hint("This charger is no longer listed. Close this view and choose a charger on the home screen.") + } + } + } + } + + // MARK: One charger + + @ViewBuilder private func charger(_ lp: Loadpoint) -> some View { + let canConfigure = site.canConfigure + Text(stale ? "Waiting for current charger status. The last reading is out of date." : EVText.status(lp, canControl: canConfigure)) + .font(.body.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + if lp.manualSaveError { Hint(EVText.manualSaveErrorText) } + if !stale, let plan = planStatus(lp) { Hint(plan) } + if let seen = lp.charger?.updatedAtMs ?? lp.manual?.chargerUpdatedAtMs { + Hint("Charger last seen: \(EVText.clock(seen))", tone: Theme.fgMuted) + } + if let session = EVText.session(lp) { Text(session).font(.callout) } + + if lp.boostActive { + Text(EVText.boostActive(lp)) + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Theme.storage.opacity(0.18), in: Capsule()) + if canConfigure { + Button("Stop boost") { Task { await model.stopBoost(lp) } } + .buttonStyle(.outline) + .disabled(sending) + outcome(.boost, lp) + } + } + + if lp.pluggedIn { battery(lp) } + if canConfigure, let capacity = lp.vehicleCapacityWh { carBattery(lp, capacityWh: capacity) } + if canConfigure, lp.pluggedIn { chargeNow(lp) } + goalSection(lp) + if canConfigure, lp.pluggedIn { boostSection(lp) } + if let stopped = EVText.boostStopped(lp) { Hint(stopped) } + windows(lp) + } + + private func planStatus(_ lp: Loadpoint) -> String? { + if (lp.planPending || model.planPending), justSavedGoal == lp.id { return "Goal saved. Updating the plan…" } + var merged = lp + merged.planPending = lp.planPending || model.planPending + merged.planOutdated = lp.planOutdated || model.planOutdated + return EVText.plan(merged, nowMs: site.nowMs, canControl: site.canConfigure) + } + + @ViewBuilder private func outcome(_ control: LoadpointsModel.Control, _ lp: Loadpoint) -> some View { + if let text = model.outcome(for: control, lp) { Hint(text) } + } + + private func isSending(_ control: LoadpointsModel.Control, _ lp: Loadpoint) -> Bool { + if case .sending(let c) = model.command, c == control, model.commandLoadpointID == lp.id { return true } + return false + } + + // MARK: The car's battery now + + @ViewBuilder private func battery(_ lp: Loadpoint) -> some View { + let level = model.socShown(lp) + let unconfirmed = lp.socSource == "assumed" && model.socDraft[lp.id] == nil && model.acceptedSoc[lp.id] == nil + ControlCard { + HStack { + Text("Battery now").font(.subheadline.weight(.semibold)) + Spacer() + Text(unconfirmed ? "Not confirmed" : "\(Int(level)) %").font(Theme.number(15)) + } + if site.canConfigure { + Slider(value: Binding( + get: { model.socShown(lp) }, + set: { model.socDraft[lp.id] = $0.rounded() } + ), in: 0...100, step: 1) { editing in + guard !editing else { return } + let chosen = model.socShown(lp) + Task { + await model.setSoc(lp, pct: chosen) + if model.socDraft[lp.id] == chosen { model.socDraft[lp.id] = nil } + } + } + .disabled(sending) + .accessibilityLabel("Car's current charge, percent") + } + if isSending(.soc, lp) { + Hint("Sending charge level: \(Int(level)) %…") + } else if let text = model.outcome(for: .soc, lp) { + Hint(text) + } else if site.canConfigure { + Hint(EVText.socSource(lp)) + } else { + Hint(lp.socSource == "assumed" ? "Battery level needs confirmation by someone who can control this charger." : lp.socSource == "vehicle" ? "Reported by the car." : "Estimated from energy delivered.") + } + } + } + + // MARK: The car's battery size + + private func carBattery(_ lp: Loadpoint, capacityWh: Double) -> some View { + DisclosureGroup("Car battery · \(EnergyFormat.short(capacityWh / 1000)) kWh") { + VStack(alignment: .leading, spacing: 8) { + Hint(lp.capacitySource == "default" ? "FTW is using a default size. Check it against your car." : "Used for estimates. Check this size if you use another car.") + HStack { + Text("Usable battery size (kWh)").font(.callout) + Spacer() + TextField("kWh", text: Binding( + get: { capacityDraft[lp.id] ?? EnergyFormat.short(capacityWh / 1000) }, + set: { capacityDraft[lp.id] = $0 } + )) + .multilineTextAlignment(.trailing) + .frame(width: 90) + .textFieldStyle(.roundedBorder) + #if os(iOS) + .keyboardType(.decimalPad) + #endif + .accessibilityLabel("Usable battery size, kWh") + } + Button(capacityFailed[lp.id] == true ? "Try battery size again" : "Save battery size") { + Task { await setCapacity(lp) } + } + .buttonStyle(.outline) + .disabled(capacityBusy == lp.id) + Hint("Find the usable size in your car's specifications.", tone: Theme.fgMuted) + if let note = capacityNote[lp.id] { + Hint(note, tone: capacityFailed[lp.id] == true ? Theme.importing : Theme.fgDim) + } + } + .padding(.top, 6) + } + .font(.callout) + } + + private func setCapacity(_ lp: Loadpoint) async { + guard capacityBusy == nil else { return } + let typed = (capacityDraft[lp.id] ?? "").replacingOccurrences(of: ",", with: ".") + guard let kwh = Double(typed), (1...300).contains(kwh) else { + capacityNote[lp.id] = "Enter the usable battery size from 1 to 300 kWh." + capacityFailed[lp.id] = true + return + } + capacityBusy = lp.id + capacityFailed[lp.id] = false + capacityNote[lp.id] = "Sending battery size…" + do { + capacityNote[lp.id] = try await model.setCapacity(lp, kwh: kwh) + capacityDraft[lp.id] = nil + } catch { + capacityFailed[lp.id] = true + capacityNote[lp.id] = (error as? BoxAPIError)?.help ?? "Battery size is not confirmed. Check the current value before trying again." + } + capacityBusy = nil + } + + // MARK: Charge now + + @ViewBuilder private func chargeNow(_ lp: Loadpoint) -> some View { + let range = EVText.current(lp) + let chosen = model.ampsShown(lp) + let paused = EVText.isPaused(lp) + let holding = lp.manualActive && !lp.manualRestoreUnconfirmed && !paused + ControlCard { + if holding { + HStack { + Text("Charge now is active").font(.subheadline.weight(.semibold)) + Spacer() + Text(EVText.readout(lp, amps: chosen)).font(Theme.number(14)) + } + if range.maxA > range.minA { + Slider(value: Binding( + get: { Double(model.ampsShown(lp)) }, + set: { model.ampsDraft[lp.id] = Int($0.rounded()) } + ), in: Double(range.minA)...Double(range.maxA), step: 1) { editing in + guard !editing else { return } + let amps = model.ampsShown(lp) + Task { + await model.chargeNow(lp, amps: amps) + if model.ampsDraft[lp.id] == amps { model.ampsDraft[lp.id] = nil } + } + } + .disabled(sending) + .accessibilityLabel("Charging current") + } + } + if lp.manualActive || lp.manualRestoreUnconfirmed { + Button(paused || lp.manualRestoreUnconfirmed ? "Resume plan" : "Return to plan") { + Task { await model.stopCharging(lp) } + } + .buttonStyle(.outline) + .disabled(sending) + } + if !lp.manualActive || paused || lp.manualRestoreUnconfirmed { + Button(isSending(.hold, lp) ? "Sending charge request…" : "Charge now") { + Task { await model.chargeNow(lp, amps: chosen) } + } + .buttonStyle(.primary) + .disabled(sending) + } + if !paused { + Button("Pause charging") { Task { await model.pauseCharging(lp) } } + .buttonStyle(.quiet) + .disabled(sending) + } + if lp.manualRestoreUnconfirmed { + Hint("Choose Charge now to request charging, Resume plan to use your goal, or Pause charging to request a stop.") + } else if paused { + Hint("The goal and solar rule wait until you resume the plan. Charge now starts immediately.") + } else if lp.manualActive { + Hint("Changes apply when you release the slider. Return to plan restores your schedule and solar settings.") + } else { + Hint("Starts at up to \(EVText.readout(lp, amps: chosen)). Ignores the goal and solar rule until you return to the plan or unplug.") + } + outcome(.hold, lp) + } + } + + // MARK: Your goal + + @ViewBuilder private func goalSection(_ lp: Loadpoint) -> some View { + ControlCard { + Text("Your goal").font(.headline) + if lp.manualActive || lp.manualRestoreUnconfirmed { + Hint(EVText.isPaused(lp) || lp.manualRestoreUnconfirmed ? "Resume the plan to use this goal. Edits apply then." : "Charge now overrides this goal. Edits apply when you return to the plan.") + } + if let draft = goal, draft.loadpointID == lp.id { + goalEditor(lp) + } else { + if removedGoal == lp.id { + Hint(model.error != nil ? "Goal removed. Current charging status is unavailable." : "Goal removed.") + } + if let sentence = EVText.schedule(lp) { + HStack { + Text(sentence).font(.callout) + Spacer() + if site.canConfigure { + Button("Change goal") { beginEdit(lp) }.buttonStyle(.quiet) + } + } + } else if model.loaded, site.canConfigure { + Button("Set a ready time") { beginEdit(lp) }.buttonStyle(.outline) + } + if let saveError { Hint(saveError, tone: Theme.importing) } + } + surplus(lp) + } + } + + @ViewBuilder private func goalEditor(_ lp: Loadpoint) -> some View { + if let binding = Binding($goal) { + DatePicker("Ready by", selection: binding.time, displayedComponents: .hourAndMinute) + Toggle("Repeat on chosen days", isOn: binding.recurring) + if binding.wrappedValue.recurring { + HStack(spacing: 6) { + ForEach(Array(EVText.dayLabels.enumerated()), id: \.offset) { bit, day in + let on = binding.wrappedValue.days & (1 << bit) != 0 + Button(day) { binding.wrappedValue.days ^= 1 << bit } + .font(.caption.weight(.semibold)) + .padding(.vertical, 5) + .padding(.horizontal, 7) + .foregroundStyle(on ? Theme.onAccent : Theme.fgDim) + .background(on ? Theme.accent : Theme.surfaceSunken, in: Capsule()) + .buttonStyle(.plain) + .accessibilityAddTraits(on ? .isSelected : []) + } + } + } + HStack { + Text("Charge to") + Slider(value: binding.socPct, in: 10...100, step: 5) + .accessibilityLabel("Target charge, percent") + Text("\(Int(binding.wrappedValue.socPct)) %").font(Theme.number(14)).frame(width: 52, alignment: .trailing) + } + DisclosureGroup("Solar timing") { + Toggle("Also use spare solar before the planned hours", isOn: Binding( + get: { binding.wrappedValue.surplusUnlockPct > 0 }, + set: { binding.wrappedValue.surplusUnlockPct = $0 ? 50 : 0 } + )) + if binding.wrappedValue.surplusUnlockPct > 0 { + Stepper("Keep home battery above \(Int(binding.wrappedValue.surplusUnlockPct)) %", value: binding.surplusUnlockPct, in: 1...100, step: 5) + } + } + .font(.callout) + if lp.schedule == nil { + Button("Use \(Int(binding.wrappedValue.socPct)) % by \(binding.wrappedValue.time.formatted(date: .omitted, time: .shortened))") { + Task { await saveGoal() } + } + .buttonStyle(.primary) + .disabled(saving) + } + HStack { + Button("Close goal settings") { goal = nil }.buttonStyle(.quiet).disabled(saving) + Spacer() + if lp.schedule != nil { + Button("Remove") { Task { await removeGoal(lp) } }.buttonStyle(.quiet).disabled(saving) + } + } + Hint(saveError ?? (scheduleNote == "Schedule saved." && model.error != nil ? "Schedule saved. Current charging status is unavailable." : scheduleNote), tone: saveError != nil ? Theme.importing : Theme.fgDim) + if saveError != nil { + Button("Try again") { Task { await saveGoal() } }.buttonStyle(.quiet).disabled(saving) + } + } + } + + private func beginEdit(_ lp: Loadpoint) { + if removedGoal == lp.id { removedGoal = nil } + saveError = nil + scheduleNote = lp.schedule != nil ? "Changes apply as you make them." : "No goal set yet. Choose this goal, or change the level or time." + let clock = lp.schedule.map { EVText.localTime(minuteUTC: $0.timeOfDayMinUTC) } ?? (hour: 7, minute: 0) + let time = Calendar.current.date(bySettingHour: clock.hour, minute: clock.minute, second: 0, of: Date()) ?? Date() + let wireDays = lp.schedule?.days ?? 0 + let target = lp.targetSocPct.flatMap { $0 >= 10 ? $0 : nil } ?? 80 + goal = GoalDraft( + loadpointID: lp.id, + time: time, + recurring: lp.schedule?.recurring ?? false, + days: wireDays == 0 ? 0x7F : wireDays & 0x7F, + socPct: (lp.schedule?.socPct ?? target).rounded(), + surplusUnlockPct: lp.schedule?.surplusUnlockPct ?? 0 + ) + } + + private func saveGoal() async { + guard let draft = goal, !saving, let lp = model.points.first(where: { $0.id == draft.loadpointID }) else { return } + let revision = goalRevision + let parts = Calendar.current.dateComponents([.hour, .minute], from: draft.time) + saving = true + saveError = nil + do { + try await model.saveSchedule(lp, socPct: draft.socPct, hour: parts.hour ?? 7, minute: parts.minute ?? 0, recurring: draft.recurring, days: draft.days, surplusUnlockPct: draft.surplusUnlockPct) + justSavedGoal = lp.id + if revision == goalRevision { scheduleNote = "Schedule saved." } + } catch { + if revision == goalRevision { + saveError = (error as? BoxAPIError)?.help ?? "Your box didn't confirm the change. Check the current settings before trying again." + } + } + saving = false + // An edit that landed while this one was on the wire goes out now. + if revision != goalRevision, goal != nil { await saveGoal() } + } + + private func removeGoal(_ lp: Loadpoint) async { + saving = true + saveError = nil + scheduleNote = "Removing goal…" + do { + try await model.removeSchedule(lp) + if justSavedGoal == lp.id { justSavedGoal = nil } + removedGoal = lp.id + goal = nil + } catch { + saveError = (error as? BoxAPIError)?.help ?? "Your box didn't confirm the change. Check the current settings before trying again." + } + saving = false + } + + // MARK: Only spare solar + + @ViewBuilder private func surplus(_ lp: Loadpoint) -> some View { + if site.canConfigure { + let overridden = lp.manualActive || lp.manualRestoreUnconfirmed + Toggle("Only spare solar", isOn: Binding( + get: { model.surplusShown(lp) }, + set: { on in + model.surplusDraft[lp.id] = on + Task { + await model.setSurplusOnly(lp, on) + model.surplusDraft[lp.id] = nil + } + } + )) + .disabled(sending || overridden) + Hint(overridden + ? (EVText.isPaused(lp) || lp.manualRestoreUnconfirmed ? "This rule resumes with the plan." : "Charge now overrides this rule. It resumes when you return to the plan.") + : model.surplusShown(lp) ? "No grid or home battery. Your target may not be reached in time." : "The plan may use grid power to reach your target.") + if isSending(.surplus, lp) { + Hint("Asking your box…") + } else { + outcome(.surplus, lp) + } + } else if lp.surplusOnly { + Hint("Charges from spare solar only.") + } + } + + // MARK: Home battery boost + + private func boostSection(_ lp: Loadpoint) -> some View { + DisclosureGroup("Home battery boost") { + VStack(alignment: .leading, spacing: 8) { + if !lp.boostActive { + if let draft = boost, draft.loadpointID == lp.id { + Text("Let the house battery charge the car for a while.").font(.callout) + Stepper("Keep \(draft.reservePct) % in the house battery", value: Binding( + get: { boost?.reservePct ?? EVText.boostReserveDefaultPct }, + set: { boost?.reservePct = $0 } + ), in: EVText.boostReserveMinPct...100, step: 5) + .disabled(sending) + HStack(spacing: 6) { + ForEach(EVText.boostDurations, id: \.seconds) { d in + let on = draft.durationS == d.seconds + Button(d.label) { boost?.durationS = d.seconds } + .font(.caption.weight(.semibold)) + .padding(.vertical, 5) + .padding(.horizontal, 9) + .foregroundStyle(on ? Theme.onAccent : Theme.fgDim) + .background(on ? Theme.accent : Theme.surfaceSunken, in: Capsule()) + .buttonStyle(.plain) + .disabled(sending) + } + } + HStack { + Button(sending ? "Asking your box…" : "Start boost") { + Task { + await model.boost(lp, reservePct: draft.reservePct, durationS: draft.durationS) + if case .applied = model.command { boost = nil } + } + } + .buttonStyle(.primary) + .disabled(sending) + Button("Cancel") { boost = nil }.buttonStyle(.quiet).disabled(sending) + } + Hint("Ends when the time is up, the house battery reaches the reserve, or you stop it.") + } else if lp.manualActive { + Hint("Available after returning to the plan.") + } else if model.surplusShown(lp) { + Hint("Not while the charger uses spare solar only.") + } else { + Button("Boost from the house battery") { + boost = BoostDraft(loadpointID: lp.id, reservePct: EVText.boostReserveDefaultPct, durationS: EVText.boostDurationDefault) + } + .buttonStyle(.outline) + } + outcome(.boost, lp) + } + } + .padding(.top, 6) + } + .font(.callout) + } + + // MARK: Charging ahead + + @ViewBuilder private func windows(_ lp: Loadpoint) -> some View { + let planReady = !lp.manualActive && !lp.planPending && !model.planPending && !lp.planOutdated && !model.planOutdated + let ahead = model.windows[lp.id] ?? [] + if planReady, !stale, !ahead.isEmpty { + ControlCard { + Kicker("Charging ahead") + ForEach(ahead) { w in + HStack { + Text("\(EVText.clock(w.fromMs))–\(EVText.clock(w.toMs))").font(Theme.number(13, weight: .regular)) + Spacer() + if let wh = w.energyWh { + Text("\((wh / 1000).formatted(.number.precision(.fractionLength(0...1)))) kWh").font(.footnote) + } else if let peak = w.peakW { + Text("up to \(PowerFormat.text(peak))").font(.footnote) + } + } + } + } + } else if planReady, model.planMissing { + Hint("Charging times aren't readable right now.") + } + } +} + +/// A group of related controls, set off like a card. +private struct ControlCard: View { + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { content } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + } +} diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift new file mode 100644 index 0000000..e26859b --- /dev/null +++ b/appleApp/FTW/UI/FlowDiagram.swift @@ -0,0 +1,174 @@ +import FTWKit +import SwiftUI + +/// The house and what flows through it, drawn from the same nodes the box's +/// own dashboard draws: solar top left, battery top right, grid bottom left, +/// the car bottom right, the house in the middle. +/// +/// Dots move along a line only while readings are live. A cached or quiet +/// view holds still, because motion says power is flowing right now. +struct FlowDiagram: View { + let readings: Flow.Readings + let moving: Bool + let tap: (Flow.Role) -> Void + + private static let height: CGFloat = 340 + + var body: some View { + GeometryReader { geo in + let layout = Layout(size: geo.size, nodes: readings.nodes) + ZStack { + TimelineView(.animation(minimumInterval: 1.0 / 30, paused: !moving)) { context in + Canvas { canvas, _ in + draw(canvas, layout: layout, at: context.date.timeIntervalSinceReferenceDate) + } + } + .accessibilityHidden(true) + + Hub(loadKw: readings.loadKw, selfPoweredPct: readings.selfPoweredPctToday) + .position(layout.hub) + .onTapGesture { tap(.load) } + + ForEach(readings.nodes) { node in + Bubble(node: node) + .position(layout.points[node.id] ?? layout.hub) + .onTapGesture { if node.tappable { tap(node.role) } } + } + } + } + .frame(height: Self.height) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + + /// Where each node sits. Several nodes in one corner stack toward the + /// middle, the way a site with two inverters shows two suns. + struct Layout { + let hub: CGPoint + let points: [String: CGPoint] + + init(size: CGSize, nodes: [Flow.Node]) { + let w = size.width, h = size.height + hub = CGPoint(x: w / 2, y: h / 2) + var points = [String: CGPoint]() + var counts = [String: Int]() + for node in nodes { + let key = "\(node.corner)" + let n = counts[key, default: 0] + counts[key] = n + 1 + let x: CGFloat = (node.corner == .topLeft || node.corner == .bottomLeft) ? w * 0.17 : w * 0.83 + let top = node.corner == .topLeft || node.corner == .topRight + let y: CGFloat = top ? h * 0.16 + CGFloat(n) * 96 : h * 0.84 - CGFloat(n) * 96 + points[node.id] = CGPoint(x: x, y: y) + } + self.points = points + } + } + + private func draw(_ canvas: GraphicsContext, layout: Layout, at time: TimeInterval) { + for node in readings.nodes { + guard let from = layout.points[node.id] else { continue } + var line = Path() + line.move(to: from) + line.addLine(to: layout.hub) + let active = node.kw * 1000 > Flow.idleW + let color = Theme.color(node.tone) + canvas.stroke(line, with: .color(active ? color.opacity(0.45) : Theme.line), style: StrokeStyle(lineWidth: active ? 2 : 1.5, dash: active ? [] : [3, 5])) + guard active, moving else { continue } + // Speed grows with power but never races: a kettle and a car + // charger are both readable at a glance. + let speed = 0.25 + min(1.2, log10(1 + node.kw) * 0.9) + let dots = 4 + for i in 0.. 0 ? "drawing" : "exporting" } + figure("Solar", Contract.FID.pvW, site) { _ in "producing" } + figure("Battery", Contract.FID.batteryW, site) { $0 > 0 ? "charging" : "supplying" } + figure("Home", Contract.FID.loadW, site) { _ in "using" } + } + } else { + Text("Nothing from your box yet.").foregroundStyle(Theme.fgDim) + } + } else { + Text("No home is paired on this Mac.").foregroundStyle(Theme.fgDim) + } + Divider() + HStack { + Button("Open FTW") { + NSApplication.shared.activate() + if let window = NSApplication.shared.windows.first(where: \.canBecomeMain) { + window.makeKeyAndOrderFront(nil) + } else { + openWindow(id: FTWApp.mainWindow) + } + } + Spacer() + Button("Quit") { NSApplication.shared.terminate(nil) } + } + } + .padding(14) + .frame(width: 320) + } + + private func figure(_ label: String, _ fid: Int, _ site: SiteModel, words: (Double) -> String) -> some View { + let reading = site.session.fields[fid].map { fid == Contract.FID.pvW ? abs($0) : $0 } + let value = reading.map(PowerFormat.text) ?? "—" + let word = reading.map { PowerFormat.direction($0) == .idle ? "idle" : words($0) } ?? "" + return GridRow { + Text(label).foregroundStyle(Theme.fgDim) + Text(value).font(Theme.number(14)) + Text(word).foregroundStyle(Theme.fgMuted) + } + } +} +#endif diff --git a/appleApp/FTW/UI/HistoryView.swift b/appleApp/FTW/UI/HistoryView.swift new file mode 100644 index 0000000..47ecdff --- /dev/null +++ b/appleApp/FTW/UI/HistoryView.swift @@ -0,0 +1,311 @@ +import Charts +import FTWKit +import SwiftUI + +/// What the house used, made, bought and sold, then power minute by minute. +struct HistoryView: View { + let home: HomeModels + + var body: some View { + Group { + if home.site.hasPassthrough { + EnergySection(energy: home.energy) + Divider().overlay(Theme.line) + } + PowerSection(history: home.history) + } + .onAppear { + home.energy.activate() + home.history.activate() + } + .onDisappear { + home.energy.deactivate() + home.history.deactivate() + } + } +} + +// MARK: Energy, day by day + +private struct EnergySection: View { + let energy: EnergyModel + @State private var shown: Series = .load + + enum Series: CaseIterable { + case load, pv, bought, sold + + var label: String { + switch self { + case .load: return "Used at home" + case .pv: return "Made by solar" + case .bought: return "Bought" + case .sold: return "Sold" + } + } + + var note: String { + switch self { + case .load: return "everything the house drew" + case .pv: return "what the panels produced" + case .bought: return "taken from the grid" + case .sold: return "sent back to the grid" + } + } + + var color: Color { + switch self { + case .load: return Theme.fgDim + case .pv: return Theme.generation + case .bought: return Theme.importing + case .sold: return Theme.exporting + } + } + + func wh(_ t: EnergyModel.Totals) -> Double { + switch self { + case .load: return t.loadWh + case .pv: return t.pvWh + case .bought: return t.importWh + case .sold: return t.exportWh + } + } + + func wh(_ d: EnergyModel.Day) -> Double { + switch self { + case .load: return d.loadWh + case .pv: return d.pvWh + case .bought: return d.importWh + case .sold: return d.exportWh + } + } + } + + var body: some View { + let totals = energy.totals + let hasDays = !energy.days.isEmpty + HStack { + Kicker(energy.range.title) + Spacer() + Segments(options: EnergyModel.Range.allCases, selected: energy.range, label: \.label) { energy.select($0) } + } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + ForEach(Series.allCases, id: \.self) { series in + let parts = EnergyFormat.parts(series.wh(totals)) + Button { shown = series } label: { + VStack(alignment: .leading, spacing: 3) { + Text(series.label).font(.caption.weight(.semibold)).foregroundStyle(Theme.fgDim) + Group { + if hasDays { + Text(parts.text).font(Theme.number(22)) + Text(" \(parts.unit)").font(.caption) + } else { + Text("—").font(Theme.number(22)) + } + } + .foregroundStyle(series == .load ? Theme.fg : series.color) + Text(series.note).font(.caption2).foregroundStyle(Theme.fgMuted) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .overlay(RoundedRectangle(cornerRadius: Theme.radiusSmall).strokeBorder(shown == series ? series.color : Theme.line, lineWidth: shown == series ? 2 : 1)) + } + .buttonStyle(.plain) + .accessibilityAddTraits(shown == series ? .isSelected : []) + } + } + if hasDays, energy.days.count > 1 { + Text("\(shown.label), kWh per day").font(.caption).foregroundStyle(Theme.fgDim) + Chart(energy.days) { day in + BarMark(x: .value("Day", day.day), y: .value("kWh", shown.wh(day) / 1000)) + .foregroundStyle(shown.color) + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 6)) { value in + AxisValueLabel { + if let day = value.as(String.self) { Text(Self.dayLabel(day)).font(.caption2) } + } + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel() + } + } + .frame(height: 140) + } + note(totals: totals, hasDays: hasDays) + } + + @ViewBuilder private func note(totals: EnergyModel.Totals, hasDays: Bool) -> some View { + if let error = energy.error { + Hint(error) + } else if !hasDays { + Hint(energy.loading || !energy.loaded ? "Reading your box…" : "Nothing recorded for this period yet.") + } else { + VStack(alignment: .leading, spacing: 4) { + if let share = energy.solarSharePct { + Hint("Solar made \(share)% as much energy as the home used.") + } + if totals.batChargedWh > 0 || totals.batDischargedWh > 0 { + Hint("The battery took in \(EnergyFormat.label(totals.batChargedWh)) and gave back \(EnergyFormat.label(totals.batDischargedWh)).") + } + if energy.range != .today { + Hint("Today is still running.", tone: Theme.fgMuted) + } + } + } + } + + /// "2026-09-24" to "24 Sep", short enough for thirty bars. + static func dayLabel(_ day: String) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + guard let date = f.date(from: day) else { return day } + return date.formatted(.dateTime.day().month(.abbreviated)) + } +} + +// MARK: Power, minute by minute + +private struct PowerSection: View { + let history: HistoryModel + + private struct Trace { + let name: String + let label: String + let color: Color + } + + private let traces = [ + Trace(name: "grid_w", label: "Grid", color: Theme.importing), + Trace(name: "pv_w", label: "Solar", color: Theme.generation), + Trace(name: "battery_w", label: "Battery", color: Theme.storage), + Trace(name: "load_w", label: "House", color: Theme.fgDim), + ] + + var body: some View { + HStack { + Kicker("Power, minute by minute") + Spacer() + Segments(options: HistoryModel.Range.allCases, selected: history.range, label: \.label) { history.select($0) } + } + Group { + if let frame = history.frame, frame.points > 0 { + chart(frame) + } else { + Text(history.loaded ? "Nothing recorded for this range yet." : "Reading your box…") + .font(.footnote) + .foregroundStyle(Theme.fgDim) + .frame(maxWidth: .infinity, minHeight: 200) + .background(Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + } + } + readout + if !history.note.isEmpty { + Hint(history.note) + } + } + + private func chart(_ frame: HistoryGeometry.Frame) -> some View { + let step = max(1, frame.points / 400) + let indices = Array(stride(from: 0, to: frame.points, by: step)) + let spanMs = Double(frame.points) * frame.stepMs + // A day reads as clock times; anything longer as dates. + let format: Date.FormatStyle = spanMs <= 36 * 3_600_000 ? .dateTime.hour().minute() : .dateTime.day().month(.abbreviated) + return Chart { + ForEach(traces, id: \.name) { trace in + ForEach(indices, id: \.self) { i in + if let v = frame.value(trace.name, at: i) { + LineMark( + x: .value("Time", Date(timeIntervalSince1970: frame.time(at: i) / 1000)), + y: .value("Watts", Double(v)), + series: .value("Series", trace.label) + ) + .foregroundStyle(trace.color) + .lineStyle(StrokeStyle(lineWidth: trace.name == "load_w" ? 1 : 1.5)) + } + } + } + RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) + if let at = history.cursorAtMs { + RuleMark(x: .value("Cursor", Date(timeIntervalSince1970: at / 1000))) + .foregroundStyle(Theme.fgMuted) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel { + if let w = value.as(Double.self) { + Text(w == 0 ? "0" : "\(PowerFormat.scale(w))\(w > 0 ? " in" : " out")").font(Theme.number(9, weight: .regular)) + } + } + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 3)) { _ in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel(format: format) + } + } + .chartOverlay { proxy in + GeometryReader { geo in + Rectangle().fill(.clear).contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + guard let plot = proxy.plotFrame else { return } + let x = drag.location.x - geo[plot].origin.x + guard let date: Date = proxy.value(atX: x) else { return } + let index = Int(((date.timeIntervalSince1970 * 1000 - frame.startMs) / frame.stepMs).rounded()) + history.cursor = min(frame.points - 1, max(0, index)) + } + .onEnded { _ in history.cursor = nil } + ) + } + } + .frame(height: 220) + } + + private var readout: some View { + VStack(alignment: .leading, spacing: 8) { + Text(history.cursorAtMs.map(Clock.dayAndTime) ?? "Latest") + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.fgDim) + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { + ForEach(traces, id: \.name) { trace in + let watts = history.value(trace.name) + HStack(alignment: .top, spacing: 8) { + RoundedRectangle(cornerRadius: 2).fill(trace.color).frame(width: 4, height: 30) + VStack(alignment: .leading, spacing: 1) { + Text(trace.label).font(.caption2).foregroundStyle(Theme.fgDim) + if let watts { + let parts = PowerFormat.parts(watts) + Text(parts.text).font(Theme.number(15)) + Text(" \(parts.unit)").font(.caption2) + } else { + Text("—").font(Theme.number(15)) + } + Text(words(trace.name, watts)).font(.caption2).foregroundStyle(Theme.fgMuted) + } + } + .accessibilityElement(children: .combine) + } + } + } + } + + /// Never a minus sign; a direction word instead. + private func words(_ name: String, _ watts: Double?) -> String { + guard let watts else { return "no reading" } + if name == "load_w" { return "used" } + let direction = PowerFormat.direction(watts) + if direction == .idle { return "idle" } + switch name { + case "pv_w": return "generated" + case "battery_w": return direction == .into ? "charged" : "supplied" + default: return direction == .into ? "drawn" : "exported" + } + } +} diff --git a/appleApp/FTW/UI/LiveSheet.swift b/appleApp/FTW/UI/LiveSheet.swift new file mode 100644 index 0000000..e79ceba --- /dev/null +++ b/appleApp/FTW/UI/LiveSheet.swift @@ -0,0 +1,101 @@ +import Charts +import FTWKit +import SwiftUI + +/// Which bubble was tapped. Everything else follows from it. +enum LiveRole: String, Identifiable { + case grid, pv, battery, load + var id: String { rawValue } +} + +/// One part of the house over the last two minutes. The line moves on news +/// and freezes on silence: a repeated cache value moves nothing. +struct LiveSheet: View { + let site: SiteModel + let role: LiveRole + let fields: [Int: Double] + @Environment(\.dismiss) private var dismiss + + private struct Spec { + let title: String + let fid: Int + let signed: Bool + let words: (Double) -> String + } + + private var spec: Spec { + switch role { + case .grid: return Spec(title: "Grid", fid: Contract.FID.gridW, signed: true) { abs($0) < 20 ? "balanced" : $0 > 0 ? "drawing from the grid" : "exporting to the grid" } + case .pv: return Spec(title: "Solar", fid: Contract.FID.pvW, signed: false) { abs($0) < 20 ? "not producing" : "producing" } + case .battery: return Spec(title: "Battery", fid: Contract.FID.batteryW, signed: true) { abs($0) < 20 ? "resting" : $0 > 0 ? "charging" : "discharging" } + case .load: return Spec(title: "Home", fid: Contract.FID.loadW, signed: false) { _ in "used by the house" } + } + } + + var body: some View { + let spec = spec + let raw = fields[spec.fid] + let live = site.isLive + let color = Theme.color(Flow.tone(Flow.Role(rawValue: role.rawValue) ?? .load, raw)) + // Reading the stream's clock ties this view to every new frame. + let _ = site.session.uptimeMs + let points = site.recentField(spec.fid).map { (t: $0.t, v: spec.signed ? $0.v : abs($0.v)) } + NavigationStack { + VStack(alignment: .leading, spacing: 12) { + if let raw { + let parts = PowerFormat.parts(spec.signed ? raw : abs(raw)) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(parts.text).font(Theme.number(44, weight: .bold)).foregroundStyle(color) + Text(parts.unit).font(.title3).foregroundStyle(color) + if role == .battery, let soc = fields[Contract.FID.batterySoc] { + Text("\(PowerFormat.soc(soc))%").font(Theme.number(20)).foregroundStyle(Theme.fgDim) + } + } + Text(spec.words(spec.signed ? raw : abs(raw)) + (live ? "" : " · last known")) + .foregroundStyle(Theme.fgDim) + chart(points, color: color) + .frame(height: 200) + Hint("last two minutes", tone: Theme.fgMuted) + } else { + Hint("No reading from your box yet.") + } + Spacer() + } + .padding(20) + .navigationTitle(spec.title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Close") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } + + private func chart(_ points: [(t: Double, v: Double)], color: Color) -> some View { + let end = site.nowMs + return Chart { + ForEach(points, id: \.t) { p in + LineMark(x: .value("Time", Date(timeIntervalSince1970: p.t / 1000)), y: .value("Watts", p.v)) + .foregroundStyle(color) + .interpolationMethod(.monotone) + } + if spec.signed { + RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) + } + } + .chartXScale(domain: Date(timeIntervalSince1970: (end - 120_000) / 1000)...Date(timeIntervalSince1970: end / 1000)) + .chartXAxis(.hidden) + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel { + if let w = value.as(Double.self) { Text(PowerFormat.scale(w)).font(Theme.number(10, weight: .regular)) } + } + } + } + } +} diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift new file mode 100644 index 0000000..1305028 --- /dev/null +++ b/appleApp/FTW/UI/NowView.swift @@ -0,0 +1,285 @@ +import FTWKit +import SwiftUI + +/// Now, the first screen. A glance: one sentence at the top, the house +/// under it, then price, what happens next, today and the fuse. +struct NowView: View { + let app: AppModel + let home: HomeModels + let openCharger: (String?) -> Void + let open: (HomeTab) -> Void + @State private var liveRole: LiveRole? + + private var site: SiteModel { home.site } + + var body: some View { + Group { + switch site.session.phase { + case .booting: + Title("Your box is starting") + Text("This can take a few minutes after an update while it tidies its database. Nothing is wrong. It will appear here as soon as it is ready.") + if let boot = site.session.boot { + Hint("\(Self.bootWords(boot.phase)) · \(boot.pct)%") + } + case .terminated: + Title("Access ended") + Text(site.session.terminated == .revoked ? "Your access to this home was withdrawn by its owner." : "This session ended.") + if site.session.terminated == .revoked { + Button("Your box won't let this phone in?") { app.recovering = true }.buttonStyle(.quiet) + } + default: + if site.session.fields.isEmpty { + nothingYet + } else { + house + } + } + } + .onAppear { + home.status.activate() + home.prices.activate() + home.plan.activate() + home.savings.activate() + } + .onDisappear { + home.status.deactivate() + home.prices.deactivate() + home.plan.deactivate() + home.savings.deactivate() + } + .sheet(item: $liveRole) { role in + LiveSheet(site: site, role: role, fields: flowFields) + } + } + + /// Paired, and not one reading has ever arrived. A hollow house would + /// read as a home at zero, so this says what is true instead. + @ViewBuilder private var nothingYet: some View { + Title("Nothing from your box yet") + Text(app.connectHelp ?? "This phone is paired to it and keeps trying on its own. Your house appears here as soon as the box answers.") + if app.connectHelp != nil { + Button("Get this phone back in") { app.recovering = true }.buttonStyle(.primary) + } else if !app.isDemo { + Button("Your box won't let this phone in?") { app.recovering = true }.buttonStyle(.quiet) + } + } + + // MARK: The house + + private var live: Bool { site.isLive } + + private var flowFields: [Int: Double] { + Flow.withLoadpointEV(site.session.fields, evW: home.charging.chargeW) + } + + private var watchingStatus: Bool { + site.documentVisible && site.session.phase == .streaming && site.hasPassthrough + } + + private var statusLive: Bool { home.status.fresh && live && watchingStatus } + + private var readings: Flow.Readings { + if let status = home.status.status, statusLive || !live { + return Flow.readings(status: status) + } + return Flow.readings(fields: flowFields) + } + + @ViewBuilder private var house: some View { + if let help = app.connectHelp { + Card { + Text(help) + Button("Get this phone back in") { app.recovering = true }.buttonStyle(.quiet) + } + } + if site.session.needsUpdate { + Card { Text("This app is older than your box. Some things are hidden until it updates.") } + } + Text(Explanation.explain(fields: flowFields, dispatchBlockedBy: site.session.dispatchBlockedBy, ceilingW: site.ceilingW).headline) + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + + // Motion claims power is flowing right now, so a cached view holds still. + FlowDiagram(readings: readings, moving: live) { role in + switch role { + case .ev: openCharger(nil) + case .grid: if live { liveRole = .grid } + case .pv: if live { liveRole = .pv } + case .battery: if live { liveRole = .battery } + case .load: if live { liveRole = .load } + } + } + if home.status.status != nil, !statusLive { + Hint("Device details are out of date.\(live ? " Showing live totals." : "")") + } + NowOutlook(site: site, home: home, statusLive: statusLive, open: open) + } + + static func bootWords(_ phase: String) -> String { + switch phase { + case "vacuum": return "tidying its records" + case "migrate": return "bringing its records up to date" + case "drivers": return "waking the equipment" + default: return "getting ready" + } + } +} + +/// Price, what FTW does next, today and the fuse: the rest of a glance. +private struct NowOutlook: View { + let site: SiteModel + let home: HomeModels + let statusLive: Bool + let open: (HomeTab) -> Void + + var body: some View { + if let prices = home.prices.prices { + Card { + PriceChart(prices: prices, nowMs: site.nowMs, compact: true) + if home.prices.hasHole { + Hint("Some hours are missing their price.") + } else if prices.stale { + Hint("Tomorrow's rates aren't published yet.") + } + } + } + planCard + if let today = today { + todayCard(today) + } + if let status = home.status.status, let fuse = Flow.fuse(status: status) { + fuseCard(fuse) + } + } + + private var planCard: some View { + let brief = PlanText.brief(home.plan.plan, nowMs: site.nowMs, mode: home.plan.actualMode, dispatchBlockedBy: site.session.dispatchBlockedBy, clock: Clock.time) + return Card { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Kicker("Automation") + Text("What FTW does next").font(.headline) + } + Spacer() + Text(brief.stateLabel) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .foregroundStyle(brief.tone == .active ? Theme.exporting : brief.tone == .warn ? Theme.generation : Theme.fgDim) + .background(Theme.surfaceSunken, in: Capsule()) + } + Text(brief.action).font(.body.weight(.semibold)) + if let time = brief.time { Hint(time) } + if let reason = brief.reason { Hint("\(reason).") } + Hint(brief.constraint, tone: Theme.fgMuted) + Button("Open full plan →") { open(.plan) }.buttonStyle(.quiet) + } + } + + private struct Today { + let importWh: Double + let exportWh: Double + let pvWh: Double + } + + private var today: Today? { + guard let t = home.status.status?["energy"]?["today"], t.object != nil else { return nil } + return Today(importWh: EnergyFormat.wholeWh(t["import_wh"]?.number), exportWh: EnergyFormat.wholeWh(t["export_wh"]?.number), pvWh: EnergyFormat.wholeWh(t["pv_wh"]?.number)) + } + + private func todayCard(_ today: Today) -> some View { + Card { + Kicker(statusLive ? "Since midnight" : "Last known") + Text(statusLive ? "Today" : "Last totals").font(.headline) + if !statusLive, let at = home.status.receivedAtMs { + Hint("Energy totals last updated \(Clock.dayAndTime(at)).") + } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) { + EnergyTile(label: "Imported", wh: today.importWh, color: Theme.importing) + EnergyTile(label: "Exported", wh: today.exportWh, color: Theme.exporting) + EnergyTile(label: "Solar", wh: today.pvWh, color: Theme.generation) + if let savings = home.savings.periods { + VStack(alignment: .leading, spacing: 2) { + Text("Saved \(home.prices.currency)").font(.caption).foregroundStyle(Theme.fgDim) + if savings.today.available { + Text(Savings.compact(savings.today.savedMinor)) + .font(Theme.number(20)) + .foregroundStyle(savings.today.savedMinor >= 0 ? Theme.exporting : Theme.importing) + } else { + Text("—").font(Theme.number(20)) + } + if savings.week.available { + Text("\(Savings.compact(savings.week.savedMinor)) this week").font(.caption).italic().foregroundStyle(Theme.fgDim) + } + } + } + } + Button("Open history →") { open(.history) }.buttonStyle(.quiet) + } + } + + private func fuseCard(_ fuse: Flow.Fuse) -> some View { + Card { + Kicker(statusLive ? "Live safety" : "Last known") + Text("Fuse").font(.headline) + if !statusLive, let at = home.status.receivedAtMs { + Hint("Fuse readings last updated \(Clock.dayAndTime(at)).") + } + if !fuse.phases.isEmpty { + ForEach(fuse.phases) { phase in + FuseBar(label: phase.label, amps: phase.amps, pct: phase.pct, exporting: phase.exporting) + } + } else if let fallback = fuse.fallback { + FuseBar(label: "\(Int(fuse.maxAmps)) A", amps: fallback.amps, pct: fallback.pct, exporting: false) + } + } + } +} + +struct EnergyTile: View { + let label: String + let wh: Double + let color: Color + + var body: some View { + let parts = EnergyFormat.parts(wh) + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(Theme.fgDim) + (Text(parts.text).font(Theme.number(20)) + Text(" \(parts.unit)").font(.caption)) + .foregroundStyle(color) + } + } +} + +private struct FuseBar: View { + let label: String + let amps: Double + let pct: Double + let exporting: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label).font(.caption.weight(.semibold)).foregroundStyle(Theme.fgDim) + Spacer() + Text("\(PowerFormat.fixed(abs(amps), 1)) A").font(Theme.number(13)) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Theme.surfaceSunken) + Capsule().fill(color).frame(width: geo.size.width * min(1, max(0, pct / 100))) + } + } + .frame(height: 6) + } + .accessibilityElement(children: .combine) + } + + private var color: Color { + if pct >= 90 { return Theme.importing } + if pct >= 70 { return Theme.generation } + return exporting ? Theme.exporting : Theme.storage + } +} diff --git a/appleApp/FTW/UI/PairView.swift b/appleApp/FTW/UI/PairView.swift new file mode 100644 index 0000000..b326090 --- /dev/null +++ b/appleApp/FTW/UI/PairView.swift @@ -0,0 +1,204 @@ +import FTWKit +import SwiftUI +import UniformTypeIdentifiers + +/// Pairing, the first thing anyone sees. Scan, then Face ID. Everything that +/// can fail does so before the passkey prompt. +struct PairView: View { + let app: AppModel + @State private var model: PairModel + @State private var importing = false + + init(app: AppModel) { + self.app = app + _model = State(initialValue: PairModel(app: app)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + content + } + .padding(20) + .frame(maxWidth: 520, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + .fileImporter(isPresented: $importing, allowedContentTypes: [.image]) { result in + read(result) + } + } + + /// A screen reached from a home says what this phone cannot do. + private var problem: String? { app.recovering ? app.connectHelp : nil } + private var canDismiss: Bool { app.recovering } + + @ViewBuilder private var content: some View { + switch model.stage { + case .scanning: + QRScanner { code in Task { await model.pair(code) } } + .frame(height: 360) + .clipShape(RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: 24).strokeBorder(Theme.accent, lineWidth: 3).padding(60)) + Hint("In your box's local dashboard, open Settings → FTW app. Hold the pairing QR inside the frame.") + Button("Cancel") { model.cancel() }.buttonStyle(.quiet) + + case .pairing: + Title("Securing this phone") + Text("Confirm with Face ID or Touch ID if your phone asks.") + Button("Cancel") { model.cancel() }.buttonStyle(.quiet) + + case .demoing: + Title("Starting the demo") + Text("Loading a simulated home.") + + case .recovering: + Title("Checking") + Text("Asking your passkey what it can open.") + Button("Cancel") { model.cancel() }.buttonStyle(.quiet) + + case .choosing: + Title(model.recovered.count == 1 ? "Your home is here" : "Pick a home to open") + Text("Your passkey opened a sealed copy. Nothing was sent to your box.") + ForEach(model.recovered) { home in + Button { model.adopt(home) } label: { + Text("Open \(home.label) ") + Text(home.fingerprint).font(Theme.number(15)) + } + .buttonStyle(.primary) + } + Button("Not now") { model.cancel() }.buttonStyle(.quiet) + + case .intro: + if let fingerprint = model.offeredFingerprint { + offer(fingerprint) + } else { + intro + } + } + } + + /// A link that arrived from outside, shown with the box it names before + /// anything trusts it. + @ViewBuilder private func offer(_ fingerprint: String) -> some View { + let known = model.known + Title(known != nil ? "Connect to a different box?" : "Connect this box?") + Group { + if let known { + Text("This link points at box ") + Text(fingerprint).font(Theme.number(16)) + Text(". Connecting it replaces \(known.label) as the home this app shows and controls. Your key for \(known.label) stays on this phone.") + } else { + Text("This link points at box ") + Text(fingerprint).font(Theme.number(16)) + Text(". Only continue if you just opened Settings → FTW app and chose Show pairing code in this box's local dashboard.") + } + } + if let message = model.message { Problem(message) } + Button(known != nil ? "Connect \(fingerprint)" : "Connect this box") { + Task { await model.acceptOffer() } + } + .buttonStyle(.primary) + Button("Not now") { model.declineOffer() }.buttonStyle(.quiet) + } + + @ViewBuilder private var intro: some View { + Title(problem != nil ? "Get this phone back in" : model.canOpen ? "Welcome back" : "Connect FTW") + if let problem { + Text(problem) + } else if model.canOpen { + Text("Your key is still on this phone. Nothing to set up again.") + } else { + Text("Connect your own box, open a home saved with your passkey, or try a live simulated home first.") + } + if let message = model.message { Problem(message) } + + if problem == nil, !canDismiss, !model.canOpen { + demoOffer + } + + if model.canOpen, let known = model.known { + Button("Open \(known.label)") { model.openKnown() }.buttonStyle(.primary) + Button("Scan a new pairing QR") { model.stage = .scanning }.buttonStyle(.quiet) + Hint("Use a new QR from Settings → FTW app if this key no longer works.") + } else { + setup + Button { Task { await model.recover() } } label: { + VStack(alignment: .leading, spacing: 4) { + Text("Open with your passkey").font(.body.weight(.semibold)).foregroundStyle(Theme.fg) + Text("Used FTW before? Ask Face ID or Touch ID for a saved home.").font(.footnote).foregroundStyle(Theme.fgDim) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.outline) + } + + if canDismiss { + Button("Not now") { model.dismiss() }.buttonStyle(.quiet) + } + } + + private var demoOffer: some View { + Card { + Kicker("Interactive demo") + Text("See a home running").font(.title3.weight(.semibold)) + Text("Explore live solar, battery, grid, EV charging, plans and history. The data is simulated and nothing is saved.") + .foregroundStyle(Theme.fgDim) + Button("Try the live demo") { model.tryDemo() }.buttonStyle(.primary) + } + } + + private var setup: some View { + Card { + Text("Connect your own box").font(.title3.weight(.semibold)) + Hint("The QR code is inside FTW Settings. It is not printed on the Raspberry Pi or its case.") + VStack(alignment: .leading, spacing: 6) { + Text("1. Open your box's local FTW dashboard while on your home network.") + Text("2. Go to Settings → FTW app.") + Text("3. Choose Show pairing code, then scan the QR here.") + } + .font(.callout) + Hint("Next, a supported device asks for Face ID or Touch ID to protect your FTW key. There is no FTW account or password. If that passkey supports recovery and the save reaches Sourceful, FTW keeps a sealed recovery copy that Sourceful cannot open. If not, pairing still works; a new Settings QR is the way back.") + Button("Scan the pairing QR") { model.stage = .scanning }.buttonStyle(.primary) + #if os(macOS) + Button("Read the QR from a picture") { importing = true }.buttonStyle(.quiet) + Hint("On a Mac, a screenshot of the code works as well as the camera.") + #endif + DisclosureGroup("Can't see Show pairing code?") { + Hint("Turn on Let the FTW app connect to this box, save, and restart the box first.") + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.footnote) + } + } + + private func read(_ result: Result) { + guard case .success(let url) = result else { return } + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + if let code = QRImage.pairingCode(at: url) { + Task { await model.pair(code) } + } else { + model.noCodeFound() + } + } +} + +struct Title: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text) + .font(.largeTitle.weight(.bold)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } +} + +struct Problem: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text) + .font(.callout) + .foregroundStyle(Theme.importing) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift new file mode 100644 index 0000000..db05cf9 --- /dev/null +++ b/appleApp/FTW/UI/PlanView.swift @@ -0,0 +1,204 @@ +import FTWKit +import SwiftUI + +/// What the box means to do, how the home is run, and what power costs. +struct PlanView: View { + let home: HomeModels + @State private var showAdvanced = false + + private var plan: PlanModel { home.plan } + private var site: SiteModel { home.site } + + var body: some View { + Group { + Text(PlanText.headline(plan.plan, nowMs: site.nowMs).text) + .font(.title3.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + if let problem = plan.problem { Problem(problem) } + + modes + + if let prices = home.prices.prices { + Card { + PriceChart(prices: prices, nowMs: site.nowMs) + if home.prices.hasHole { + Hint("Some hours are missing their price.") + } else if prices.stale { + Hint("Tomorrow's rates aren't published yet.") + } + } + } + + timeline + } + .onAppear { + plan.activate() + home.prices.activate() + } + .onDisappear { + plan.deactivate() + home.prices.deactivate() + } + } + + // MARK: How the home is run + + @ViewBuilder private var modes: some View { + Kicker("How your home is run") + if plan.inManual, let planHome = plan.planHome { + Card { + Text("The plan is not running the battery.") + if plan.canControl { + Button(sendingMode == planHome.key ? "Sending…" : "Use the plan") { choose(planHome.key) } + .buttonStyle(.primary) + .disabled(isSending) + } + } + } + ForEach(plan.primaryModes) { choice($0) } + if !plan.advancedModes.isEmpty { + if showAdvanced { + ForEach(plan.advancedModes) { choice($0) } + Button("Fewer options") { showAdvanced = false }.buttonStyle(.quiet) + } else { + if let selected = plan.advancedModes.first(where: { $0.key == plan.shownMode }) { + choice(selected) + } + Button("More ways to run it") { showAdvanced = true }.buttonStyle(.quiet) + } + } + status + } + + private var isSending: Bool { + if case .sending = plan.command { return true } + return false + } + + private var sendingMode: String? { + if case .sending(let m) = plan.command { return m } + return nil + } + + private func choose(_ mode: String) { + Task { await plan.setMode(mode) } + if plan.advancedModes.contains(where: { $0.key == mode }) { showAdvanced = false } + } + + private func choice(_ info: ModeInfo) -> some View { + let pressed = plan.shownMode == info.key + return Button { choose(info.key) } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(PlanText.label(info)).font(.body.weight(.semibold)).foregroundStyle(Theme.fg) + Spacer() + if sendingMode == info.key { + Text("Sending…").font(.caption).foregroundStyle(Theme.fgDim) + } else if pressed { + Text("In use").font(.caption.weight(.semibold)).foregroundStyle(Theme.accent) + } + } + Text(PlanText.help(info)) + .font(.footnote) + .foregroundStyle(Theme.fgDim) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(pressed ? Theme.accent : Theme.line, lineWidth: pressed ? 2 : 1)) + } + .buttonStyle(.plain) + .disabled(!plan.canControl || isSending) + .accessibilityAddTraits(pressed ? .isSelected : []) + } + + /// One line, in the band's voice. Never a modal. + @ViewBuilder private var status: some View { + switch plan.command { + case .sending: + Hint("Sending…") + case .applied: + Hint("Done.", tone: Theme.exporting) + case .unconfirmed: + Hint("Your box took it, but hasn't confirmed yet. It'll show here when it does.", tone: Theme.generation) + case .failed(let help): + Hint(help, tone: Theme.generation) + case .idle: + switch plan.whyNoControl { + case .role: Hint("You have view-only access, so this is the owner's to change.") + case .box: Hint("This box doesn't support changing how it runs.") + case nil: EmptyView() + } + } + } + + // MARK: The next twelve hours + + @ViewBuilder private var timeline: some View { + let now = site.nowMs + let slots = Array((plan.plan?.slots ?? []).filter { $0.startMs + $0.durationMs > now }.prefix(48)) + if !slots.isEmpty { + let peak = max(1, slots.map { abs($0.batteryW) }.max() ?? 1) + let currency = home.prices.currency + HStack { + Kicker("Next 12 hours") + Spacer() + Text("to import, \(PriceUnits.unit(currency).perKwh)").font(.caption).foregroundStyle(Theme.fgMuted) + } + VStack(spacing: 0) { + ForEach(slots) { slot in + SlotRow(slot: slot, peakW: peak, isNow: now >= slot.startMs && now < slot.startMs + slot.durationMs, currency: currency) + } + } + } else if plan.loading { + Hint("Asking your box…") + } + } +} + +private struct SlotRow: View { + let slot: PlanSlot + let peakW: Double + let isNow: Bool + let currency: String + + var body: some View { + let action = PlanText.action(slot) + let parts = PowerFormat.parts(slot.batteryW) + HStack(spacing: 10) { + Text(Clock.time(slot.startMs)) + .font(Theme.number(13, weight: isNow ? .bold : .regular)) + .frame(width: 52, alignment: .leading) + GeometryReader { geo in + Capsule() + .fill(action == .charge ? Theme.storage : action == .discharge ? Theme.accent : Theme.fgMuted) + .frame(width: max(2, geo.size.width * abs(slot.batteryW) / peakW)) + .frame(maxHeight: .infinity) + } + .frame(width: 60, height: 6) + Group { + if action == .idle { + Text("resting").foregroundStyle(Theme.fgDim) + } else { + Text(parts.text).font(Theme.number(13)) + Text(" \(parts.unit) \(action == .charge ? "in" : "out")").foregroundStyle(Theme.fgDim) + } + } + .font(.footnote) + .frame(width: 90, alignment: .leading) + Text(PlanText.reason(slot.reason)) + .font(.footnote) + .foregroundStyle(Theme.fgDim) + .lineLimit(1) + Spacer(minLength: 4) + if let price = PriceUnits.text(slot.priceMinor, currency) { + Text(price).font(Theme.number(12, weight: .regular)).foregroundStyle(Theme.fgDim) + } + } + .padding(.vertical, 7) + .padding(.horizontal, 8) + .background(isNow ? Theme.surfaceRaised : Color.clear, in: RoundedRectangle(cornerRadius: 8)) + .accessibilityElement(children: .combine) + } +} diff --git a/appleApp/FTW/UI/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift new file mode 100644 index 0000000..1c69a01 --- /dev/null +++ b/appleApp/FTW/UI/PriceChart.swift @@ -0,0 +1,125 @@ +import Charts +import FTWKit +import SwiftUI + +/// Prices ahead, as bars from zero. Compact on Now: the price this moment +/// and the cheapest two hours. Full on Plan: every slot of today and +/// tomorrow with the moment marked. +struct PriceChart: View { + let prices: Prices + let nowMs: Double + var compact = false + + var body: some View { + let unit = PriceUnits.unit(prices.currency) + let summary = PriceStrip.summary(prices, nowMs: nowMs) + VStack(alignment: .leading, spacing: 10) { + if compact { + VStack(alignment: .leading, spacing: 2) { + Kicker("Market now") + Text("Electricity price").font(.headline) + } + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + (Text(PriceStrip.text(summary.current?.totalMinor, prices.currency)).font(Theme.number(28, weight: .bold)) + Text(" \(unit.perKwh)").font(.caption)) + Text("\(prices.zone.isEmpty ? "—" : prices.zone) · incl. fees and VAT").font(.caption).foregroundStyle(Theme.fgMuted) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("Cheapest 2 h").font(.caption).foregroundStyle(Theme.fgDim) + if let block = summary.cheapest { + Text("\(PriceStrip.text(block.meanMinor, prices.currency)) \(unit.label)").font(Theme.number(15)).foregroundStyle(Theme.exporting) + Text("\(Clock.time(block.startMs))–\(Clock.time(block.endMs))").font(.caption).foregroundStyle(Theme.fgDim) + } else { + Text("No 2 h window published").font(.caption).foregroundStyle(Theme.fgDim) + } + } + } + .accessibilityElement(children: .combine) + if summary.bars.isEmpty { + Hint("No prices published ahead yet.") + } else { + strip(summary, unit: unit) + .frame(height: 64) + if let mean = summary.meanMinor { + Hint("Dotted line: average ahead, \(PriceStrip.text(mean, prices.currency)) \(unit.label)", tone: Theme.fgMuted) + } + } + } else { + HStack { + Text("Price to import").font(.headline) + Spacer() + Text(unit.perKwh).font(.caption).foregroundStyle(Theme.fgMuted) + } + full(unit: unit) + .frame(height: 180) + } + } + } + + private func strip(_ summary: PriceStrip.Summary, unit: PriceUnits.Unit) -> some View { + Chart { + ForEach(summary.bars) { bar in + BarMark( + xStart: .value("From", date(bar.startMs)), + xEnd: .value("To", date(bar.endMs)), + y: .value("Price", PriceUnits.display(bar.minor, prices.currency)) + ) + .foregroundStyle(color(bar.tone)) + .opacity(bar.current ? 1 : 0.8) + } + if let mean = summary.meanMinor { + RuleMark(y: .value("Average", PriceUnits.display(mean, prices.currency))) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3])) + .foregroundStyle(Theme.fgMuted) + } + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .accessibilityLabel("Upcoming prices as bars from zero. The dotted line marks the average ahead.") + } + + private func full(unit: PriceUnits.Unit) -> some View { + Chart { + ForEach(prices.slots) { slot in + let past = slot.startMs + slot.durationMs <= nowMs + let current = nowMs >= slot.startMs && nowMs < slot.startMs + slot.durationMs + BarMark( + xStart: .value("From", date(slot.startMs)), + xEnd: .value("To", date(slot.startMs + slot.durationMs)), + y: .value("Price", PriceUnits.display(slot.totalMinor, prices.currency)) + ) + .foregroundStyle(current ? Theme.accent : past ? Theme.fgMuted.opacity(0.35) : Theme.storage) + } + RuleMark(x: .value("Now", date(nowMs))) + .foregroundStyle(Theme.accent) + .lineStyle(StrokeStyle(lineWidth: 1)) + .annotation(position: .top, alignment: .leading) { + Text("now").font(Theme.number(10)).foregroundStyle(Theme.accent) + } + } + .chartXAxis { + AxisMarks(values: .stride(by: .hour, count: 6)) { _ in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel(format: .dateTime.hour()) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel() + } + } + } + + private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } + + private func color(_ tone: PriceStrip.Tone) -> Color { + switch tone { + case .dear: return Theme.importing + case .cheap: return Theme.exporting + case .flat: return Theme.fgMuted + case .negative: return Theme.generation + } + } +} diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift new file mode 100644 index 0000000..bfda663 --- /dev/null +++ b/appleApp/FTW/UI/RootView.swift @@ -0,0 +1,213 @@ +import FTWKit +import SwiftUI + +/// The shell: pairing when there is nothing to show, else one home in four +/// screens. Paints from what is on the phone and never waits on the network. +struct RootView: View { + let app: AppModel + + var body: some View { + Group { + if app.needsPairing { + // A new link is a new offer, and gets a fresh screen. + PairView(app: app) + .id(app.offeredLink ?? "") + } else if let home = app.home { + HomeView(app: app, home: home) + } + } + .background(Theme.surface.ignoresSafeArea()) + .tint(Theme.accent) + } +} + +enum HomeTab: String, Hashable { + case now, plan, history, box + + /// Where a home opens. Debug builds take `-FTWTab plan` and the like, so + /// CI can photograph every screen of the demo. + static var initial: HomeTab { + #if DEBUG + return UserDefaults.standard.string(forKey: "FTWTab").flatMap(HomeTab.init(rawValue:)) ?? .now + #else + return .now + #endif + } +} + +/// Which charger's sheet is open. Nil id means every charger. +struct ChargerRequest: Identifiable, Equatable { + let loadpointID: String? + var id: String { loadpointID ?? "*" } +} + +struct HomeView: View { + let app: AppModel + let home: HomeModels + @State private var tab: HomeTab = .initial + @State private var charger: ChargerRequest? + + var body: some View { + VStack(spacing: 0) { + if app.isDemo { + DemoBand(site: home.site) { app.exitDemo() } + } else { + FreshnessBandView(band: home.site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: home.site.lastFrameAtMs) + } + ChargingNotice(site: home.site, charging: home.charging) { id in + tab = .now + charger = ChargerRequest(loadpointID: id) + } + TabView(selection: $tab) { + Tab("Now", systemImage: "house", value: HomeTab.now) { + Screen(app: app) { + NowView(app: app, home: home, openCharger: { charger = ChargerRequest(loadpointID: $0) }, open: { tab = $0 }) + } + } + Tab("Plan", systemImage: "calendar", value: HomeTab.plan) { + Screen(app: app) { PlanView(home: home) } + } + Tab("History", systemImage: "chart.xyaxis.line", value: HomeTab.history) { + Screen(app: app) { HistoryView(home: home) } + } + Tab("Box", systemImage: "shippingbox", value: HomeTab.box) { + Screen(app: app) { + if app.isDemo { + DemoBoxView(site: home.site) { app.exitDemo() } + } else { + BoxView(app: app, home: home) + } + } + } + } + .tabViewStyle(.sidebarAdaptable) + } + .sheet(item: $charger) { request in + EVSheet(site: home.site, model: home.loadpoints, loadpointID: request.loadpointID) + } + .onAppear { home.charging.activate() } + .onDisappear { home.charging.deactivate() } + } +} + +/// One tab's scrolling page, with pull to refresh. +private struct Screen: View { + let app: AppModel + @ViewBuilder var content: Content + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { content } + .padding(16) + .frame(maxWidth: 640, alignment: .leading) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + .background(Theme.surface) + .refreshable { await app.refresh() } + } +} + +/// The one place freshness is said. Above every screen, never scrolled away. +struct FreshnessBandView: View { + let band: Freshness.Band + let frameAtMs: Double? + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + .phaseAnimator([false, true], trigger: frameAtMs) { dot, beat in + dot.scaleEffect(band.tone == .live && beat ? 1.5 : 1) + } animation: { _ in .easeOut(duration: 0.3) } + Text(band.message) + .font(.footnote.weight(.medium)) + .foregroundStyle(Theme.fg) + .lineLimit(1) + .minimumScaleFactor(0.8) + Spacer(minLength: 4) + if let wait = band.wait { + Text(wait) + .font(Theme.number(12, weight: .regular)) + .foregroundStyle(Theme.fgMuted) + .accessibilityHidden(true) + } + if let age = band.age { + Text(age) + .font(Theme.number(12, weight: .regular)) + .foregroundStyle(age == "—" ? Theme.fgMuted : Theme.fgDim) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Theme.surfaceSunken.ignoresSafeArea(edges: .top)) + .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } + .accessibilityElement(children: .combine) + } + + private var color: Color { + switch band.tone { + case .live: return Theme.freshLive + case .stale: return Theme.freshStale + case .reaching, .lost: return Theme.freshLost + } + } +} + +/// The demo says it is a demo, everywhere, and offers the way out. +struct DemoBand: View { + let site: SiteModel + let exit: () -> Void + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(site.session.phase == .streaming ? Theme.accent : Theme.fgMuted) + .frame(width: 8, height: 8) + Text(site.session.phase == .streaming ? "Live demo · simulated home" : "Starting the demo") + .font(.footnote.weight(.medium)) + Spacer() + Button("Exit demo", action: exit) + .font(.footnote.weight(.semibold)) + .buttonStyle(.quiet) + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(Theme.surfaceSunken.ignoresSafeArea(edges: .top)) + .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } + } +} + +/// A car on the cable, said once above every screen, with a way into its +/// sheet. Only what the box said, and dated when it is out of date. +struct ChargingNotice: View { + let site: SiteModel + let charging: ChargingWatch + let open: (String) -> Void + + var body: some View { + let fresh = charging.fresh && site.session.phase == .streaming + ForEach(site.heardFromBox ? charging.points.filter(\.pluggedIn) : []) { lp in + let current = fresh && lp.charger?.available != false + VStack(alignment: .leading, spacing: 4) { + Text(current ? "Car connected" : "Car status is out of date") + .font(.subheadline.weight(.semibold)) + Hint(current ? EVText.status(lp, canControl: site.canConfigure) : "Waiting for current charger status. The last reading cannot confirm charging.", tone: Theme.fg) + if current, let plan = EVText.plan(lp, nowMs: site.nowMs, canControl: site.canConfigure) { + Hint(plan) + } + if lp.manualSaveError { Hint(EVText.manualSaveErrorText) } + Button("Check charging\(lp.socSource != "vehicle" ? " and battery level" : "")") { open(lp.id) } + .buttonStyle(.quiet) + .font(.footnote.weight(.semibold)) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised) + .overlay(alignment: .leading) { Theme.mobility.frame(width: 3) } + .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } + } + } +} diff --git a/appleApp/FTW/UI/Theme.swift b/appleApp/FTW/UI/Theme.swift new file mode 100644 index 0000000..fea21ef --- /dev/null +++ b/appleApp/FTW/UI/Theme.swift @@ -0,0 +1,207 @@ +import FTWKit +import SwiftUI + +/// The web app's design roles (src/styles/tokens.css), light and dark, so the +/// native app and the box's own page look like one product. Views read +/// roles, never raw colours. +enum Theme { + static let surface = pair(0xF4F4F2, 0x0D0D0D) + static let surfaceSunken = pair(0xECECE8, 0x101010) + static let surfaceRaised = pair(0xFAFAF8, 0x161616) + static let surfaceElevated = pair(0xFFFFFF, 0x1E1E1E) + static let line = pair(0xCECEC7, 0x2A2A2A) + static let fg = pair(0x191919, 0xE8E8E8) + static let fgDim = pair(0x4F4F4B, 0xA0A0A0) + static let fgMuted = pair(0x686862, 0x858585) + static let onAccent = pair(0x0A0A0A, 0x0A0A0A) + + static let accent = pair(0xDA7F00, 0xFFAC41) + static let importing = pair(0xCC243D, 0xFF6E74) + static let exporting = pair(0x009639, 0x5FD37F) + static let generation = pair(0xD48500, 0xFFB000) + static let storage = pair(0x008FA8, 0x13DCF6) + static let mobility = pair(0x7F5BB6, 0xCCA8FF) + + static let freshLive = exporting + static let freshStale = generation + static let freshLost = fgMuted + + static let radius: CGFloat = 14 + static let radiusSmall: CGFloat = 10 + + static func color(_ tone: Flow.Tone) -> Color { + switch tone { + case .muted: return fgMuted + case .importing: return importing + case .exporting: return exporting + case .solar: return generation + case .battery, .charging, .discharging: return storage + case .ev: return mobility + case .house: return accent + } + } + + /// Mono figures, as the web app sets every number, so a value that + /// changes every second does not shift the line under the reader's eye. + static func number(_ size: CGFloat, weight: Font.Weight = .semibold) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } + + private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { + #if os(iOS) + return Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) + #else + return Color(NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? NSColor(rgb: dark) : NSColor(rgb: light) + }) + #endif + } +} + +#if os(iOS) +private extension UIColor { + convenience init(rgb: UInt32) { + self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) + } +} +#else +private extension NSColor { + convenience init(rgb: UInt32) { + self.init(srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) + } +} +#endif + +/// A raised card, the web app's `.card`. +struct Card: View { + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { content } + .padding(.vertical, 14) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + } +} + +/// The small caps line over a card title. +struct Kicker: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text.uppercased()) + .font(.caption2.weight(.semibold)) + .tracking(0.8) + .foregroundStyle(Theme.fgMuted) + } +} + +/// A sentence under a control: what happened, or what to do. +struct Hint: View { + let text: String + var tone: Color = Theme.fgDim + init(_ text: String, tone: Color = Theme.fgDim) { + self.text = text + self.tone = tone + } + + var body: some View { + Text(text) + .font(.footnote) + .foregroundStyle(tone) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// The web app's three button weights. +struct FTWButtonStyle: ButtonStyle { + enum Kind { case primary, quiet, outline, danger } + var kind: Kind + @Environment(\.isEnabled) private var enabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.body.weight(kind == .quiet ? .regular : .semibold)) + .padding(.vertical, kind == .quiet ? 6 : 11) + .padding(.horizontal, kind == .quiet ? 4 : 16) + .frame(maxWidth: kind == .primary || kind == .danger ? .infinity : nil) + .foregroundStyle(foreground) + .background(background, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .overlay { + if kind == .outline { + RoundedRectangle(cornerRadius: Theme.radiusSmall).strokeBorder(Theme.line, lineWidth: 1) + } + } + .opacity(enabled ? (configuration.isPressed ? 0.7 : 1) : 0.45) + .contentShape(Rectangle()) + } + + private var foreground: Color { + switch kind { + case .primary: return Theme.onAccent + case .danger: return .white + case .quiet: return Theme.accent + case .outline: return Theme.fg + } + } + + private var background: Color { + switch kind { + case .primary: return Theme.accent + case .danger: return Theme.importing + case .quiet, .outline: return .clear + } + } +} + +extension ButtonStyle where Self == FTWButtonStyle { + static var primary: FTWButtonStyle { FTWButtonStyle(kind: .primary) } + static var quiet: FTWButtonStyle { FTWButtonStyle(kind: .quiet) } + static var outline: FTWButtonStyle { FTWButtonStyle(kind: .outline) } + static var danger: FTWButtonStyle { FTWButtonStyle(kind: .danger) } +} + +/// A pressed-button choice between a few options, the web app's range picker. +struct Segments: View { + let options: [T] + let selected: T + let label: (T) -> String + let choose: (T) -> Void + + var body: some View { + HStack(spacing: 4) { + ForEach(options, id: \.self) { option in + Button { choose(option) } label: { + Text(label(option)) + .font(.footnote.weight(.semibold)) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .foregroundStyle(option == selected ? Theme.onAccent : Theme.fgDim) + .background(option == selected ? Theme.accent : Color.clear, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(option == selected ? .isSelected : []) + } + } + .padding(3) + .background(Theme.surfaceSunken, in: Capsule()) + } +} + +/// Clock time in the phone's own style. +enum Clock { + static func time(_ ms: Double) -> String { + Date(timeIntervalSince1970: ms / 1000).formatted(date: .omitted, time: .shortened) + } + + static func dayAndTime(_ ms: Double) -> String { + Date(timeIntervalSince1970: ms / 1000).formatted(.dateTime.day().month(.abbreviated).hour().minute()) + } + + static func day(_ ms: Double) -> String { + Date(timeIntervalSince1970: ms / 1000).formatted(date: .long, time: .omitted) + } +} diff --git a/appleApp/FTWKit/Package.resolved b/appleApp/FTWKit/Package.resolved new file mode 100644 index 0000000..ed2adca --- /dev/null +++ b/appleApp/FTWKit/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "887bf9d08cf6d2c6849bb790dc2fb6ad11cc2f85e25edea3cd00de3a4912a469", + "pins" : [ + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "da9d28d69ebe3894b18376c8f2395c2f37b8448f", + "version" : "4.5.2" + } + } + ], + "version" : 3 +} diff --git a/appleApp/FTWKit/Package.swift b/appleApp/FTWKit/Package.swift new file mode 100644 index 0000000..f28fe2d --- /dev/null +++ b/appleApp/FTWKit/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version:6.0 +// +// FTWKit: everything the native app does that is not a pixel. Pairing, the +// passkey derivations, Noise IK, frames, the relay, the session and the +// state each screen reads. The SwiftUI app in ../FTW is a thin layer over it. +// +// It builds and tests on Linux as well as on Apple platforms, so the +// protocol can be checked anywhere Swift runs. CryptoKit provides the +// primitives on Apple platforms; swift-crypto provides the same API on +// Linux and is linked nowhere else. + +import PackageDescription + +let package = Package( + name: "FTWKit", + platforms: [.iOS(.v18), .macOS(.v15)], + products: [ + .library(name: "FTWKit", targets: ["FTWKit"]), + ], + dependencies: [ + // Below 5, whose manifest needs Swift 6.2: Xcode 16 must still + // resolve this package even though it links it nowhere. + .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0"..<"5.0.0"), + ], + targets: [ + .target( + name: "FTWKit", + dependencies: [ + .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])), + ] + ), + .testTarget( + name: "FTWKitTests", + dependencies: ["FTWKit"], + resources: [.copy("Resources")] + ), + ] +) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift new file mode 100644 index 0000000..9d7e04e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift @@ -0,0 +1,64 @@ +import Foundation + +/// How frames are reaching us. Orthogonal to `SourceState`, never merged +/// with it: "connected, but the inverter went quiet 40 seconds ago" needs +/// both. +public enum CarrierKind: String, Sendable, Codable { + case webrtc, relay, cache, none +} + +public enum CarrierStatus: Equatable, Sendable { + case connecting + case open(sinceMs: Double) + case closed(reason: String, retryable: Bool) + + public var isOpen: Bool { + if case .open = self { return true } + return false + } + + var phaseName: String { + switch self { + case .connecting: return "connecting" + case .open: return "open" + case .closed: return "closed" + } + } +} + +/// Moves opaque frames and nothing else. It cannot read a frame and has no +/// opinion about the protocol, so a LAN path can be added later as one more +/// implementation without a line changing above this. +@MainActor +public protocol Carrier: AnyObject { + var kind: CarrierKind { get } + var status: CarrierStatus { get } + func send(_ frame: Bytes) + /// Replaces the handlers. One owner at a time: the carrier stack is + /// built as a chain, each layer owning the one below it. + func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) + /// Recheck the live path after the app or network wakes. Frames are + /// never replayed. + func wake() + func close(reason: String) +} + +// MARK: - WebSockets + +/// The socket underneath the relay carrier, reduced to what it uses. +@MainActor +public protocol WebSocketConnection: AnyObject { + func send(_ data: Bytes) + func close() +} + +@MainActor +public struct WebSocketEvents { + public var onOpen: @MainActor () -> Void + public var onText: @MainActor (String) -> Void + public var onBinary: @MainActor (Bytes) -> Void + /// Close code and reason. A socket that failed to open closes with 1006. + public var onClose: @MainActor (Int, String) -> Void +} + +public typealias WebSocketFactory = @MainActor (_ url: URL, _ events: WebSocketEvents) -> WebSocketConnection diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift new file mode 100644 index 0000000..53f671c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift @@ -0,0 +1,233 @@ +import Foundation + +/// The carrier that makes every other carrier private. +/// +/// Wraps an inner carrier and runs the Noise IK handshake across it before a +/// single application frame moves. Above, an ordinary carrier; below, opaque +/// bytes. That is the whole reason the relay can be blind without the +/// session knowing a relay exists. +/// +/// Nothing here is on the first-frame path: the handshake is two round +/// trips and runs behind cached readings already on screen. +@MainActor +public final class NoiseCarrier: Carrier { + /// The box answers a refused handshake with silence, on purpose, so this + /// is the only thing that tells "not yet" from "never". + static let handshakeDeadlineMs: Double = 12_000 + /// A foreground person should never wait out the ordinary window. + static let foregroundHandshakeDeadlineMs: Double = 3_000 + static let handshakeBackoffBaseMs: Double = 3_000 + static let handshakeBackoffCapMs: Double = 60_000 + /// Message 2 of IK: an ephemeral key and one tag over an empty payload. + static let message2Bytes = Noise.dhBytes + Noise.tagBytes + /// Failures an inbound frame that is not ours can cause. The relay + /// broadcasts a box frame to every phone in its room, so these are + /// routine routing misses, not errors. + static let foreignFrameErrors: Set = ["E_NOISE_AUTH", "E_NOISE_REPLAY", "E_NOISE_MESSAGE", "E_NOISE_NONCE_EXHAUSTED"] + + private let inner: Carrier + private let staticKey: Primitives.KeyPair + private let remoteStatic: Bytes + private let prologue: Bytes + private let payload: Bytes + private let scheduler: Scheduler + + private var handshake: HandshakeState? + private var transport: NoiseTransport? + public private(set) var status: CarrierStatus = .connecting + private var closed = false + private var awaitingReply = false + private var deadline: Cancellable? + private var retry: Cancellable? + private var attempt = 0 + private var foregroundAttempt = false + + private var onFrame: @MainActor (Bytes) -> Void = { _ in } + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + + /// `handshakePayload` is the single-use pairing code, sent encrypted in + /// message 1. The box spends it once and remembers the key it came with. + public init(inner: Carrier, staticKey: Primitives.KeyPair, remoteStatic: Bytes, prologue: Bytes, handshakePayload: Bytes = [], scheduler: Scheduler) { + self.inner = inner + self.staticKey = staticKey + self.remoteStatic = remoteStatic + self.prologue = prologue + self.payload = handshakePayload + self.scheduler = scheduler + inner.setHandlers( + onFrame: { [weak self] bytes in self?.onInnerFrame(bytes) }, + onStatus: { [weak self] status in self?.onInnerStatus(status) } + ) + if inner.status.isOpen { beginHandshake() } + } + + /// Binds a session to its box, so a captured handshake cannot be + /// replayed into another one. + public static func prologue(boxStaticKey: Bytes) -> Bytes { + Array("ftw.session.v1:".utf8) + boxStaticKey + } + + public var kind: CarrierKind { inner.kind } + + public func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onFrame = onFrame + self.onStatus = onStatus + } + + public func send(_ frame: Bytes) { + // Before the handshake there is no key, and sending in the clear to + // keep a caller happy would be worse than dropping. + guard let transport, status.isOpen else { return } + do { + inner.send(try transport.encrypt(frame)) + } catch { + // Nonce exhaustion or a destroyed cipher: continuing would risk + // reusing a (key, nonce) pair. + fail("encryption failed", retryable: true) + } + } + + /// Abandon a session the OS may have frozen, and start a fresh one. + public func wake() { + if closed { return } + attempt = 0 + foregroundAttempt = true + resetSession() + retry?.cancel() + retry = nil + setStatus(.closed(reason: "reconnecting after wake", retryable: true)) + inner.wake() + if inner.status.isOpen { beginHandshake() } + } + + public func close(reason: String = "closed by client") { + if closed { return } + closed = true + deadline?.cancel() + retry?.cancel() + resetSession() + inner.close(reason: reason) + setStatus(.closed(reason: reason, retryable: false)) + onFrame = { _ in } + onStatus = { _ in } + } + + private func onInnerStatus(_ s: CarrierStatus) { + if closed { return } + if s.isOpen { + attempt = 0 + retry?.cancel() + retry = nil + beginHandshake() + return + } + // A Noise session cannot survive a gap: its keys belong to one + // handshake and its counters to one stream. So a drop restarts. + resetSession() + retry?.cancel() + retry = nil + setStatus(s) + } + + private func beginHandshake() { + if closed || awaitingReply || transport != nil { return } + // A fresh handshake per connection. Reusing one would mint a second + // cipher pair from the same chaining key. + let hs: HandshakeState + do { + hs = try HandshakeState.initiator(staticKey: staticKey, remoteStatic: remoteStatic, prologue: prologue) + } catch { + fail("the box key is not usable", retryable: false) + return + } + handshake = hs + awaitingReply = true + setStatus(.connecting) + + // A refused handshake is answered with silence, so silence needs its + // own ending or a revoked phone waits forever on an open socket. + deadline?.cancel() + let window = foregroundAttempt ? Self.foregroundHandshakeDeadlineMs : Self.handshakeDeadlineMs + foregroundAttempt = false + deadline = scheduler.after(window) { [weak self] in + guard let self, !self.closed, self.awaitingReply else { return } + self.fail("the box did not answer", retryable: true) + } + + do { + inner.send(try hs.writeMessage(payload)) + } catch { + fail("handshake failed", retryable: true) + } + } + + private func onInnerFrame(_ bytes: Bytes) { + if closed { return } + if awaitingReply { + // Only something the right shape is offered to the handshake. A + // second phone in the house starts its handshake into a running + // telemetry stream, and those frames are not message 2. + if bytes.count == Self.message2Bytes { completeHandshake(bytes) } + return + } + guard let transport else { return } + do { + let frame = try transport.decrypt(bytes) + onFrame(frame) + } catch let e as Noise.NoiseError where Self.foreignFrameErrors.contains(e.code) { + return + } catch { + return + } + } + + private func completeHandshake(_ bytes: Bytes) { + guard let hs = handshake else { return } + do { + _ = try hs.readMessage(bytes) + deadline?.cancel() + deadline = nil + transport = NoiseTransport(try hs.split()) + awaitingReply = false + handshake = nil + attempt = 0 + setStatus(.open(sinceMs: scheduler.nowMs)) + } catch { + // On a shared room another phone's frame can match the length by + // coincidence. A frame that does not open is somebody else's; the + // deadline ends a handshake that is truly going nowhere. + } + } + + private func setStatus(_ s: CarrierStatus) { + status = s + onStatus(s) + } + + /// A retryable failure on a socket that still stands is retried from + /// here, because nowhere else will: the inner carrier only re-emits open + /// after a real reconnect. + private func fail(_ reason: String, retryable: Bool) { + if closed { return } + resetSession() + setStatus(.closed(reason: reason, retryable: retryable)) + guard retryable, inner.status.isOpen else { return } + retry?.cancel() + let ceiling = min(Self.handshakeBackoffCapMs, Self.handshakeBackoffBaseMs * pow(2, Double(attempt))) + attempt = min(attempt + 1, 16) + retry = scheduler.after(scheduler.random() * ceiling) { [weak self] in + guard let self else { return } + self.retry = nil + self.beginHandshake() + } + } + + private func resetSession() { + deadline?.cancel() + deadline = nil + transport?.close() + transport = nil + handshake = nil + awaitingReply = false + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift new file mode 100644 index 0000000..3ad1336 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift @@ -0,0 +1,235 @@ +import Foundation + +/// A WebSocket to Sourceful's blind relay, wrapped so nothing above it ever +/// learns the network went away. There is no reconnect button in this app +/// and nowhere to put one: a lost connection is this file's problem, and +/// the person sees only the freshness stamp slipping while it is solved. +/// +/// Frames are never queued across a reconnect: an old instruction arriving +/// as if new is what `notValidAfterMs` exists to prevent, so sending on a +/// carrier that is not open drops the frame. The session re-handshakes and +/// asks again. +/// +/// The socket outlives the peer. When the box drops off the relay the socket +/// stays up and the carrier reports closed, then open again the moment the +/// relay says the box is back: an hour offline costs one connection. +@MainActor +public final class RelayCarrier: Carrier { + public static let closeBadJoin = 4400 + public static let closeEpoch = 4409 + public static let closeRotated = 4410 + public static let closeBusy = 4429 + static let ctrlReady = "ready" + static let ctrlGone = "gone" + + static let backoffBaseMs: Double = 500 + static let backoffCapMs: Double = 60_000 + /// Rejoin spread after a rotation, so the old handle and the new one are + /// hard to line up by timing alone. + static let rotateJitterMs: Double = 3_000 + static let correctionLimit = 2 + /// How far the relay may move our epoch. One hour covers a drifting + /// clock; it does not cover a relay steering us onto handles it chose. + static let maxEpochCorrection: Int64 = 1 + + public let kind: CarrierKind = .relay + public private(set) var status: CarrierStatus = .connecting + + private let url: URL + private let secret: Bytes + private let scheduler: Scheduler + private let makeSocket: WebSocketFactory + + private var socket: WebSocketConnection? + private var socketGeneration = 0 + private var dialledAtMs: Double = 0 + private var attempt = 0 + private var corrections = 0 + /// Epochs between the relay's clock and ours, learned from a correction. + private var epochOffset: Int64 = 0 + private var retry: Cancellable? + private var shutdown = false + public private(set) var rttMs: Double? + + private var onFrame: @MainActor (Bytes) -> Void = { _ in } + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + + public init(url: URL = Origin.relayURL, secret: Bytes, scheduler: Scheduler, makeSocket: @escaping WebSocketFactory = URLSessionWebSocket.factory()) { + self.url = url + self.secret = secret + self.scheduler = scheduler + self.makeSocket = makeSocket + dial() + } + + public func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onFrame = onFrame + self.onStatus = onStatus + } + + public func send(_ frame: Bytes) { + guard status.isOpen, let socket else { return } + socket.send(frame) + } + + /// Drop an apparently live socket and dial now, after a foreground wake. + public func wake() { + if shutdown { return } + // A dial started within the last second is kept: the app's own wake + // and the network's can land together. + if status == .connecting, socket != nil, scheduler.nowMs - dialledAtMs < 1_000 { return } + retry?.cancel() + retry = nil + attempt = 0 + rttMs = nil + drop() + dial() + } + + /// The network came back: skip whatever backoff is running. + public func networkAvailable() { + guard !shutdown, retry != nil else { return } + retry?.cancel() + retry = nil + attempt = 0 + dial() + } + + public func close(reason: String = "closed by client") { + if shutdown { return } + shutdown = true + retry?.cancel() + retry = nil + drop() + setStatus(.closed(reason: reason, retryable: false)) + onFrame = { _ in } + onStatus = { _ in } + } + + private func dial() { + if shutdown { return } + let epoch = Rendezvous.epoch(nowMs: scheduler.nowMs) + epochOffset + guard let handle = try? Rendezvous.handle(secret: secret, epoch: epoch) else { + setStatus(.closed(reason: "rendezvous secret is missing", retryable: false)) + return + } + setStatus(.connecting) + dialledAtMs = scheduler.nowMs + socketGeneration += 1 + let generation = socketGeneration + let target = url.appendingPathComponent("r").appendingPathComponent(String(epoch)).appendingPathComponent(handle).appendingPathComponent("app") + socket = makeSocket(target, WebSocketEvents( + onOpen: { [weak self] in + guard let self, generation == self.socketGeneration else { return } + self.rttMs = self.scheduler.nowMs - self.dialledAtMs + }, + onText: { [weak self] text in + guard let self, generation == self.socketGeneration else { return } + self.onText(text) + }, + onBinary: { [weak self] bytes in + guard let self, generation == self.socketGeneration else { return } + self.onBinary(bytes) + }, + onClose: { [weak self] code, reason in + guard let self, generation == self.socketGeneration else { return } + self.onClose(code: code, reason: reason) + } + )) + } + + private func onText(_ text: String) { + if text == Self.ctrlReady { + corrections = 0 + setStatus(.open(sinceMs: scheduler.nowMs)) + } else if text == Self.ctrlGone { + // The box left. Keep the socket; the relay says when it is back. + setStatus(.closed(reason: "box offline", retryable: true)) + } + } + + private func onBinary(_ bytes: Bytes) { + guard status.isOpen else { return } + // Only a delivered frame proves the path works, so this is where the + // dial backoff resets. + attempt = 0 + onFrame(bytes) + } + + private func onClose(code: Int, reason: String) { + if shutdown { return } + socket = nil + socketGeneration += 1 + rttMs = nil + + let delayMs: Double + switch code { + case Self.closeRotated: + adoptEpoch(reason) + delayMs = scheduler.random() * Self.rotateJitterMs + case Self.closeEpoch: + adoptEpoch(reason) + corrections += 1 + delayMs = corrections > Self.correctionLimit ? backoff() : 0 + default: + delayMs = backoff() + } + setStatus(.closed(reason: Self.closeReason(code), retryable: true)) + schedule(delayMs) + } + + /// Take the relay's epoch as a clock correction, never as an order. A + /// relay that could name any epoch could make us publish handles of its + /// choosing, so only a strict number within one hour of ours is taken. + private func adoptEpoch(_ announced: String) { + let trimmed = announced.trimmingCharacters(in: .whitespaces) + let digits = trimmed.hasPrefix("-") ? trimmed.dropFirst() : Substring(trimmed) + guard !digits.isEmpty, digits.allSatisfy(\.isASCII), digits.allSatisfy(\.isNumber), let epoch = Int64(trimmed) else { return } + let offset = epoch - Rendezvous.epoch(nowMs: scheduler.nowMs) + guard abs(offset) <= Self.maxEpochCorrection else { return } + epochOffset = offset + } + + /// Full jitter: the whole delay is random, so peers never resynchronise. + private func backoff() -> Double { + let ceiling = min(Self.backoffCapMs, Self.backoffBaseMs * pow(2, Double(attempt))) + attempt = min(attempt + 1, 16) + return scheduler.random() * ceiling + } + + private func schedule(_ delayMs: Double) { + retry?.cancel() + retry = scheduler.after(delayMs) { [weak self] in + guard let self else { return } + self.retry = nil + self.dial() + } + } + + private func drop() { + socketGeneration += 1 + socket?.close() + socket = nil + } + + private func setStatus(_ next: CarrierStatus) { + switch (status, next) { + case (.connecting, .connecting), (.open, .open): + return + case let (.closed(a, _), .closed(b, _)) where a == b: + return + default: + status = next + onStatus(next) + } + } + + static func closeReason(_ code: Int) -> String { + switch code { + case closeEpoch, closeRotated: return "rendezvous rotated" + case closeBusy: return "relay is busy" + case closeBadJoin: return "relay rejected the join" + default: return "connection lost" + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Rendezvous.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Rendezvous.swift new file mode 100644 index 0000000..3e628af --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Rendezvous.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Rendezvous handles: the name the box and the app use to find each other +/// on the relay, and the one piece of metadata the relay unavoidably sees. +/// +/// handle = HKDF-SHA256(secret, info = "ftw/rendezvous/v1/")[0..16] +/// +/// The secret arrives optically in the QR and never travels through +/// Sourceful. Without it two epochs' handles are unrelated strings, so the +/// relay cannot follow a household across months from an identifier. +public enum Rendezvous { + /// An hour: short enough not to be a household identifier, long enough + /// that rotations are rare beside ordinary reconnects. + public static let epochMs: Double = 3_600_000 + public static let handleBytes = 16 + + /// The epoch this device's clock guesses. The relay corrects a wrong one. + public static func epoch(nowMs: Double) -> Int64 { + Int64((nowMs / epochMs).rounded(.down)) + } + + public static func handle(secret: Bytes, epoch: Int64) throws -> String { + guard secret.count >= 16 else { + throw Primitives.CryptoFailure(message: "rendezvous secret is too short to be a secret") + } + return Primitives.hkdf(ikm: secret, salt: [], info: Array("ftw/rendezvous/v1/\(epoch)".utf8), length: handleBytes).hex + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/URLSessionWebSocket.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/URLSessionWebSocket.swift new file mode 100644 index 0000000..ee0769c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/URLSessionWebSocket.swift @@ -0,0 +1,114 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A WebSocket over URLSession, feeding events back on the main actor. +/// +/// Every event carries a generation check through `closed`, so a socket +/// that has been dropped can never deliver a late frame to its successor. +@MainActor +public final class URLSessionWebSocket: NSObject, WebSocketConnection { + private var task: URLSessionWebSocketTask? + private var session: URLSession? + private let events: WebSocketEvents + private var closed = false + private var opened = false + + public static func factory() -> WebSocketFactory { + { url, events in URLSessionWebSocket(url: url, events: events) } + } + + init(url: URL, events: WebSocketEvents) { + self.events = events + super.init() + let delegate = Delegate(owner: self) + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 30 + let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + self.session = session + let task = session.webSocketTask(with: url) + task.maximumMessageSize = 1 << 20 + self.task = task + task.resume() + receive() + } + + public func send(_ data: Bytes) { + guard !closed, let task else { return } + task.send(.data(Data(data))) { _ in } + } + + public func close() { + guard !closed else { return } + closed = true + task?.cancel(with: .normalClosure, reason: nil) + session?.invalidateAndCancel() + task = nil + session = nil + } + + private func receive() { + guard let task else { return } + task.receive { [weak self] result in + Task { @MainActor [weak self] in + guard let self, !self.closed else { return } + switch result { + case .success(let message): + if !self.opened { self.didOpen() } + switch message { + case .string(let text): self.events.onText(text) + case .data(let data): self.events.onBinary(data.byteArray) + @unknown default: break + } + self.receive() + case .failure: + // The close handshake arrives through the delegate with its + // code and reason, and the relay's codes matter: 4409 and + // 4410 carry the epoch. The delegate may land a moment after + // this failure, so it gets that moment before a plain 1006. + try? await Task.sleep(nanoseconds: 250_000_000) + let code = task.closeCode.rawValue + let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? "" + self.didClose(code: code == 0 ? 1006 : code, reason: reason) + } + } + } + } + + fileprivate func didOpen() { + guard !closed, !opened else { return } + opened = true + events.onOpen() + } + + fileprivate func didClose(code: Int, reason: String) { + guard !closed else { return } + closed = true + session?.invalidateAndCancel() + task = nil + session = nil + events.onClose(code, reason) + } + + private final class Delegate: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable { + weak var owner: URLSessionWebSocket? + + init(owner: URLSessionWebSocket) { + self.owner = owner + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + Task { @MainActor [weak owner] in owner?.didOpen() } + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + let text = reason.map { String(decoding: $0, as: UTF8.self) } ?? "" + Task { @MainActor [weak owner] in owner?.didClose(code: closeCode.rawValue, reason: text) } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + Task { @MainActor [weak owner] in owner?.didClose(code: 1006, reason: "") } + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Crypto/Noise.swift b/appleApp/FTWKit/Sources/FTWKit/Crypto/Noise.swift new file mode 100644 index 0000000..08d52e6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Crypto/Noise.swift @@ -0,0 +1,337 @@ +import Foundation + +/// Noise_IK_25519_ChaChaPoly_SHA256. +/// +/// IK because the initiator already knows the responder's static key: the +/// app reads it optically off the box's QR code, so the trust anchor never +/// travels through Sourceful's cloud. A hostile relay can drop frames but +/// cannot present itself as a box. +/// +/// IK: +/// <- s +/// ... +/// -> e, es, s, ss +/// <- e, ee, se +/// +/// Byte-identical to the TypeScript client and the Go box, and checked +/// against the Cacophony vectors and the shared interop vectors in the tests. +public enum Noise { + public static let protocolName = "Noise_IK_25519_ChaChaPoly_SHA256" + public static let dhBytes = 32 + public static let hashBytes = 32 + public static let tagBytes = 16 + /// 2^64 - 1 is reserved by the spec and must never encrypt. + public static let maxNonce = UInt64.max + + public static let message1Overhead = dhBytes + dhBytes + tagBytes + tagBytes + public static let message2Overhead = dhBytes + tagBytes + + /// Failures here are local: they never cross the wire. The carrier maps + /// them to prose; this layer only says which thing went wrong. + public struct NoiseError: Error, Equatable { + public let code: String + public let message: String + } + + /// Noise HKDF: one extract over the chaining key, then expand with empty + /// info. Splitting one expand is byte-identical to the chained HMACs. + static func hkdf2(_ chainingKey: Bytes, _ ikm: Bytes) -> (Bytes, Bytes) { + let prk = Primitives.hmacSHA256(key: chainingKey, ikm) + let t1 = Primitives.hmacSHA256(key: prk, [1]) + let t2 = Primitives.hmacSHA256(key: prk, t1 + [2]) + return (t1, t2) + } + + static func dh(_ secret: Bytes, _ peer: Bytes) throws -> Bytes { + do { + return try Primitives.x25519(secret: secret, peer: peer) + } catch { + throw NoiseError(code: "E_NOISE_DH", message: "X25519 failed") + } + } + + /// ChaChaPoly's 96-bit nonce, Noise-encoded: four zero bytes, then the + /// 64-bit counter little-endian. + static func nonceBytes(_ n: UInt64) -> Bytes { + var out = Bytes(repeating: 0, count: 12) + for i in 0..<8 { out[4 + i] = UInt8(truncatingIfNeeded: n >> (8 * UInt64(i))) } + return out + } +} + +/// One direction's key and counter. +/// +/// The counter never wraps. Reusing a (key, nonce) pair under +/// ChaCha20-Poly1305 hands an observer the XOR of two plaintexts and makes the +/// authenticator forgeable, so exhaustion throws and the session dies. +public final class CipherState { + private var key: Bytes? + public private(set) var nonce: UInt64 = 0 + private var destroyed = false + + public init(key: Bytes? = nil) { + self.key = key + } + + public var hasKey: Bool { key != nil } + + /// Jump the counter, for a carrier that may reorder or drop. Replay + /// protection lives in the transport, which decides what is acceptable. + public func setNonce(_ n: UInt64) throws { + guard n < Noise.maxNonce else { + throw Noise.NoiseError(code: "E_NOISE_NONCE_EXHAUSTED", message: "nonce \(n) is out of range") + } + nonce = n + } + + public func encrypt(ad: Bytes, _ plaintext: Bytes) throws -> Bytes { + try assertUsable() + // Before the first mixKey there is no key and the spec passes data + // through. A destroyed cipher is a separate flag so it can never + // quietly emit plaintext. + guard let key else { return plaintext } + let n = try take() + return try Primitives.chachaSeal(key: key, nonce: Noise.nonceBytes(n), ad: ad, plaintext: plaintext) + } + + public func decrypt(ad: Bytes, _ ciphertext: Bytes) throws -> Bytes { + try assertUsable() + guard let key else { return ciphertext } + let n = try take() + do { + return try Primitives.chachaOpen(key: key, nonce: Noise.nonceBytes(n), ad: ad, ciphertext: ciphertext) + } catch { + // Never say more than this. Which check failed is exactly what a + // chosen-ciphertext attacker is probing for. + throw Noise.NoiseError(code: "E_NOISE_AUTH", message: "authentication failed") + } + } + + func copy() -> CipherState { + let c = CipherState(key: key) + c.nonce = nonce + c.destroyed = destroyed + return c + } + + /// Wipe the key. A closed session must not leave one reachable. + public func destroy() { + if var k = key { wipe(&k) } + key = nil + destroyed = true + } + + private func assertUsable() throws { + if destroyed { throw Noise.NoiseError(code: "E_NOISE_CLOSED", message: "cipher was destroyed") } + } + + private func take() throws -> UInt64 { + guard nonce < Noise.maxNonce else { + throw Noise.NoiseError(code: "E_NOISE_NONCE_EXHAUSTED", message: "nonce space exhausted") + } + defer { nonce += 1 } + return nonce + } +} + +/// Chaining key, handshake hash, and the cipher over handshake payloads. +final class SymmetricState { + var ck: Bytes + var h: Bytes + var cipher = CipherState() + + init() { + let name = Array(Noise.protocolName.utf8) + // The name is exactly 32 bytes, so it is used verbatim; a longer one + // would be hashed. + h = name.count <= Noise.hashBytes ? name + Bytes(repeating: 0, count: Noise.hashBytes - name.count) : Primitives.sha256(name) + ck = h + } + + func mixHash(_ data: Bytes) { + h = Primitives.sha256(h + data) + } + + func mixKey(_ ikm: Bytes) { + let (next, temp) = Noise.hkdf2(ck, ikm) + ck = next + cipher = CipherState(key: temp) + } + + func encryptAndHash(_ plaintext: Bytes) throws -> Bytes { + let ct = try cipher.encrypt(ad: h, plaintext) + mixHash(ct) + return ct + } + + func decryptAndHash(_ ciphertext: Bytes) throws -> Bytes { + let pt = try cipher.decrypt(ad: h, ciphertext) + mixHash(ciphertext) + return pt + } + + func copy() -> SymmetricState { + let c = SymmetricState() + c.ck = ck + c.h = h + c.cipher = cipher.copy() + return c + } + + func split() -> (CipherState, CipherState) { + let (k1, k2) = Noise.hkdf2(ck, []) + return (CipherState(key: k1), CipherState(key: k2)) + } +} + +public struct HandshakeResult { + /// Encrypts what we send. + public let send: CipherState + /// Decrypts what we receive. + public let recv: CipherState + public let handshakeHash: Bytes + /// The peer's static key, now authenticated rather than merely claimed. + public let remoteStatic: Bytes +} + +/// The IK state machine. Anything out of order throws rather than producing +/// a message the peer cannot read. +public final class HandshakeState { + private enum Step { case write1, read1, write2, read2, done, split } + + private var sym = SymmetricState() + private let s: Primitives.KeyPair + private var e: Primitives.KeyPair? + private var rs: Bytes? + private var re: Bytes? + private let initiator: Bool + private let fixedEphemeral: Primitives.KeyPair? + private var step: Step + + /// `ephemeral` is for test vectors only: generating it is the whole + /// source of forward secrecy. + public static func initiator(staticKey: Primitives.KeyPair, remoteStatic: Bytes, prologue: Bytes = [], ephemeral: Primitives.KeyPair? = nil) throws -> HandshakeState { + guard remoteStatic.count == Noise.dhBytes else { + throw Noise.NoiseError(code: "E_NOISE_KEY", message: "IK needs the responder static key up front") + } + return HandshakeState(initiator: true, staticKey: staticKey, remoteStatic: remoteStatic, prologue: prologue, ephemeral: ephemeral) + } + + public static func responder(staticKey: Primitives.KeyPair, prologue: Bytes = [], ephemeral: Primitives.KeyPair? = nil) -> HandshakeState { + HandshakeState(initiator: false, staticKey: staticKey, remoteStatic: nil, prologue: prologue, ephemeral: ephemeral) + } + + private init(initiator: Bool, staticKey: Primitives.KeyPair, remoteStatic: Bytes?, prologue: Bytes, ephemeral: Primitives.KeyPair?) { + self.initiator = initiator + s = staticKey + fixedEphemeral = ephemeral + step = initiator ? .write1 : .read1 + sym.mixHash(prologue) + // IK's pre-message `<- s`: both ends hash the responder's static key + // before a byte moves, so a relay substituting its own key fails at + // the first decryption. + if initiator, let remoteStatic { + rs = remoteStatic + sym.mixHash(remoteStatic) + } else { + sym.mixHash(staticKey.publicKey) + } + } + + public var isComplete: Bool { step == .done } + + public func writeMessage(_ payload: Bytes = []) throws -> Bytes { + if initiator && step == .write1 { return try writeMessage1(payload) } + if !initiator && step == .write2 { return try writeMessage2(payload) } + throw Noise.NoiseError(code: "E_NOISE_STATE", message: "cannot write at this step") + } + + public func readMessage(_ message: Bytes) throws -> Bytes { + if !initiator && step == .read1 { return try readMessage1(message) } + if initiator && step == .read2 { return try readMessage2(message) } + throw Noise.NoiseError(code: "E_NOISE_STATE", message: "cannot read at this step") + } + + /// Ends the handshake and hands over the keys, once. Splitting twice + /// would mint two send ciphers with the same key at nonce 0. + public func split() throws -> HandshakeResult { + guard step == .done else { + throw Noise.NoiseError(code: "E_NOISE_STATE", message: step == .split ? "handshake already split" : "handshake is not done") + } + let (c1, c2) = sym.split() + step = .split + return HandshakeResult( + send: initiator ? c1 : c2, + recv: initiator ? c2 : c1, + handshakeHash: sym.h, + remoteStatic: rs ?? [] + ) + } + + // -> e, es, s, ss + private func writeMessage1(_ payload: Bytes) throws -> Bytes { + let e = fixedEphemeral ?? Primitives.generateKeyPair() + self.e = e + let rs = self.rs! + sym.mixHash(e.publicKey) + sym.mixKey(try Noise.dh(e.secretKey, rs)) + let encStatic = try sym.encryptAndHash(s.publicKey) + sym.mixKey(try Noise.dh(s.secretKey, rs)) + let encPayload = try sym.encryptAndHash(payload) + step = .read2 + return e.publicKey + encStatic + encPayload + } + + private func readMessage1(_ message: Bytes) throws -> Bytes { + let encStaticEnd = Noise.dhBytes + Noise.dhBytes + Noise.tagBytes + guard message.count >= encStaticEnd + Noise.tagBytes else { + throw Noise.NoiseError(code: "E_NOISE_MESSAGE", message: "handshake message 1 is \(message.count) bytes") + } + // Commit on success: a message that fails to authenticate must leave + // no trace, or one stray frame ends every handshake still waiting. + let sym = self.sym.copy() + let re = Array(message[0.. Bytes { + let e = fixedEphemeral ?? Primitives.generateKeyPair() + self.e = e + sym.mixHash(e.publicKey) + sym.mixKey(try Noise.dh(e.secretKey, re!)) + sym.mixKey(try Noise.dh(e.secretKey, rs!)) + let encPayload = try sym.encryptAndHash(payload) + step = .done + return e.publicKey + encPayload + } + + private func readMessage2(_ message: Bytes) throws -> Bytes { + guard message.count >= Noise.dhBytes + Noise.tagBytes else { + throw Noise.NoiseError(code: "E_NOISE_MESSAGE", message: "handshake message 2 is \(message.count) bytes") + } + // Mixing runs against a copy that becomes the state only once the tag + // verifies. Two phones connecting at once read each other's 48-byte + // replies on a shared relay room; mixing a stray one would poison the + // state and the genuine reply could never authenticate. + let sym = self.sym.copy() + let re = Array(message[0.. Bytes { + Bytes(SHA256.hash(data: data)) + } + + public static func hmacSHA256(key: Bytes, _ data: Bytes) -> Bytes { + Bytes(HMAC.authenticationCode(for: data, using: SymmetricKey(data: key))) + } + + /// RFC 5869 HKDF with SHA-256. An empty salt is a salt of zeros, as the + /// RFC and WebCrypto both say. + public static func hkdf(ikm: Bytes, salt: Bytes, info: Bytes, length: Int) -> Bytes { + let key = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: ikm), + salt: salt, + info: info, + outputByteCount: length + ) + return key.withUnsafeBytes { Bytes($0) } + } + + // MARK: X25519 + + public struct KeyPair: Sendable { + public let secretKey: Bytes + public let publicKey: Bytes + } + + public static func generateKeyPair() -> KeyPair { + let key = Curve25519.KeyAgreement.PrivateKey() + return KeyPair(secretKey: Bytes(key.rawRepresentation), publicKey: Bytes(key.publicKey.rawRepresentation)) + } + + public static func keyPair(fromSecret secret: Bytes) throws -> KeyPair { + guard secret.count == 32 else { throw CryptoFailure(message: "static key is \(secret.count) bytes, need 32") } + let key = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: secret) + return KeyPair(secretKey: secret, publicKey: Bytes(key.publicKey.rawRepresentation)) + } + + /// X25519. Throws on a low-order peer key, whose shared secret is all + /// zeros: the spec permits accepting it and refusing is strictly safer. + public static func x25519(secret: Bytes, peer: Bytes) throws -> Bytes { + do { + let mine = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: secret) + let theirs = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: peer) + let shared = try mine.sharedSecretFromKeyAgreement(with: theirs) + let out = shared.withUnsafeBytes { Bytes($0) } + if out.allSatisfy({ $0 == 0 }) { throw CryptoFailure(message: "low-order peer key") } + return out + } catch let e as CryptoFailure { + throw e + } catch { + throw CryptoFailure(message: "X25519 failed") + } + } + + // MARK: ChaCha20-Poly1305 + + public static func chachaSeal(key: Bytes, nonce: Bytes, ad: Bytes, plaintext: Bytes) throws -> Bytes { + let box = try ChaChaPoly.seal( + plaintext, + using: SymmetricKey(data: key), + nonce: try ChaChaPoly.Nonce(data: nonce), + authenticating: ad + ) + return Bytes(box.ciphertext) + Bytes(box.tag) + } + + public static func chachaOpen(key: Bytes, nonce: Bytes, ad: Bytes, ciphertext: Bytes) throws -> Bytes { + guard ciphertext.count >= 16 else { throw CryptoFailure(message: "ciphertext shorter than its tag") } + let box = try ChaChaPoly.SealedBox( + nonce: try ChaChaPoly.Nonce(data: nonce), + ciphertext: ciphertext[..<(ciphertext.count - 16)], + tag: ciphertext[(ciphertext.count - 16)...] + ) + return Bytes(try ChaChaPoly.open(box, using: SymmetricKey(data: key), authenticating: ad)) + } + + // MARK: AES-256-GCM + + /// Ciphertext followed by the 16-byte tag, the layout WebCrypto returns, + /// so a blob sealed by the web app opens here and the reverse. + public static func aesGCMSeal(key: Bytes, nonce: Bytes, ad: Bytes = [], plaintext: Bytes) throws -> Bytes { + let box = try AES.GCM.seal( + plaintext, + using: SymmetricKey(data: key), + nonce: try AES.GCM.Nonce(data: nonce), + authenticating: ad + ) + return Bytes(box.ciphertext) + Bytes(box.tag) + } + + public static func aesGCMOpen(key: Bytes, nonce: Bytes, ad: Bytes = [], ciphertext: Bytes) throws -> Bytes { + guard ciphertext.count >= 16 else { throw CryptoFailure(message: "ciphertext shorter than its tag") } + let box = try AES.GCM.SealedBox( + nonce: try AES.GCM.Nonce(data: nonce), + ciphertext: ciphertext[..<(ciphertext.count - 16)], + tag: ciphertext[(ciphertext.count - 16)...] + ) + return Bytes(try AES.GCM.open(box, using: SymmetricKey(data: key), authenticating: ad)) + } + + // MARK: Ed25519 + + /// The public half of an RFC 8032 seed. Deterministic, so the same PRF + /// output yields the same escrow write key here and in the web app. + public static func ed25519PublicKey(seed: Bytes) throws -> Bytes { + Bytes(try Curve25519.Signing.PrivateKey(rawRepresentation: seed).publicKey.rawRepresentation) + } + + public static func ed25519Sign(seed: Bytes, message: Bytes) throws -> Bytes { + Bytes(try Curve25519.Signing.PrivateKey(rawRepresentation: seed).signature(for: message)) + } + + public static func ed25519Verify(publicKey: Bytes, signature: Bytes, message: Bytes) -> Bool { + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false } + return key.isValidSignature(signature, for: message) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift b/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift new file mode 100644 index 0000000..5e3634e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift @@ -0,0 +1,98 @@ +import Foundation + +/// The encrypted channel once the handshake is done. +/// +/// offset field note +/// 0 seq u64BE cleartext, authenticated as associated data +/// 8 body ChaCha20-Poly1305 over the frame, tag included +/// +/// The sequence number is on the wire so a carrier that reorders or drops +/// (the LAN path, later) costs one frame instead of the session. Eight bytes +/// of monotonic counter and nothing else, so frame size stays constant. +public final class NoiseTransport { + public static let seqBytes = 8 + public static let overhead = seqBytes + Noise.tagBytes + /// At 1 Hz on lane 0, 64 frames is a minute of tolerance and one word. + public static let replayWindow: UInt64 = 64 + + public let handshakeHash: Bytes + /// The box's static key as authenticated, not as claimed. + public let remoteStatic: Bytes + + private let send: CipherState + private let recv: CipherState + private var highestSeq: UInt64? + private var seen: UInt64 = 0 + private var closed = false + + public init(_ result: HandshakeResult) { + send = result.send + recv = result.recv + handshakeHash = result.handshakeHash + remoteStatic = result.remoteStatic + } + + public var nextSeq: UInt64 { send.nonce } + + public func encrypt(_ frame: Bytes) throws -> Bytes { + try assertOpen() + let seq = send.nonce.bigEndianBytes + let body = try send.encrypt(ad: seq, frame) + return seq + body + } + + public func decrypt(_ bytes: Bytes) throws -> Bytes { + try assertOpen() + guard bytes.count >= Self.overhead else { + throw Noise.NoiseError(code: "E_NOISE_MESSAGE", message: "transport message is \(bytes.count) bytes") + } + let seqBytes = Array(bytes[0..= Self.replayWindow { + throw Noise.NoiseError(code: "E_NOISE_REPLAY", message: "sequence \(seq) is outside the replay window") + } + if (seen >> behind) & 1 == 1 { + throw Noise.NoiseError(code: "E_NOISE_REPLAY", message: "sequence \(seq) was already accepted") + } + } + + private func markSeen(_ seq: UInt64) { + guard let highest = highestSeq else { + highestSeq = seq + seen = 1 + return + } + if seq > highest { + let shift = seq - highest + seen = shift >= Self.replayWindow ? 1 : (seen << shift) | 1 + highestSeq = seq + } else { + seen |= 1 << (highest - seq) + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift new file mode 100644 index 0000000..69506d1 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift @@ -0,0 +1,709 @@ +import Foundation + +/// A box that lives in this process: the other end of the protocol, for the +/// demo and for the tests. It speaks frames exactly as a box does, holds a +/// `SimulatedHouse`, and answers the same routes and commands the app uses. +/// +/// It is not the box and says so: the demo runs behind its own band and +/// writes nothing to disk. +@MainActor +public final class SimulatedBox { + public let house: SimulatedHouse + private let scheduler: Scheduler + private let requireStepUp: Bool + private let bootedAtMs: Double + + /// Frames to the app. + public var send: (@MainActor (Bytes) -> Void)? + + private var subscription: Subscription? + private var telemetry: Cancellable? + private var seq: UInt64 = 0 + private var controlRev: UInt64 = 1 + private var lastSent: [Int: Double] = [:] + private var planRev: UInt64 = 1 + public var booting = false + /// Silence instead of answers, to test the app's deadlines. + public var mute = false + /// Ack commands but never report a result. + public var neverConfirm = false + public private(set) var receivedCommands: [String] = [] + public private(set) var stepUpsSeen = 0 + + // Box-side records the Box screen reads. + var devices: [(id: String, role: String, addedAtMs: Double, lastSeenMs: Double)] = [] + var notifyEnabled = false + var notifyRules: [(type: String, enabled: Bool)] = [ + ("charging.connected", false), ("charging.session_complete", false), ("charging.interrupted", false), + ("update.installed", false), ("driver.offline", false), ("fuse.over_limit", false), + ] + var sentPushes: [(title: String, atMs: Double)] = [] + + public static let modes: [ModeInfo] = [ + ModeInfo(key: "planner_passive_arbitrage", label: "Passive arbitrage", tooltip: "Charge from the cheapest available source (PV when sunny, grid during cheap hours). Never exports from battery.", tier: "primary"), + ModeInfo(key: "planner_arbitrage", label: "Active arbitrage", tooltip: "Full price arbitrage — charge cheap, discharge into expensive hours (battery may export to grid).", tier: "primary"), + ModeInfo(key: "idle", label: "Stop batteries", tooltip: "Hold every battery at 0 W for as long as this mode is on.", tier: "advanced"), + ModeInfo(key: "self_consumption", label: "Self (manual)", tooltip: "Manual self-consumption — PI chases grid target, no plan.", tier: "advanced"), + ModeInfo(key: "peak_shaving", label: "Peak", tooltip: "Limit grid import to the configured peak limit.", tier: "advanced"), + ModeInfo(key: "charge", label: "Charge", tooltip: "Force full charge regardless of price.", tier: "advanced"), + ModeInfo(key: "planner_self", label: "Planner (self)", tooltip: "Forecast-driven self-consumption.", tier: "hidden"), + ] + + public init(scheduler: Scheduler, house: SimulatedHouse = SimulatedHouse(), requireStepUp: Bool = false, uptimeAtStartMs: Double = 3_600_000) { + self.scheduler = scheduler + self.house = house + self.requireStepUp = requireStepUp + bootedAtMs = scheduler.nowMs - uptimeAtStartMs + house.step(nowMs: scheduler.nowMs, dtMs: 1_000) + devices = [("Qm94T3du", Contract.roleOwner, scheduler.nowMs - 40 * 86_400_000, scheduler.nowMs - 3 * 3_600_000)] + } + + var uptimeMs: Double { scheduler.nowMs - bootedAtMs } + + /// Move the house and send what a box would send for that second. + public func tick(_ dtMs: Double = 1_000) { + house.step(nowMs: scheduler.nowMs, dtMs: dtMs) + } + + public func stop() { + telemetry?.cancel() + telemetry = nil + send = nil + } + + /// Register a phone, as a pairing on the box would. + public func enroll(deviceID: String, role: String) { + devices.insert((deviceID, role, scheduler.nowMs, scheduler.nowMs), at: 0) + } + + // MARK: Frames in + + public func receive(_ bytes: Bytes) { + if mute { return } + guard let frame = try? Frame.decode(bytes) else { return } + let env = frame.envelope + let body = env.b ?? CBOR.emptyMap + switch env.t { + case "hello": onHello(body) + case "sub": + let first = subscription == nil + subscription = Subscription(bucket: body["bucket"]?.int ?? 512, hz: body["hz"]?.double ?? 1) + if first { sendSnap() } + restartTelemetry() + case "plan.get": sendBulk(Envelope(t: "plan", id: env.id, b: planCBOR())) + case "price.get": + // The box decodes times into int64 and drops a body with a + // fraction in one, answering nothing. So does this. + guard let from = Self.wholeMs(body["fromMs"]), let to = Self.wholeMs(body["toMs"]) else { return } + sendBulk(Envelope(t: "price", id: env.id, b: pricesCBOR(from: from, to: to))) + case "hist.query": + guard Self.wholeMs(body["fromMs"]) != nil, Self.wholeMs(body["toMs"]) != nil else { return } + onHistory(env.id, body) + case "api.req": onAPI(env.id, body) + case "cmd": onCommand(body) + default: break + } + } + + /// An integer, as Go's int64 decoding demands; a float is refused. + static func wholeMs(_ c: CBOR?) -> Double? { + switch c { + case .unsigned(let u)?: return Double(u) + case .negative(let n)?: return -1 - Double(n) + default: return nil + } + } + + private func onHello(_ body: CBOR) { + let wantsSub = body["sub"] + var pairs: [(String, CBOR)] = [ + ("proto", .int(Proto.max)), + ("mode", .text(booting ? "booting" : "full")), + ("box", .map([("id", .text("demo-box")), ("build", .text("0.131.0-demo")), ("tz", .text(house.timeZone.identifier))])), + ("clock", .map([("source", .text("ntp")), ("syncedAtMs", .number(scheduler.nowMs - 60_000)), ("uptimeMs", .number(uptimeMs))])), + ("caps", .array(["status.core", "status.drivers", "history.5m", "history.1h", "history.etag", "cmd.lease", "cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough"].map { .text($0) })), + ("capsHash", .text("demo")), + ("modes", .array(Self.modes.map { .map([("key", .text($0.key)), ("label", .text($0.label)), ("tooltip", .text($0.tooltip)), ("tier", .text($0.tier))]) })), + ("role", .text(Contract.roleOwner)), + ("scopes", .array(Contract.scopes.map { .text($0) })), + ] + if booting { + pairs.append(("boot", .map([("phase", .text("vacuum")), ("pct", .int(40)), ("etaMs", .null)]))) + } else if let sub = wantsSub, sub.entries != nil { + pairs.append(("subscribed", .bool(true))) + } + sendBulk(Envelope(t: "hello_ok", b: .map(pairs))) + if !booting, let sub = wantsSub, sub.entries != nil { + subscription = Subscription(bucket: sub["bucket"]?.int ?? 512, hz: sub["hz"]?.double ?? 1) + sendSnap() + restartTelemetry() + } + } + + // MARK: Telemetry + + var fields: [Int: Double] { + var f: [Int: Double] = [ + Contract.FID.mode: Double(Self.modes.firstIndex { $0.key == house.modeKey } ?? 0), + Contract.FID.gridW: house.gridW, + Contract.FID.pvW: house.pvW, + Contract.FID.batteryW: house.batteryW, + Contract.FID.batterySoc: (house.socFraction * 1000).rounded(), + Contract.FID.loadW: house.baseLoadW + house.evWatts, + ] + f[Contract.FID.evW] = house.evWatts + return f + } + + private func sourcesCBOR() -> CBOR { + let now = uptimeMs + func src(_ kind: String, _ name: String) -> CBOR { + .map([("kind", .text(kind)), ("name", .text(name)), ("lastOkMs", .number(now - 400)), ("staleAfterMs", .int(10_000)), ("state", .text("live"))]) + } + return .map([("meter", src("meter", "sdm630")), ("inverter", src("inverter", "sungrow")), ("charger", src("charger", "easee"))]) + } + + private func sendSnap() { + let f = fields + lastSent = f + let dict: [(String, CBOR)] = [ + ("1", .map([("name", .text("mode")), ("unit", .null), ("srcId", .null)])), + ("2", .map([("name", .text("grid_w")), ("unit", .text("W")), ("srcId", .text("meter"))])), + ("3", .map([("name", .text("pv_w")), ("unit", .text("W")), ("srcId", .text("inverter"))])), + ("4", .map([("name", .text("battery_w")), ("unit", .text("W")), ("srcId", .text("inverter"))])), + ("5", .map([("name", .text("battery_soc")), ("unit", .text("permille")), ("srcId", .text("inverter"))])), + ("6", .map([("name", .text("load_w")), ("unit", .text("W")), ("srcId", .text("meter"))])), + ("10", .map([("name", .text("ev_w")), ("unit", .text("W")), ("srcId", .text("charger"))])), + ] + sendBulk(Envelope(t: "snap", b: .map([ + ("uptimeMs", .number(uptimeMs)), + ("controlRev", .unsigned(controlRev)), + ("dict", .map(dict)), + ("fields", .map(f.sorted { $0.key < $1.key }.map { (String($0.key), CBOR.number($0.value)) })), + ("sources", sourcesCBOR()), + ("dispatchBlockedBy", .array([])), + ]))) + } + + private func restartTelemetry() { + telemetry?.cancel() + guard let sub = subscription else { return } + let period = 1_000 / max(0.01, sub.hz) + func loop() { + telemetry = scheduler.after(period) { [weak self] in + guard let self, self.send != nil else { return } + self.tick(period) + self.sendTelemetry() + loop() + } + } + loop() + } + + /// A delta when something moved, a tick when nothing did: the same size + /// and cadence either way. + func sendTelemetry() { + guard let sub = subscription, !mute else { return } + seq += 1 + let f = fields + let changed = f.filter { lastSent[$0.key] != $0.value }.sorted { $0.key < $1.key } + lastSent = f + let env: Envelope + if changed.isEmpty { + env = Envelope(t: "tick", b: .map([("seq", .unsigned(seq)), ("uptimeMs", .number(uptimeMs))])) + } else { + env = Envelope(t: "delta", b: .map([ + ("seq", .unsigned(seq)), + ("uptimeMs", .number(uptimeMs)), + ("fields", .map(changed.map { (String($0.key), CBOR.number($0.value)) })), + ])) + } + if let frame = try? Frame.encode(lane: Frame.laneControl, envelope: env, bucket: sub.bucket) { + send?(frame) + } + } + + // MARK: Plan and prices + + func planSlots() -> [PlanSlot] { + let slotMs: Double = 900_000 + let start = (scheduler.nowMs / slotMs).rounded(.down) * slotMs + return (0..<96).map { i in + let t = start + Double(i) * slotMs + let price = house.priceAt(t) + let pv = house.pvAt(t) + let load = house.baseLoadAt(t) + var battery = 0.0 + var reason = "idle" + if -pv > load + 300 { + battery = min(house.batteryMaxW, -pv - load).rounded(); reason = "solar_surplus" + } else if price < 45 { + battery = house.modeKey == "planner_self" ? 0 : 3_000; reason = battery > 0 ? "cheap_import" : "idle" + } else if price > 110 { + battery = -min(house.batteryMaxW, load).rounded(); reason = "expensive_import" + } else if price > 80 { + reason = "reserve_held" + } + return PlanSlot(startMs: t, durationMs: slotMs, batteryW: battery, gridW: load + battery + pv, priceMinor: price, reason: reason) + } + } + + func planCBOR() -> CBOR { + .map([ + ("rev", .unsigned(planRev)), + ("uptimeMs", .number(uptimeMs)), + ("slots", .array(planSlots().map { s in + .map([ + ("startMs", .number(s.startMs)), ("durationMs", .number(s.durationMs)), + ("batteryW", .number(s.batteryW)), ("gridW", .number(s.gridW)), + ("priceMinor", s.priceMinor.map { CBOR.number($0) } ?? .null), ("reason", .text(s.reason)), + ]) + })), + ("stale", .bool(false)), + ("ceilingW", .int(11_000)), + ]) + } + + func pricesCBOR(from: Double, to: Double) -> CBOR { + let slotMs: Double = 3_600_000 + // Tomorrow's rates publish at 13:00 local. + let midnight = scheduler.nowMs - house.hourOfDay(scheduler.nowMs) * 3_600_000 + let published = midnight + (house.hourOfDay(scheduler.nowMs) >= 13 ? 48 : 24) * 3_600_000 + let end = min(to, published) + var slots = [CBOR]() + var t = (from / slotMs).rounded(.down) * slotMs + while t < end { + let total = house.priceAt(t) + slots.append(.map([("startMs", .number(t)), ("durationMs", .number(slotMs)), ("spotMinor", .number((total * 0.6).rounded())), ("totalMinor", .number(total))])) + t += slotMs + } + return .map([("zone", .text("SE3")), ("currency", .text("SEK")), ("slots", .array(slots)), ("stale", .bool(end < to))]) + } + + // MARK: History + + private func onHistory(_ id: UInt32?, _ body: CBOR) { + let series = body["series"]?.stringArray ?? [] + let res = body["res"]?.string.flatMap(Resolution.init(rawValue:)) ?? .fiveMinutes + let from = body["fromMs"]?.double ?? scheduler.nowMs - 86_400_000 + let to = body["toMs"]?.double ?? scheduler.nowMs + let maxPoints = body["maxPoints"]?.int ?? 2000 + var have = [String: String]() + for h in body["have"]?.array ?? [] { + if let t = h["tileId"]?.string, let e = h["etag"]?.string { have[t] = e } + } + let plan = HistoryGeometry.plan(res, fromMs: from, toMs: to, maxPoints: maxPoints) + for tile in plan.tiles { + let partial = tile.startMs + HistoryGeometry.spec(plan.res).tileSpanMs > scheduler.nowMs + let columns: [[Int32]] = series.map { name in + (0.. scheduler.nowMs { return missingSample } + let pv = house.pvAt(t) + let load = house.baseLoadAt(t) + let battery = max(-5_000, min(5_000, -(load + pv))) + switch name { + case "pv_w": return Int32(pv) + case "load_w": return Int32(load) + case "battery_w": return Int32(battery) + case "grid_w": return Int32(load + battery + pv) + default: return missingSample + } + } + } + let data = HistoryGeometry.pack(columns) + let etag = HistoryGeometry.etag(data) + if !partial, have[tile.tileId] == etag { continue } + sendBulk(Envelope(t: "hist.chunk", id: id, b: .map([ + ("tileId", .text(tile.tileId)), ("etag", .text(etag)), ("res", .text(plan.res.rawValue)), + ("startMs", .number(tile.startMs)), ("stepMs", .number(plan.stepMs)), + ("series", .array(series.map { .text($0) })), ("data", .bytes(data)), ("partial", .bool(partial)), + ]))) + } + sendBulk(Envelope(t: "hist.end", id: id, b: .map([("resActual", .text(plan.res.rawValue)), ("gaps", .array([]))]))) + } + + // MARK: Commands + + private func onCommand(_ body: CBOR) { + let cmdId = body["cmdId"]?.string ?? "" + let op = body["op"]?.string ?? "" + let args = body["args"] ?? CBOR.emptyMap + receivedCommands.append(op) + if let notAfter = body["notValidAfterMs"]?.double, notAfter < uptimeMs { + result(cmdId, "expired", error: ("E_CMD_EXPIRED", [])) + return + } + sendControl(Envelope(t: "cmd.ack", b: .map([("cmdId", .text(cmdId)), ("leaseId", .text("lease-\(cmdId.prefix(8))")), ("expiresAtMs", .number(uptimeMs + 60_000))]))) + if neverConfirm { return } + + switch op { + case Contract.opSetMode: + guard let mode = args["mode"]?.string, Self.modes.contains(where: { $0.key == mode }) else { + result(cmdId, "rejected", error: ("E_PRECONDITION", [])) + return + } + house.modeKey = mode + controlRev += 1 + planRev += 1 + result(cmdId, "applied", observed: Double(Self.modes.firstIndex { $0.key == mode } ?? 0)) + // A plan pushed unasked after a mode change, as the box does. + sendBulk(Envelope(t: "plan", b: planCBOR())) + case Contract.opLoadpointHold: + if args["clear"]?.bool == true { + house.manualHoldW = nil + } else { + house.manualHoldW = args["power_w"]?.double ?? 0 + } + result(cmdId, "applied", observed: house.manualHoldW ?? 0) + case Contract.opLoadpointBoost: + if args["cancel"]?.bool == true { + house.boostUntilMs = nil + house.lastBoostStop = "cancelled" + } else if house.manualHoldW != nil || house.surplusOnly { + result(cmdId, "rejected", error: ("E_UNAVAILABLE", [("op", .text(op))])) + return + } else { + house.boostReserve = (args["min_battery_soc_pct"]?.double ?? 30) / 100 + house.boostUntilMs = scheduler.nowMs + (args["duration_s"]?.double ?? 3_600) * 1000 + } + result(cmdId, "applied") + case Contract.opLoadpointSocSet: + guard house.carPluggedIn else { + result(cmdId, "rejected", error: ("E_UNAVAILABLE", [("op", .text(op)), ("reason", .text("unplugged"))])) + return + } + house.carSocFraction = max(0, min(1, args["soc"]?.double ?? house.carSocFraction)) + house.carSocSource = "inferred" + result(cmdId, "applied", observed: house.carSocFraction) + case Contract.opLoadpointSurplusOnlySet: + house.surplusOnly = args["surplus_only"]?.bool ?? false + result(cmdId, "applied", observed: house.surplusOnly ? 1 : 0) + default: + result(cmdId, "rejected", error: ("E_UNKNOWN_OP", [])) + } + } + + private func result(_ cmdId: String, _ state: String, observed: Double? = nil, error: (String, [(String, CBOR)])? = nil) { + var pairs: [(String, CBOR)] = [("cmdId", .text(cmdId)), ("state", .text(state))] + if let observed { pairs.append(("observed", .map([("value", .number(observed)), ("src", .text("core")), ("uptimeMs", .number(uptimeMs))]))) } + if let error { pairs.append(("error", .map([("code", .text(error.0)), ("args", .map(error.1))]))) } + sendControl(Envelope(t: "cmd.result", b: .map(pairs))) + } + + // MARK: The box's own API + + enum Tier { case read, configure, actuate, local } + + /// Priced beside the route, never from the method, as the box does. + static let routes: [(method: String, path: String, tier: Tier, op: String?)] = [ + ("GET", "/api/status", .read, nil), + ("GET", "/api/energy/daily", .read, nil), + ("GET", "/api/savings/daily", .read, nil), + ("GET", "/api/app-link/devices", .read, nil), + ("POST", "/api/app-link/pairing", .configure, nil), + ("DELETE", "/api/app-link/devices/{id}", .configure, nil), + ("GET", "/api/loadpoints", .read, nil), + ("GET", "/api/mpc/plan", .read, nil), + ("POST", "/api/loadpoints/{id}/vehicle", .configure, nil), + ("PUT", "/api/loadpoints/{id}/schedule", .configure, nil), + ("DELETE", "/api/loadpoints/{id}/schedule", .configure, nil), + ("POST", "/api/loadpoints/{id}/soc", .actuate, Contract.opLoadpointSocSet), + ("POST", "/api/mode", .actuate, Contract.opSetMode), + ("GET", "/api/notifications/vapid", .read, nil), + ("GET", "/api/notifications/history", .read, nil), + ("GET", "/api/notifications/rules", .read, nil), + ("PUT", "/api/notifications/rules", .configure, nil), + ("POST", "/api/notifications/test", .configure, nil), + ("POST", "/api/restart", .configure, nil), + ("GET", "/api/config", .local, nil), + ("GET", "/api/support/dump", .local, nil), + ] + + static func match(_ method: String, _ path: String) -> (tier: Tier, op: String?, pattern: String, id: String?)? { + let parts = path.split(separator: "/", omittingEmptySubsequences: false) + for r in routes where r.method == method { + let want = r.path.split(separator: "/", omittingEmptySubsequences: false) + guard want.count == parts.count else { continue } + var id: String? + var ok = true + for (a, b) in zip(want, parts) { + if a == "{id}" { id = String(b).removingPercentEncoding } else if a != b { ok = false; break } + } + if ok { return (r.tier, r.op, r.path, id) } + } + return nil + } + + private func onAPI(_ id: UInt32?, _ body: CBOR) { + let method = body["method"]?.string ?? "GET" + let path = body["path"]?.string ?? "" + let stepUp = body["stepUp"]?.bool == true + var query = [String: String]() + for e in body["query"]?.entries ?? [] { if let k = e.key.string, let v = e.value.string { query[k] = v } } + let requestBody = body["body"]?.byteString.flatMap { try? JSON(parsing: $0) } + + guard path.hasPrefix("/api/"), let route = Self.match(method, path) else { + refuse(id, "E_UNKNOWN_OP", []) + return + } + switch route.tier { + case .local: + refuse(id, "E_LOCAL_ONLY", []) + return + case .actuate: + refuse(id, "E_USE_CMD", route.op.map { [("op", .text($0))] } ?? []) + return + case .configure: + if stepUp { stepUpsSeen += 1 } + if requireStepUp && !stepUp { + refuse(id, "E_NEEDS_STEP_UP", []) + return + } + case .read: + break + } + let (status, json) = answer(method, route.pattern, route.id, query, requestBody) + respond(id, status: status, json: json) + } + + private func answer(_ method: String, _ pattern: String, _ itemID: String?, _ query: [String: String], _ body: JSON?) -> (Int, JSON) { + let now = scheduler.nowMs + switch (method, pattern) { + case ("GET", "/api/status"): + let perPhase = house.gridW / 3 + return (200, [ + "grid_w": .number(house.gridW), "pv_w": .number(house.pvW), "bat_w": .number(house.batteryW), + "load_w": .number(house.baseLoadW + house.evWatts), + "energy": ["today": [ + "import_wh": .number(house.todayImportWh), "export_wh": .number(house.todayExportWh), + "pv_wh": .number(house.todayPVWh), "load_wh": .number(house.todayLoadWh), + "bat_charged_wh": .number(house.todayChargedWh), "bat_discharged_wh": .number(house.todayDischargedWh), + ]], + "drivers": [ + "sungrow": ["status": "ok", "pv_w": .number(house.pvW), "bat_w": .number(house.batteryW), "bat_soc": .number(house.socFraction)], + "easee": ["status": "ok", "ev_w": .number(house.evWatts)], + ], + "fuse": ["max_amps": .number(house.fuseAmps), "phases": 3, "voltage": 230], + "phase_amps": .array((0..<3).map { .number(((perPhase + Double($0) * 90) / 230 * 10).rounded() / 10) }), + "phase_powers": .array((0..<3).map { .number((perPhase + Double($0) * 90).rounded()) }), + ]) + case ("GET", "/api/energy/daily"): + let days = max(1, min(90, Int(query["days"] ?? "7") ?? 7)) + let rows: [JSON] = (0..= 1_000 { house.carCapacityWh = wh } + return (200, ["ok": true]) + case ("PUT", "/api/loadpoints/{id}/schedule"): + house.schedule = ( + body?["soc"]?.number ?? 0.8, + Int(body?["time_of_day_min_utc"]?.number ?? 300), + body?["recurring"]?.bool ?? false, + Int(body?["days"]?.number ?? 0), + body?["surplus_unlock_bat_soc"]?.number ?? 0 + ) + return (200, ["ok": true]) + case ("DELETE", "/api/loadpoints/{id}/schedule"): + house.schedule = nil + return (200, ["ok": true]) + case ("GET", "/api/app-link/devices"): + return (200, ["devices": .array(devices.map { ["id": .string($0.id), "role": .string($0.role), "added_at_ms": .number($0.addedAtMs), "last_seen_ms": .number($0.lastSeenMs)] })]) + case ("POST", "/api/app-link/pairing"): + let role = body?["role"]?.string ?? "" + guard role == Contract.roleViewer || role == Contract.roleOwner else { return (400, ["code": "E_BAD_ROLE"]) } + let invite = Enrollment(boxStaticPublic: Bytes(repeating: 7, count: 32), pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + return (200, ["url": .string(invite.url()), "role": .string(role), "expires_at_ms": .number(now + 600_000)]) + case ("DELETE", "/api/app-link/devices/{id}"): + guard let itemID, devices.contains(where: { $0.id == itemID }) else { return (404, [:]) } + let owners = devices.filter { $0.role == Contract.roleOwner } + if owners.count == 1, owners.first?.id == itemID { return (409, ["code": "E_LAST_OWNER_PROTECTED"]) } + devices.removeAll { $0.id == itemID } + return (200, ["ok": true]) + case ("GET", "/api/notifications/vapid"): + return (200, ["public_key": "BDemoKeyNotForSending"]) + case ("GET", "/api/notifications/history"): + return (200, ["events": .array(sentPushes.reversed().map { ["title": .string($0.title), "at_ms": .number($0.atMs)] })]) + case ("GET", "/api/notifications/rules"): + return (200, rulesJSON()) + case ("PUT", "/api/notifications/rules"): + notifyEnabled = body?["enabled"]?.bool ?? notifyEnabled + for rule in body?["events"]?.array ?? [] { + guard let type = rule["type"]?.string, let i = notifyRules.firstIndex(where: { $0.type == type }) else { continue } + notifyRules[i].enabled = rule["enabled"]?.bool ?? false + } + return (200, rulesJSON()) + case ("POST", "/api/notifications/test"): + sentPushes.append(("Test from your FTW box", now)) + return (200, ["ok": true]) + case ("POST", "/api/restart"): + return (200, ["status": "restarting"]) + default: + return (404, [:]) + } + } + + private func rulesJSON() -> JSON { + ["enabled": .bool(notifyEnabled), "events": .array(notifyRules.map { ["type": .string($0.type), "enabled": .bool($0.enabled), "threshold": 0] })] + } + + func loadpointJSON() -> JSON { + let h = house + var lp: JSON = [ + "id": "garage", + "driver_name": "easee", + "plugged_in": .bool(h.carPluggedIn), + "vehicle_capacity_wh": .number(h.carCapacityWh), + "capacity_source": "user", + "soc_retention": "session", + "current_soc": .number(h.carSocFraction), + "soc_source": .string(h.carSocSource), + "current_power_w": .number(h.evWatts), + "delivered_wh_session": .number(h.sessionWh), + "min_charge_w": 4_140, + "max_charge_w": 11_000, + "phases": 3, + "voltage_v": 230, + "manual_active": .bool(h.manualHoldW != nil), + "manual_charge_w": h.manualHoldW.map { .number($0) } ?? .null, + "surplus_only": .bool(h.surplusOnly), + "charger": ["known": true, "available": true, "updated_at_ms": .number(scheduler.nowMs - 2_000)], + "plan_pending": false, + "plan_outdated": false, + "commanded_known": true, + "commanded_w": .number(h.evWatts), + "commanded_reason": "", + "battery_boost": [ + "active": .bool(h.boostUntilMs != nil), + "expires_at_ms": h.boostUntilMs.map { .number($0) } ?? .null, + "min_battery_soc": .number(h.boostReserve), + "stop_reason": .string(h.lastBoostStop ?? ""), + ], + ] + if let manual = h.manualHoldW { + lp = lp.setting("manual", [ + "active": true, + "state": manual == 0 ? "paused" : (h.evWatts > 100 ? "charging" : "sent"), + "requested_w": .number(manual), + "requested_a": .number((manual / 690).rounded()), + ]) + } + if let s = h.schedule { + lp = lp.setting("schedule", [ + "soc": .number(s.socFraction), "time_of_day_min_utc": .number(Double(s.minuteUTC)), + "recurring": .bool(s.recurring), "days": .number(Double(s.days)), "surplus_unlock_bat_soc": .number(s.surplusUnlock), + ]) + // The next cheap night, as one window. + let midnight = scheduler.nowMs - h.hourOfDay(scheduler.nowMs) * 3_600_000 + let start = midnight + (h.hourOfDay(scheduler.nowMs) >= 5 ? 25 : 1) * 3_600_000 + let needWh = max(0, (s.socFraction - h.carSocFraction) * h.carCapacityWh) + lp = lp.setting("plan_windows", needWh > 0 ? [["start_ms": .number(start), "end_ms": .number(start + needWh / 11_000 * 3_600_000), "wh": .number(needWh.rounded())]] : []) + lp = lp.setting("target_soc", .number(s.socFraction)) + } else { + lp = lp.setting("plan_windows", []) + } + return lp + } + + // MARK: Frames out + + private func refuse(_ id: UInt32?, _ code: String, _ args: [(String, CBOR)]) { + sendBulk(Envelope(t: "error", id: id, b: .map([("code", .text(code)), ("retryable", .bool(Contract.isRetryable(code))), ("args", .map(args))]))) + } + + private func respond(_ id: UInt32?, status: Int, json: JSON) { + let body = json.encoded() + sendBulk(Envelope(t: "api.head", id: id, b: .map([("status", .int(status)), ("headers", .map([("content-type", .text("application/json"))])), ("len", .int(body.count))]))) + var seq = 0 + var at = 0 + while at < body.count { + let end = min(body.count, at + 12_288) + sendBulk(Envelope(t: "api.chunk", id: id, b: .map([("seq", .int(seq)), ("data", .bytes(Array(body[at.. Void = { _ in } + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + + public init(box: SimulatedBox, scheduler: Scheduler, latencyMs: Double = 120) { + self.box = box + self.scheduler = scheduler + self.latencyMs = latencyMs + box.send = { [weak self] frame in + guard let self else { return } + self.scheduler.after(self.latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.onFrame(frame) + } + } + scheduler.after(latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.status = .open(sinceMs: scheduler.nowMs) + self.onStatus(self.status) + } + } + + public func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onFrame = onFrame + self.onStatus = onStatus + } + + public func send(_ frame: Bytes) { + guard status.isOpen, !closed else { return } + scheduler.after(latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.box.receive(frame) + } + } + + public func wake() {} + + public func close(reason: String) { + if closed { return } + closed = true + box.stop() + status = .closed(reason: reason, retryable: false) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedHouse.swift b/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedHouse.swift new file mode 100644 index 0000000..fa576c4 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedHouse.swift @@ -0,0 +1,197 @@ +import Foundation + +/// A made-up house for the demo and the tests: solar that follows the sun, +/// a household load with a morning and an evening, a battery run by the +/// chosen mode, and one car charger. +/// +/// Power follows the site convention: positive into the site, PV never +/// positive, and `grid = load + battery + pv` where the load includes the car. +@MainActor +public final class SimulatedHouse { + public let pvPeakW: Double = 6_500 + public let batteryCapacityWh: Double = 13_500 + public let batteryMaxW: Double = 5_000 + public let fuseAmps: Double = 25 + public let timeZone: TimeZone + + public private(set) var socFraction: Double = 0.62 + public private(set) var batteryW: Double = 0 + public private(set) var pvW: Double = 0 + public private(set) var baseLoadW: Double = 0 + public private(set) var gridW: Double = 0 + + public var modeKey = "planner_passive_arbitrage" + + // The car. + public var carPluggedIn = true + public var carSocFraction: Double = 0.41 + public var carSocSource = "inferred" + public var carCapacityWh: Double = 64_000 + public private(set) var evW: Double = 0 + public var manualHoldW: Double? + public var surplusOnly = false + public var boostUntilMs: Double? + public var boostReserve: Double = 0.3 + public var lastBoostStop: String? + public var schedule: (socFraction: Double, minuteUTC: Int, recurring: Bool, days: Int, surplusUnlock: Double)? = (0.8, 5 * 60, true, 0b0011111, 0) + public private(set) var sessionWh: Double = 5_300 + + // Energy since local midnight, integrated. + public private(set) var todayImportWh: Double = 6_100 + public private(set) var todayExportWh: Double = 2_400 + public private(set) var todayPVWh: Double = 11_800 + public private(set) var todayLoadWh: Double = 15_500 + public private(set) var todayChargedWh: Double = 4_200 + public private(set) var todayDischargedWh: Double = 3_900 + private var dayKey = "" + + public init(timeZone: TimeZone = .current) { + self.timeZone = timeZone + } + + /// Hours since local midnight, as a fraction. + func hourOfDay(_ ms: Double) -> Double { + let seconds = ms / 1000 + Double(timeZone.secondsFromGMT(for: Date(timeIntervalSince1970: ms / 1000))) + let day = seconds.truncatingRemainder(dividingBy: 86_400) + return (day < 0 ? day + 86_400 : day) / 3_600 + } + + func localDay(_ ms: Double) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = timeZone + let c = cal.dateComponents([.year, .month, .day], from: Date(timeIntervalSince1970: ms / 1000)) + return String(format: "%04d-%02d-%02d", c.year ?? 1970, c.month ?? 1, c.day ?? 1) + } + + /// Deterministic wobble in [-1, 1], so two runs of a test agree. + func wobble(_ ms: Double, _ salt: Double) -> Double { + let x = sin(ms / 7_919 + salt) * 43_758.5453 + return (x - x.rounded(.down)) * 2 - 1 + } + + /// Solar at a moment, as a PV reading (never positive). + public func pvAt(_ ms: Double) -> Double { + let h = hourOfDay(ms) + guard h > 5.5, h < 20.5 else { return 0 } + let sun = sin(Double.pi * (h - 5.5) / 15) + let cloud = 0.85 + 0.15 * wobble(ms / 60_000, 3) + return -(pvPeakW * max(0, sun) * max(0, sun) * cloud).rounded() + } + + /// The house without the car. + public func baseLoadAt(_ ms: Double) -> Double { + let h = hourOfDay(ms) + var w = 380.0 + if h >= 6.5 && h < 8.5 { w += 1_100 } + if h >= 17 && h < 19.5 { w += 1_900 } + if h >= 19.5 && h < 23 { w += 600 } + return (w + 120 * wobble(ms / 5_000, 1)).rounded() + } + + /// Import price in öre per kWh, total, for the plan and the price chart. + public func priceAt(_ ms: Double) -> Double { + let h = hourOfDay(ms) + let base = 55 + 60 * max(0, sin(Double.pi * (h - 5) / 6)) + 80 * max(0, sin(Double.pi * (h - 15) / 6)) + let night = h < 5 ? -25.0 : 0 + return (base + night + 6 * wobble(ms / 3_600_000, 7)).rounded() + } + + public var evWatts: Double { evW } + + /// Move the house forward. + public func step(nowMs: Double, dtMs: Double) { + let day = localDay(nowMs) + if day != dayKey { + if !dayKey.isEmpty { + todayImportWh = 0; todayExportWh = 0; todayPVWh = 0 + todayLoadWh = 0; todayChargedWh = 0; todayDischargedWh = 0 + } + dayKey = day + } + + pvW = pvAt(nowMs) + baseLoadW = baseLoadAt(nowMs) + + // The car. + if let until = boostUntilMs, nowMs >= until { + boostUntilMs = nil + lastBoostStop = "expired" + } + if !carPluggedIn || carSocFraction >= 0.999 { + evW = 0 + } else if let hold = manualHoldW { + evW = hold + } else if boostUntilMs != nil { + evW = 7_400 + } else if surplusOnly { + let spare = max(0, -pvW - baseLoadW - 200) + evW = spare >= 1_400 ? min(spare, 11_000).rounded() : 0 + } else if let schedule, carSocFraction < schedule.socFraction, isChargeHour(nowMs) { + evW = 11_000 + } else { + evW = 0 + } + if evW > 0 { + let wh = evW * dtMs / 3_600_000 + sessionWh += wh + carSocFraction = min(1, carSocFraction + wh * 0.92 / carCapacityWh) + } + + // The battery, by mode. + let load = baseLoadW + evW + let net = load + pvW + var want: Double + switch modeKey { + case "idle": want = 0 + case "charge": want = batteryMaxW + case "peak_shaving": want = net > 6_000 ? -(net - 6_000) : (pvW < -500 ? min(batteryMaxW, -net) : 0) + case "planner_arbitrage": + let p = priceAt(nowMs) + want = p < 50 ? batteryMaxW : (p > 120 ? -batteryMaxW : -net) + default: + // Self-consumption: cover the house, soak up the surplus. The + // boost lets the battery feed the car too, down to its reserve. + want = -net + if boostUntilMs == nil { want = -(baseLoadW + pvW) } + if boostUntilMs != nil, socFraction <= boostReserve { + boostUntilMs = nil + lastBoostStop = "battery_reserve_reached" + } + if modeKey == "planner_passive_arbitrage", want < 0, priceAt(nowMs) < 45 { want = 0 } + } + want = max(-batteryMaxW, min(batteryMaxW, want)) + if socFraction >= 0.995, want > 0 { want = 0 } + if socFraction <= 0.05, want < 0 { want = 0 } + batteryW = want.rounded() + let efficiency = batteryW > 0 ? 0.95 : 1 / 0.95 + socFraction = max(0, min(1, socFraction + batteryW * efficiency * dtMs / 3_600_000 / batteryCapacityWh)) + + gridW = (load + batteryW + pvW).rounded() + + let h = dtMs / 3_600_000 + todayLoadWh += load * h + todayPVWh += -pvW * h + if gridW > 0 { todayImportWh += gridW * h } else { todayExportWh += -gridW * h } + if batteryW > 0 { todayChargedWh += batteryW * h } else { todayDischargedWh += -batteryW * h } + } + + /// The cheap night hours before the ready time. + func isChargeHour(_ ms: Double) -> Bool { + let h = hourOfDay(ms) + return h >= 1 && h < 5 + } + + /// A day of the energy ledger, made up from the same model, for days + /// before today. + public func ledger(dayOffset: Int, nowMs: Double) -> (load: Double, pv: Double, imp: Double, exp: Double, charged: Double, discharged: Double) { + if dayOffset == 0 { + return (todayLoadWh, todayPVWh, todayImportWh, todayExportWh, todayChargedWh, todayDischargedWh) + } + let seasonal = 0.8 + 0.3 * wobble(Double(dayOffset) * 86_400_000, 11) + let pv = 26_000 * seasonal + let load = 17_000 + 3_000 * wobble(Double(dayOffset) * 86_400_000, 12) + let charged = min(12_000, pv * 0.35) + let imp = max(1_000, load - pv * 0.55) + return (load, pv, imp, max(0, pv - load * 0.45 - charged), charged, charged * 0.9) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift new file mode 100644 index 0000000..826bc58 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -0,0 +1,405 @@ +import Foundation + +/// One charger as `/api/loadpoints` serves it, read tolerantly, and the +/// sentences that describe it. No sentence claims what the box has not +/// said: a charger that does not know the car's charge is described by what +/// it does know, never by an invented percentage. +public struct Loadpoint: Equatable, Sendable, Identifiable { + public struct Schedule: Equatable, Sendable { + public var socPct: Double + public var timeOfDayMinUTC: Int + public var surplusUnlockPct: Double + public var recurring: Bool + /// 7-bit weekday mask, bit 0 = Monday. Zero means every day. + public var days: Int + } + + public struct Manual: Equatable, Sendable { + public var state: String? + public var requestedA: Double? + public var requestedW: Double? + public var commandedA: Double? + public var chargerReason: String? + public var limitReason: String? + public var chargerUpdatedAtMs: Double? + } + + public struct Charger: Equatable, Sendable { + public var known: Bool + public var available: Bool? + public var updatedAtMs: Double? + public var reason: String? + } + + public struct Window: Equatable, Sendable, Identifiable { + public var fromMs: Double + public var toMs: Double + public var peakW: Double? + public var energyWh: Double? + public var id: Double { fromMs } + } + + public var id: String + public var pluggedIn: Bool + public var powerW: Double + public var socPct: Double? + public var socSource: String + public var vehicleCapacityWh: Double? + public var capacitySource: String + public var socRetention: String + public var chargingDeclined: Bool + public var targetSocPct: Double? + public var sessionWh: Double + public var minChargeW: Double? + public var maxChargeW: Double? + public var phases: Double? + public var voltageV: Double? + public var manualSaveError: Bool + public var manualRestoreUnconfirmed: Bool + public var manualActive: Bool + public var manual: Manual? + public var charger: Charger? + public var commandedW: Double? + public var commandedReason: String + public var commandedKnown: Bool + public var updatedAtMs: Double? + public var gridDeferred: Bool + public var planStartMs: Double? + public var planEndMs: Double? + public var planPending: Bool + public var planOutdated: Bool + /// Whether this box reports its plan with the charger, in one read. + public var inlinePlan: Bool + public var planWindows: [Window] + public var manualChargeW: Double? + public var surplusOnly: Bool + public var boostActive: Bool + public var boostExpiresAtMs: Double? + public var boostReservePct: Double? + public var boostStopReason: String? + public var schedule: Schedule? + + /// A state of charge off the wire, whole percent. The box stores + /// fractions and still reads a legacy percent; zero means unset. + static func pct(_ v: JSON?) -> Double? { + guard let f = v?.number, f > 0 else { return nil } + return (f > 1 ? f : f * 100).rounded() + } + + public init(_ w: JSON) { + let num: (String) -> Double? = { w[$0]?.number } + id = w["id"]?.string ?? "" + pluggedIn = w["plugged_in"]?.bool == true + powerW = num("current_power_w") ?? 0 + let fraction = num("current_soc") + if w["plugged_in"]?.bool == false { + socPct = nil + } else if let f = fraction, f >= 0, f <= 1 { + socPct = (f * 100).rounded() + } else { + socPct = num("current_soc_pct") + } + socSource = w["soc_source"]?.string ?? "" + vehicleCapacityWh = num("vehicle_capacity_wh") + capacitySource = w["capacity_source"]?.string ?? "" + socRetention = w["soc_retention"]?.string ?? "" + chargingDeclined = w["charging_declined"]?.bool == true + targetSocPct = Loadpoint.pct(w["target_soc"]) ?? num("target_soc_pct") + sessionWh = max(0, (num("delivered_wh_session") ?? 0).rounded()) + minChargeW = num("min_charge_w") + maxChargeW = num("max_charge_w") + phases = num("phases") + voltageV = num("voltage_v") + manualActive = w["manual_active"]?.bool == true + manualRestoreUnconfirmed = w["manual_restore_unconfirmed"]?.bool == true + manualSaveError = w["manual_save_error"]?.bool == true + if let m = w["manual"], m.object != nil { + manual = Manual( + state: m["state"]?.string, + requestedA: m["requested_a"]?.number, + requestedW: m["requested_w"]?.number, + commandedA: m["commanded_a"]?.number, + chargerReason: m["charger_reason"]?.string, + limitReason: m["limit_reason"]?.string, + chargerUpdatedAtMs: m["charger_updated_at_ms"]?.number + ) + } + if let c = w["charger"], c.object != nil { + charger = Charger(known: c["known"]?.bool == true, available: c["available"]?.bool, updatedAtMs: c["updated_at_ms"]?.number, reason: c["reason"]?.string) + } + commandedW = num("commanded_w") + commandedReason = w["commanded_reason"]?.string ?? "" + commandedKnown = w["commanded_known"]?.bool == true + updatedAtMs = num("updated_at_ms") + gridDeferred = w["grid_deferred"]?.bool == true + planStartMs = num("plan_next_start_ms") + planEndMs = num("plan_next_end_ms") + planPending = w["plan_pending"]?.bool == true + planOutdated = w["plan_outdated"]?.bool == true + inlinePlan = (w["plan_pending"]?.bool != nil && w["plan_outdated"]?.bool != nil) || w["plan_windows"]?.array != nil + if planPending || planOutdated { + planWindows = [] + } else { + planWindows = (w["plan_windows"]?.array ?? []).compactMap { x in + guard let s = x["start_ms"]?.number, let e = x["end_ms"]?.number, e > s, let wh = x["wh"]?.number, wh >= 0 else { return nil } + return Window(fromMs: s, toMs: e, peakW: nil, energyWh: wh) + } + } + manualChargeW = manualActive ? num("manual_charge_w") : nil + surplusOnly = w["surplus_only"]?.bool == true + let boost = w["battery_boost"] + boostActive = boost?["active"]?.bool == true + boostExpiresAtMs = boostActive ? boost?["expires_at_ms"]?.number : nil + boostReservePct = boostActive ? Loadpoint.pct(boost?["min_battery_soc"]) : nil + if !boostActive, let reason = boost?["stop_reason"]?.string, !reason.isEmpty { + boostStopReason = reason + } else { + boostStopReason = nil + } + if let s = w["schedule"], s.object != nil, let minute = s["time_of_day_min_utc"]?.number, + let socPct = Loadpoint.pct(s["soc"]) ?? s["soc_pct"]?.number, socPct > 0 { + schedule = Schedule( + socPct: socPct, + timeOfDayMinUTC: Int(minute), + surplusUnlockPct: (s["surplus_unlock_bat_soc"]?.number ?? 0) * 100, + recurring: s["recurring"]?.bool == true, + days: Int(s["days"]?.number ?? 0) & 0x7f + ) + } + } +} + +public enum EVText { + public static let dayLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + public static let manualSaveErrorText = "This choice is active now, but could not be saved for restart. FTW is retrying." + + /// The weekday mask as a person says it. + public static func days(_ mask: Int) -> String { + let m = mask & 0x7f + if m == 0 || m == 0x7f { return "every day" } + if m == 0b0011111 { return "weekdays" } + if m == 0b1100000 { return "weekends" } + return dayLabels.enumerated().filter { m & (1 << $0.offset) != 0 }.map(\.element).joined(separator: ", ") + } + + // MARK: Clock conversions + + /// UTC minutes of the day on the local clock, today. + public static func localTime(minuteUTC: Int, at: Date = Date(), calendar: Calendar = .current) -> (hour: Int, minute: Int) { + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC")! + var comps = utc.dateComponents([.year, .month, .day], from: at) + comps.hour = minuteUTC / 60 + comps.minute = minuteUTC % 60 + let date = utc.date(from: comps) ?? at + let local = calendar.dateComponents([.hour, .minute], from: date) + return (local.hour ?? 0, local.minute ?? 0) + } + + /// A local hour and minute back to UTC minutes of the day, exact for + /// today's offset: the box's own page does the same. + public static func minuteUTC(hour: Int, minute: Int, at: Date = Date(), calendar: Calendar = .current) -> Int? { + guard (0...23).contains(hour), (0...59).contains(minute) else { return nil } + var comps = calendar.dateComponents([.year, .month, .day], from: at) + comps.hour = hour + comps.minute = minute + guard let date = calendar.date(from: comps) else { return nil } + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC")! + let u = utc.dateComponents([.hour, .minute], from: date) + return (u.hour ?? 0) * 60 + (u.minute ?? 0) + } + + public static func clock(_ ms: Double, calendar: Calendar = .current) -> String { + let c = calendar.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) + return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + } + + // MARK: Sentences + + /// What the charger is doing now. Power leads when there is any. + public static func status(_ lp: Loadpoint, canControl: Bool = true) -> String { + if lp.manualRestoreUnconfirmed { + return (canControl ? "Confirm how to continue charging." : "An owner needs to confirm how charging should continue.") + " FTW could not confirm the charger or connection." + } + if let c = lp.charger, c.available != true { + return c.known ? "Charger status is out of date. FTW cannot confirm whether the car is charging." : "Waiting for the charger’s first status report." + } + if !lp.pluggedIn { return "Not plugged in" } + if lp.manualActive { return manualStatus(lp) } + if lp.powerW >= 100 { return "Charging at \(PowerFormat.text(lp.powerW))" } + if lp.chargingDeclined { return "The car stopped asking for charge. Check its charge limit or schedule. This does not confirm the battery is full." } + if lp.commandedKnown, lp.commandedReason == "site_meter_stale" { return "Paused for safety: house power readings are out of date. Charging resumes when readings return." } + if lp.commandedKnown, lp.commandedW == 0, ["fuse_cooldown", "fuse_limit"].contains(lp.commandedReason) { return "Paused: main-fuse protection. Charging resumes on its own." } + if lp.commandedKnown, let w = lp.commandedW, w > 0 { + return "FTW requests \(PowerFormat.text(w)). Waiting for the car to draw power." + (lp.charger?.reason.map { " Charger reports: \($0)." } ?? "") + } + return "Plugged in — not charging right now" + } + + /// A zero hold is a pause; older boxes omit their zero setpoint. + public static func isPaused(_ lp: Loadpoint) -> Bool { + guard !lp.manualRestoreUnconfirmed, lp.manualActive else { return false } + if lp.manualChargeW == 0 { return true } + if lp.manual?.state == "paused" || lp.manual?.state == "pausing" { return true } + return lp.manualChargeW == nil && (lp.manual?.requestedW == 0 || lp.manual?.requestedA == 0) + } + + /// The hold is intent; only a fresh charger reading proves charging. + public static func manualStatus(_ lp: Loadpoint) -> String { + let m = lp.manual + let request = m?.requestedA.map { "\(Int($0.rounded())) A" } ?? "your charge request" + let limit = m?.commandedA.map { "\(Int($0.rounded())) A" } ?? "the requested current" + let reason = m?.chargerReason.flatMap { $0.isEmpty ? nil : " Charger reports: \($0)." } ?? "" + let flowing = lp.powerW >= 100 ? PowerFormat.text(lp.powerW) : nil + switch m?.state { + case "unavailable": return "Charger status is out of date. FTW cannot confirm whether the car is charging." + case "pausing": return "Pause requested. " + (flowing.map { "\($0) is still flowing. " } ?? "") + "Waiting for the charger to stop." + case "paused": return "Paused by you. Charging stays off until you resume the plan, choose Charge now, or unplug." + case "charging": return lp.powerW > 0 ? "Charging at \(PowerFormat.text(lp.powerW)). \(request) requested." : "The charger reports charging. Waiting for a power reading." + case "sent": return "FTW received \(request). Waiting for the charger to confirm the new limit." + (flowing.map { " Still charging at \($0)." } ?? "") + case "accepted": return "Charger reports a \(limit) limit. Waiting for the car to start drawing…\(reason)" + case "not_drawing": return "Charger offers \(limit) but the car is not drawing.\(reason.isEmpty ? " Check the car’s charge limit or schedule." : reason)" + case "stalled": + if isPaused(lp) { return "The charger has not stopped after your pause request. Check the charger’s app." } + return "The charger has not acted on \(request)." + (flowing.map { " Still charging at \($0)." } ?? "") + (reason.isEmpty ? " Check the charger and the car’s charge limit or schedule." : reason) + case "limited": + switch m?.limitReason { + case "charger_limit": return "The charger limits this request to \(limit) (\(request) requested)." + case "site_meter_stale": return "Paused for safety: house power readings are out of date. Charging resumes when readings return." + case "fuse_cooldown": return "Paused: main-fuse protection. Charging resumes on its own." + default: return "Main fuse limits this charge to \(limit) right now (\(request) requested)." + } + default: + if lp.powerW >= 100 { return "Charging at \(PowerFormat.text(lp.powerW))" } + return "Manual charge requested. Waiting for charger status." + } + } + + public static func plan(_ lp: Loadpoint, nowMs: Double, canControl: Bool = true, calendar: Calendar = .current) -> String? { + if lp.planPending { return "Updating the charging plan…" } + if lp.planOutdated { return "Charging times are unavailable. Your settings are saved." } + if !lp.pluggedIn || lp.manualActive || lp.manualRestoreUnconfirmed || lp.chargingDeclined { return nil } + if let c = lp.charger, c.available != true { return nil } + if lp.gridDeferred, lp.schedule != nil { return "Waiting for tomorrow’s electricity prices. Solar surplus can charge the car meanwhile." } + if let s = lp.planStartMs, let e = lp.planEndMs, s > 0, e > nowMs { + return "Charging planned \(clock(s, calendar: calendar))–\(clock(e, calendar: calendar))." + } + if lp.surplusOnly { return "Solar only: charging waits for spare solar power." } + if lp.schedule == nil, lp.powerW < 100 { + return canControl ? "No charging plan yet. Set a ready time, or choose Charge now." : "No charging plan yet. Ask an owner to set a ready time or start charging." + } + if lp.schedule != nil, lp.powerW < 100 { + return canControl ? "No charge window yet for this goal. Choose Charge now if you need to charge immediately." : "No charge window yet for this goal. An owner can start charging now." + } + return nil + } + + /// "85 % Ready by 07:00 · weekdays". Nil when the box has no schedule: + /// an app that cannot read one cannot claim its absence. + public static func schedule(_ lp: Loadpoint, at: Date = Date(), calendar: Calendar = .current) -> String? { + guard let s = lp.schedule else { return nil } + let t = localTime(minuteUTC: s.timeOfDayMinUTC, at: at, calendar: calendar) + let when = String(format: "%02d:%02d", t.hour, t.minute) + return "\(Int(s.socPct.rounded())) % Ready by \(when) · \(s.recurring ? days(s.days) : "once")" + } + + public static func session(_ lp: Loadpoint) -> String? { + guard lp.pluggedIn, lp.sessionWh >= 50 else { return nil } + let kwh = lp.sessionWh / 1000 + return "\(kwh >= 10 ? String(Int(kwh.rounded())) : PowerFormat.fixed(kwh, 1)) kWh this session" + } + + // MARK: The car's level + + public static let socDefaultPct: Double = 50 + + public static func socSource(_ lp: Loadpoint) -> String { + let retention: String + switch lp.socRetention { + case "session": retention = " FTW keeps this level for the same charging session, including after a box restart." + case "error": retention = " This level could not be saved for a box restart. Enter it again before relying on the plan after restarting." + default: retention = " This level must be entered again after a box restart." + } + switch lp.socSource { + case "assumed": return "Battery level needs confirmation. The plan currently assumes \(Int(lp.socPct ?? socDefaultPct)) %. Drag to match the car." + retention + case "vehicle": return "Reported by the car. Drag only to correct drift." + case "completed": return "The car stopped asking for charge. Its actual battery level is not confirmed. Drag to match the car." + default: return "Estimated from energy delivered. Drag to the real value and the plan follows." + retention + } + } + + // MARK: Charge now, in amps + + public struct Current: Equatable, Sendable { + public let minA: Int + public let maxA: Int + public let wattsPerAmp: Double + public let phases: Int + } + + /// The slider's range, with the box page's fallbacks: three phases at + /// 230 V, 6 to 16 A when no floor or ceiling was reported. + public static func current(_ lp: Loadpoint) -> Current { + let phases = (lp.phases ?? 0) > 0 ? lp.phases! : 3 + let volts = (lp.voltageV ?? 0) > 0 ? lp.voltageV! : 230 + let perAmp = phases * volts + func toA(_ w: Double?) -> Int { guard let w, w > 0 else { return 0 }; return Int((w / perAmp).rounded()) } + let minA = max(1, toA(lp.minChargeW) == 0 ? 6 : toA(lp.minChargeW)) + var maxA = toA(lp.maxChargeW) == 0 ? 16 : toA(lp.maxChargeW) + if maxA <= minA { maxA = minA + 1 } + return Current(minA: minA, maxA: maxA, wattsPerAmp: perAmp, phases: Int(phases)) + } + + /// Watts for a current, never above the ceiling the box declared. + public static func watts(_ lp: Loadpoint, amps: Int) -> Double { + let w = (Double(amps) * current(lp).wattsPerAmp).rounded() + if let cap = lp.maxChargeW, cap > 0 { return min(w, cap) } + return w + } + + public static func amps(_ lp: Loadpoint, watts: Double) -> Int { + Int((watts / current(lp).wattsPerAmp).rounded()) + } + + /// "16 A · 11.0 kW". + public static func readout(_ lp: Loadpoint, amps: Int) -> String { + "\(amps) A · \(PowerFormat.fixed(watts(lp, amps: amps) / 1000, 1)) kW" + } + + // MARK: Battery boost + + public static let boostReserveDefaultPct = 30 + public static let boostReserveMinPct = 5 + public static let boostDurations: [(seconds: Int, label: String)] = [(1800, "30 min"), (3600, "1 h"), (7200, "2 h"), (14400, "4 h")] + public static let boostDurationDefault = 3600 + + static let boostStop: [String: String] = [ + "cancelled": "it was stopped by hand", + "expired": "its time ran out", + "vehicle_unplugged": "the car was unplugged", + "ev_target_reached": "the car reached its target", + "departure_reached": "the departure time came", + "operator_hold": "a manual charge took over", + "surplus_only": "the charger went back to spare solar only", + "site_safety_block": "the site meter went quiet", + "loadpoint_driver_unavailable": "your box lost touch with the charger", + "battery_unavailable": "your box lost touch with the house battery", + "battery_reserve_reached": "the house battery reached its reserve", + "battery_hold": "the house battery was held for something else", + "core_mode": "the site mode does not allow it", + "fuse_safety_block": "the fuse limit stepped in", + "restart_lease_invalid": "your box restarted and would not resume it", + ] + + public static func boostActive(_ lp: Loadpoint, calendar: Calendar = .current) -> String { + let reserve = lp.boostReservePct.map { " down to \(Int($0)) %" } ?? "" + let until = lp.boostExpiresAtMs.map { " until \(clock($0, calendar: calendar))" } ?? "" + return "Battery boost is on — the house battery is helping the car\(reserve)\(until)." + } + + public static func boostStopped(_ lp: Loadpoint) -> String? { + guard !lp.boostActive, let reason = lp.boostStopReason else { return nil } + return "The last boost ended because \(boostStop[reason] ?? "your box stopped it")." + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift new file mode 100644 index 0000000..a8e175f --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Why, not just what. +/// +/// "Battery: -4.2 kW" is a number. "The battery is covering the house, so +/// nothing is coming from the grid" is an answer, and a glance has room for +/// one sentence. Where the readings cannot tell, this says less rather than +/// guessing: a confident wrong sentence is worse than a plain one. +public enum Explanation { + public enum Situation: String, Sendable { + case noData, exportingSurplus, chargingFromSurplus, batteryCovering, batteryShaving + case solarCovering, solarPartial, importing, dispatchBlocked + } + + public struct Result: Equatable, Sendable { + public let situation: Situation + public let headline: String + } + + static func kw(_ watts: Double) -> String { PowerFormat.text(abs(watts)) } + + public static func explain(fields: [Int: Double], dispatchBlockedBy: [String], ceilingW: Double?) -> Result { + let noise = PowerFormat.noiseW + guard let grid = fields[Contract.FID.gridW], let load = fields[Contract.FID.loadW] else { + return Result(situation: .noData, headline: "Waiting for the first reading.") + } + // The box's own safety rule outranks everything else it might do. + if !dispatchBlockedBy.isEmpty { + return Result(situation: .dispatchBlocked, headline: "Control is paused because a meter stopped reporting. Your home is running normally on grid power.") + } + // PV is never positive: -3000 means making 3 kW. + let generating = max(0, -(fields[Contract.FID.pvW] ?? 0)) + let bat = fields[Contract.FID.batteryW] ?? 0 + let ev = fields[Contract.FID.evW] ?? 0 + let carCharging = ev > noise + let covered = carCharging ? "the house and the car" : "the house" + + if grid < -noise { + return Result(situation: .exportingSurplus, headline: "Solar is covering the house and sending \(kw(grid)) back to the grid.") + } + if bat > noise, generating > noise, grid < noise { + return Result(situation: .chargingFromSurplus, headline: "Spare solar is charging the battery at \(kw(bat)).") + } + if bat < -noise { + if let ceilingW, grid > noise { + return Result(situation: .batteryShaving, headline: "The battery is supplying \(kw(bat)) to keep grid import below \(kw(ceilingW)).") + } + if grid < noise { + return Result(situation: .batteryCovering, headline: "The battery is covering \(covered), so nothing is coming from the grid.") + } + return Result(situation: .batteryShaving, headline: carCharging + ? "The car is charging at \(kw(ev)), with the battery supplying \(kw(bat))." + : "The battery is supplying \(kw(bat)), with \(kw(grid)) from the grid.") + } + if generating > noise { + if grid < noise { + return Result(situation: .solarCovering, headline: "Solar is covering everything the house is using.") + } + return Result(situation: .solarPartial, headline: "Solar is covering \(kw(generating)) of the \(kw(load)) the house is using.") + } + if grid > noise { + return Result(situation: .importing, headline: "The house is drawing \(kw(grid)) from the grid.") + } + return Result(situation: .importing, headline: "The house is drawing almost nothing right now.") + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift new file mode 100644 index 0000000..8587db6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift @@ -0,0 +1,181 @@ +import Foundation + +/// From a site's readings to the energy diagram's nodes: the same corners, +/// the same colours by role and direction, the same rules as the box's own +/// dashboard, so a phone and the box page tell one story. +public enum Flow { + /// Below this a node is idle: the box page's FLOW_IDLE_W, the same + /// number as the site's grid tolerance. + public static let idleW: Double = 42 + + public enum Role: String, Sendable { case grid, pv, battery, ev, load } + public enum Corner: Sendable { case topLeft, topRight, bottomLeft, bottomRight } + + /// Colour meaning, not colour: the UI maps these to its palette. + public enum Tone: Sendable { case muted, importing, exporting, solar, battery, charging, discharging, ev, house } + + public struct Node: Equatable, Sendable, Identifiable { + public var id: String + public var role: Role + public var corner: Corner + public var title: String + public var name: String? + /// Magnitude in kW, never signed for display. + public var kw: Double + /// Whether power flows toward the house. + public var toHub: Bool + public var tone: Tone + public var sub: String + public var socPct: Double? + public var dailyParts: [(text: String, tone: Tone)] + public var tappable: Bool + + public static func == (a: Node, b: Node) -> Bool { + a.id == b.id && a.kw == b.kw && a.toHub == b.toHub && a.sub == b.sub && a.socPct == b.socPct && a.dailyParts.map(\.text) == b.dailyParts.map(\.text) + } + } + + public struct Readings: Equatable, Sendable { + public var loadKw: Double + public var nodes: [Node] + public var selfPoweredPctToday: Double? + } + + static func idle(_ w: Double) -> Bool { abs(w) <= idleW } + + public static func tone(_ role: Role, _ watts: Double?) -> Tone { + guard let w = watts else { + switch role { + case .grid, .pv: return .muted + case .battery: return .battery + case .ev: return .ev + case .load: return .house + } + } + switch role { + case .grid: return idle(w) ? .muted : (w >= 0 ? .importing : .exporting) + case .pv: return idle(w) ? .muted : .solar + case .battery: return idle(w) ? .battery : (w >= 0 ? .charging : .discharging) + case .ev: return idle(w) ? .ev : .charging + case .load: return .house + } + } + + /// Nodes from the frozen fields on the 1 Hz stream. A field that never + /// arrived is no node, except the grid: a site that cannot see its own + /// meter is a gap the owner should see. + public static func readings(fields: [Int: Double]) -> Readings { + var nodes = [Node]() + if let g = fields[Contract.FID.gridW] { + nodes.append(Node(id: "grid", role: .grid, corner: .bottomLeft, title: "GRID", kw: abs(g) / 1000, toHub: g >= 0, tone: tone(.grid, g), sub: idle(g) ? "balanced" : (g >= 0 ? "importing" : "exporting"), dailyParts: [], tappable: true)) + } else { + nodes.append(Node(id: "grid", role: .grid, corner: .bottomLeft, title: "GRID", kw: 0, toHub: true, tone: .muted, sub: "no data", dailyParts: [], tappable: false)) + } + if let p = fields[Contract.FID.pvW] { + nodes.append(Node(id: "pv", role: .pv, corner: .topLeft, title: "SOLAR", kw: -p / 1000, toHub: true, tone: tone(.pv, p), sub: "", dailyParts: [], tappable: true)) + } + if let b = fields[Contract.FID.batteryW] { + nodes.append(Node(id: "battery", role: .battery, corner: .topRight, title: "BATTERY", kw: abs(b) / 1000, toHub: b < 0, tone: tone(.battery, b), sub: idle(b) ? "idle" : (b >= 0 ? "charging" : "discharging"), socPct: fields[Contract.FID.batterySoc].map { ($0 / 10).rounded() }, dailyParts: [], tappable: true)) + } + // No field, no node: an invented idle charger would misstate hardware. + if let e = fields[Contract.FID.evW] { + nodes.append(Node(id: "ev", role: .ev, corner: .bottomRight, title: "EV CHARGER", kw: e / 1000, toHub: false, tone: tone(.ev, e), sub: idle(e) ? "idle" : "charging", dailyParts: [], tappable: true)) + } + return Readings(loadKw: (fields[Contract.FID.loadW] ?? 0) / 1000, nodes: nodes, selfPoweredPctToday: nil) + } + + /// Nodes from GET /api/status: per-driver bubbles and energy today, the + /// dashboard's own document. + public static func readings(status: JSON) -> Readings { + var nodes = [Node]() + let today = status["energy"]?["today"] + func kwh(_ key: String) -> Double { (today?[key]?.number ?? 0) / 1000 } + let imp = kwh("import_wh"), exp = kwh("export_wh"), pvTotal = kwh("pv_wh"), loadTotal = kwh("load_wh") + let charged = kwh("bat_charged_wh"), discharged = kwh("bat_discharged_wh") + let gridDaily: [(String, Tone)] = [("↓ \(EnergyFormat.short(imp))", .importing), ("↑ \(EnergyFormat.short(exp))", .exporting)] + let batDaily: [(String, Tone)] = [("↑ \(EnergyFormat.short(charged))", .charging), ("↓ \(EnergyFormat.short(discharged))", .discharging)] + + if let g = status["grid_w"]?.number { + nodes.append(Node(id: "grid", role: .grid, corner: .bottomLeft, title: "GRID", kw: abs(g) / 1000, toHub: g >= 0, tone: tone(.grid, g), sub: idle(g) ? "balanced" : (g >= 0 ? "importing" : "exporting"), dailyParts: gridDaily.map { (text: $0.0, tone: $0.1) }, tappable: true)) + } else { + nodes.append(Node(id: "grid", role: .grid, corner: .bottomLeft, title: "GRID", kw: 0, toHub: true, tone: .muted, sub: "no data", dailyParts: [], tappable: false)) + } + + for (name, d) in (status["drivers"]?.object ?? []).sorted(by: { $0.0 < $1.0 }) { + let st = d["status"]?.string ?? "" + guard st != "offline", st != "disabled", d["not_running"]?.bool != true else { continue } + if let p = d["pv_w"]?.number { + nodes.append(Node(id: "pv-\(name)", role: .pv, corner: .topLeft, title: "SOLAR", name: name, kw: -p / 1000, toHub: true, tone: tone(.pv, p), sub: "", dailyParts: [(text: "\(EnergyFormat.short(pvTotal)) kWh", tone: .solar)], tappable: true)) + } + if let b = d["bat_w"]?.number { + let observe = d["observe_only"]?.bool == true + nodes.append(Node(id: "bat-\(name)", role: .battery, corner: .topRight, title: "BATTERY", name: name, kw: abs(b) / 1000, toHub: b < 0, tone: tone(.battery, b), sub: observe ? "observe only" : (idle(b) ? "idle" : (b >= 0 ? "charging" : "discharging")), socPct: d["bat_soc"]?.number.map { ($0 * 100).rounded() }, dailyParts: batDaily.map { (text: $0.0, tone: $0.1) }, tappable: !observe)) + } + if let e = d["ev_w"]?.number { + nodes.append(Node(id: "ev-\(name)", role: .ev, corner: .bottomRight, title: "EV CHARGER", name: name, kw: abs(e) / 1000, toHub: false, tone: tone(.ev, e), sub: idle(e) ? "idle" : "charging", dailyParts: [], tappable: true)) + } + } + let selfPowered: Double? = loadTotal > 0.001 ? max(0, min(100, (1 - imp / loadTotal) * 100)) : nil + return Readings(loadKw: (status["load_w"]?.number ?? 0) / 1000, nodes: nodes, selfPoweredPctToday: selfPowered) + } + + /// Charger watts the box's HTTP API knows, summed. + public static func loadpointChargeW(_ points: [Loadpoint]) -> Double { + points.reduce(0) { $0 + ($1.powerW > idleW ? $1.powerW : 0) } + } + + /// Put the car on its own node when the 1 Hz stream folded it into the + /// house. A box that already sends field 10 is left alone. + public static func withLoadpointEV(_ fields: [Int: Double], evW: Double) -> [Int: Double] { + guard evW > idleW else { return fields } + if let wire = fields[Contract.FID.evW], abs(wire) > idleW { return fields } + var out = fields + out[Contract.FID.evW] = evW.rounded() + out[Contract.FID.loadW] = max(0, (fields[Contract.FID.loadW] ?? 0) - evW) + return out + } + + // MARK: The fuse + + public struct Phase: Equatable, Sendable, Identifiable { + public let label: String + public let amps: Double + public let watts: Double + public let pct: Double + public let exporting: Bool + public var id: String { label } + } + + public struct Fuse: Equatable, Sendable { + public let maxAmps: Double + public let phases: [Phase] + public let fallback: (amps: Double, pct: Double)? + + public static func == (a: Fuse, b: Fuse) -> Bool { + a.maxAmps == b.maxAmps && a.phases == b.phases && a.fallback?.amps == b.fallback?.amps + } + } + + /// Per phase when the meter reports amps; otherwise one bar from grid, + /// PV and battery throughput, as the box page does. + public static func fuse(status: JSON) -> Fuse? { + guard let f = status["fuse"], let maxAmps = f["max_amps"]?.number, maxAmps > 0 else { return nil } + let n = max(1, min(3, Int((f["phases"]?.number ?? 3).rounded()))) + let voltage = f["voltage"]?.number ?? 230 + let amps = (status["phase_amps"]?.array ?? []).map { $0.number ?? 0 } + let watts = (status["phase_powers"]?.array ?? []).map { $0.number ?? 0 } + if !amps.isEmpty { + let phases = (0.. Phase in + let a = i < amps.count ? amps[i] : 0 + return Phase(label: "L\(i + 1)", amps: a, watts: i < watts.count ? watts[i] : 0, pct: min(100, abs(a) / maxAmps * 100), exporting: a < -0.1) + } + return Fuse(maxAmps: maxAmps, phases: phases, fallback: nil) + } + let grid = abs(status["grid_w"]?.number ?? 0) + let pv = abs(status["pv_w"]?.number ?? 0) + let bat = status["bat_w"]?.number ?? 0 + let throughput = max(grid, pv + (bat < 0 ? -bat : 0)) + let a = throughput / voltage / Double(n) + return Fuse(maxAmps: maxAmps, phases: [], fallback: (a, min(100, a / maxAmps * 100))) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift new file mode 100644 index 0000000..dda66dd --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift @@ -0,0 +1,104 @@ +import Foundation + +/// The freshness band in words: the web app's FreshnessBand. +/// +/// Two facts that never collapse into one: how frames reach this phone +/// (`carrier`) and whether the box's own devices are answering +/// (`srcState`). The band claims live only while readings arrive now, and +/// never claims a problem it has not confirmed: connecting while the cache +/// is on screen is normal, not a fault. +public enum Freshness { + public enum Tone: Sendable { case live, stale, reaching, lost } + + public struct Band: Equatable, Sendable { + public let tone: Tone + public let message: String + /// Seconds waited, or the boot percentage. Changes every second. + public let wait: String? + /// The age of what is on screen, as its own field. "—" means the box + /// cannot place the reading at all, which happens after a restart. + public let age: String? + } + + public static func band( + carrier: CarrierKind, + transport: CarrierKind, + srcState: SourceState, + ageMs: Double?, + phase: SessionPhase, + waitMs: Double, + bootPct: Int?, + noCarrier: Bool + ) -> Band { + let reaching = !noCarrier && [.idle, .handshaking, .subscribing, .failed, .booting].contains(phase) + let connected = carrier == .relay || carrier == .webrtc + + let tone: Tone = connected ? (srcState == .live ? .live : .stale) : (reaching ? .reaching : .lost) + + let message: String + if connected { + let liveWhere = carrier == .webrtc ? "Live at home" : "Live via encrypted relay" + let link = carrier == .webrtc ? "Home link connected" : "Encrypted relay connected" + switch srcState { + case .live: message = liveWhere + case .never: message = "\(link) · no reading yet" + case .down: message = "\(link) · a device stopped responding" + default: message = "\(link) · readings" + } + } else if phase == .terminated { + // The box is reachable; it told this phone to leave. + message = "Access ended" + } else if phase == .booting { + message = "Your box is starting" + } else if reaching { + if phase == .failed { + message = "Reconnecting to your box" + } else if phase == .subscribing { + message = "Waiting for live readings" + } else if phase == .handshaking, transport == .relay { + message = "Securing encrypted relay" + } else if phase == .handshaking, transport == .webrtc { + message = "Securing home link" + } else { + message = "Connecting to your box" + } + } else { + message = "Can't reach your box" + } + + var wait: String? + if reaching { + if phase == .booting, let bootPct { + wait = "\(bootPct)%" + } else { + wait = "\(max(0, Int(waitMs / 1000)))s" + } + } + + var age: String? + if !(connected && (srcState == .live || srcState == .never)) { + if let ageMs { + age = ageMs.isFinite ? PowerFormat.age(ageMs) : nil + } else { + age = "—" + } + } + return Band(tone: tone, message: message, wait: wait, age: age) + } +} + +extension SiteModel { + /// The band for this home, read the way the web app's shell reads it. + public func freshness(noCarrier: Bool) -> Freshness.Band { + Freshness.band( + carrier: carrier, + transport: session.carrier, + srcState: srcState, + ageMs: ageMs, + phase: session.phase, + waitMs: connectionWaitMs, + bootPct: session.boot?.pct, + noCarrier: noCarrier + ) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift b/appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift new file mode 100644 index 0000000..cbc7a5d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift @@ -0,0 +1,296 @@ +import Foundation + +/// A plan in sentences. The box sends reason codes; every word is decided +/// here. A plan is intent, not prophecy, so the copy says what the box means +/// to do and never promises it. +public enum PlanText { + public enum Action: Equatable, Sendable { case charge, discharge, idle } + + static let reasons: [String: String] = [ + "cheap_import": "Power is cheap", + "expensive_import": "Power is expensive", + "solar_surplus": "Spare solar", + "peak_shaving": "Holding the grid limit", + "reserve_held": "Keeping a reserve", + "export_paid": "Sending to the grid", + "idle": "Nothing scheduled", + ] + + /// A reason this app has not heard of is still the box's reason. + public static func reason(_ code: String) -> String { + reasons[code] ?? code.replacingOccurrences(of: "_", with: " ").capitalized + } + + public static func action(_ slot: PlanSlot) -> Action { + if slot.batteryW > 50 { return .charge } + if slot.batteryW < -50 { return .discharge } + return .idle + } + + /// Mode wording is the box's, from its own catalogue, so the dashboard + /// and this app never give one setting two names. + public static func label(_ mode: ModeInfo) -> String { mode.label } + public static func help(_ mode: ModeInfo) -> String { mode.tooltip } + + public struct Headline: Equatable, Sendable { + public let text: String + public let slotIndex: Int? + } + + /// One sentence about what happens next, which is what a plan is for. + public static func headline(_ plan: Plan?, nowMs: Double) -> Headline { + guard let plan else { return Headline(text: "No plan yet.", slotIndex: nil) } + if plan.stale { + return Headline(text: "Your box couldn't plan ahead just now, so it's running on safe defaults.", slotIndex: nil) + } + guard let current = plan.slots.firstIndex(where: { nowMs >= $0.startMs && nowMs < $0.startMs + $0.durationMs }) else { + return Headline(text: "No plan for right now.", slotIndex: nil) + } + let now = plan.slots[current] + let nowAction = action(now) + var next = current + 1 + while next < plan.slots.count, action(plan.slots[next]) == nowAction { next += 1 } + if next >= plan.slots.count { + return Headline(text: describe(now, later: false), slotIndex: current) + } + let change = plan.slots[next] + return Headline(text: "\(describe(now, later: false)) Then \(describe(change, later: true)) \(inWords(change.startMs - nowMs)).", slotIndex: current) + } + + public struct Brief: Equatable, Sendable { + public enum Tone: Sendable { case active, warn, idle } + public let stateLabel: String + public let tone: Tone + public let action: String + public let time: String? + public let reason: String? + public let constraint: String + } + + /// The box page's overview card: what, until when, why. + public static func brief(_ plan: Plan?, nowMs: Double, mode: String?, dispatchBlockedBy: [String], clock: (Double) -> String) -> Brief { + let planner = mode?.hasPrefix("planner_") ?? false + guard let plan else { + return Brief( + stateLabel: mode != nil && !planner ? "Manual" : "Checking…", + tone: .idle, + action: planner || mode == nil ? "Reading the current plan" : "Manual control is active", + time: nil, + reason: planner || mode == nil ? nil : "Planning is not controlling the battery", + constraint: constraint(nil, dispatchBlockedBy, nil) + ) + } + if plan.stale { + return Brief(stateLabel: "Fallback active", tone: .warn, action: "Your box couldn't plan ahead just now", time: nil, reason: "It's running on safe defaults", constraint: constraint(plan, dispatchBlockedBy, nil)) + } + let (label, tone) = state(mode, hasPlan: true) + guard let current = plan.slots.firstIndex(where: { nowMs >= $0.startMs && nowMs < $0.startMs + $0.durationMs }) else { + let (l, t) = state(mode, hasPlan: false) + return Brief(stateLabel: l, tone: t, action: "No plan for right now", time: nil, reason: nil, constraint: constraint(plan, dispatchBlockedBy, nil)) + } + let now = plan.slots[current] + let nowAction = action(now) + var runEnd = current + while runEnd + 1 < plan.slots.count, action(plan.slots[runEnd + 1]) == nowAction { runEnd += 1 } + let untilMs = plan.slots[runEnd].startMs + plan.slots[runEnd].durationMs + + var shown = now + var time: String? = "Now, until \(clock(untilMs))" + if nowAction == .idle { + if let future = plan.slots.enumerated().first(where: { $0.offset > current && action($0.element) != .idle })?.element { + shown = future + time = "At \(clock(future.startMs))" + } else { + time = nil + } + } + return Brief(stateLabel: label, tone: tone, action: actionLabel(shown), time: time, reason: reason(shown.reason), constraint: constraint(plan, dispatchBlockedBy, shown)) + } + + static func state(_ mode: String?, hasPlan: Bool) -> (String, Brief.Tone) { + if let mode, mode.hasPrefix("planner_") { + return hasPlan ? ("Plan active", .active) : ("Checking…", .idle) + } + if mode != nil { return ("Manual", .idle) } + return (hasPlan ? "Plan ready" : "Checking…", .idle) + } + + static func actionLabel(_ slot: PlanSlot) -> String { + let amount = PowerFormat.text(slot.batteryW) + switch action(slot) { + case .idle: return "Keep the battery steady" + case .charge: return "Charge battery at \(amount)" + case .discharge: return "Use battery at \(amount)" + } + } + + static func constraint(_ plan: Plan?, _ blocked: [String], _ slot: PlanSlot?) -> String { + if !blocked.isEmpty { return "Control is paused because a meter stopped reporting." } + if plan?.stale == true { return "The schedule is old, so FTW is using safe live balancing." } + if let slot, slot.reason == "peak_shaving", let cap = plan?.ceilingW { + return "Holding the grid limit at \(PowerFormat.text(cap))." + } + return "No active safety adjustment." + } + + static func describe(_ slot: PlanSlot, later: Bool) -> String { + let amount = PowerFormat.text(slot.batteryW) + switch action(slot) { + case .idle: + return later ? "it rests" : "The battery is resting — \(reason(slot.reason).lowercased())." + case .charge: + return later ? "it charges at \(amount)" : "The battery is charging at \(amount) — \(reason(slot.reason).lowercased())." + case .discharge: + return later ? "it covers the house at \(amount)" : "The battery is covering the house at \(amount) — \(reason(slot.reason).lowercased())." + } + } + + /// Coarse on purpose: a plan is intent that will be revised. + static func inWords(_ ms: Double) -> String { + let minutes = Int((ms / 60_000).rounded()) + if minutes < 1 { return "in a moment" } + if minutes < 25 { return "in \(minutes) min" } + if minutes < 50 { return "in about half an hour" } + let hours = Int((Double(minutes) / 60).rounded()) + if hours <= 1 { return "in about an hour" } + if hours < 10 { return "in about \(hours) hours" } + return "later today" + } +} + +/// What to call the number on a price, from the box's price-units.js. Every +/// price is minor units per kWh; only the label and the decimals differ. +public enum PriceUnits { + public struct Unit: Sendable { + public let label: String + public let perKwh: String + public let scale: Double + public let decimals: Int + } + + static let units: [String: Unit] = [ + "SEK": Unit(label: "öre", perKwh: "öre/kWh", scale: 1, decimals: 1), + "NOK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), + "DKK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), + "EUR": Unit(label: "cent", perKwh: "cent/kWh", scale: 1, decimals: 1), + "PLN": Unit(label: "gr", perKwh: "gr/kWh", scale: 1, decimals: 1), + "CHF": Unit(label: "Rp.", perKwh: "Rp./kWh", scale: 1, decimals: 1), + "CZK": Unit(label: "Kč", perKwh: "Kč/kWh", scale: 0.01, decimals: 2), + "HUF": Unit(label: "Ft", perKwh: "Ft/kWh", scale: 0.01, decimals: 1), + "RON": Unit(label: "lei", perKwh: "lei/kWh", scale: 0.01, decimals: 2), + ] + + public static func unit(_ currency: String?) -> Unit { + let code = (currency ?? "SEK").isEmpty ? "SEK" : (currency ?? "SEK").uppercased() + return units[code] ?? Unit(label: code, perKwh: "\(code)/kWh", scale: 0.01, decimals: 3) + } + + public static func display(_ minor: Double, _ currency: String?) -> Double { + minor * unit(currency).scale + } + + /// "17.4", in the chart's unit and precision. + public static func text(_ minor: Double?, _ currency: String?) -> String? { + guard let minor, minor.isFinite else { return nil } + return PowerFormat.fixed(display(minor, currency), unit(currency).decimals) + } + + /// Whether a price window misses hours rather than ending early: a head + /// that starts after the window asked for, or a hole in the middle. + public static func hasHole(_ slots: [PriceSlot], fromMs: Double) -> Bool { + guard let first = slots.first else { return false } + if first.startMs > fromMs { return true } + for i in slots.indices.dropFirst() where slots[i - 1].startMs + slots[i - 1].durationMs < slots[i].startMs { + return true + } + return false + } +} + +/// The box's compact savings card, from GET /api/savings/daily. +public enum Savings { + public struct Day: Equatable, Sendable { + public let day: String + public let savedOre: Double + public let resolution: String + } + + public struct Period: Equatable, Sendable { + public let savedMinor: Double + public let pricedDays: Int + public let totalDays: Int + public var available: Bool { pricedDays > 0 } + public var complete: Bool { totalDays > 0 && pricedDays == totalDays } + } + + public struct Periods: Equatable, Sendable { + public let today: Period + public let week: Period + public let month: Period + } + + public static func day(_ row: JSON) -> Day? { + guard let d = row["day"]?.string, d.count == 10, d.dropFirst(4).first == "-" else { return nil } + return Day(day: d, savedOre: row["saved_ore"]?.number ?? 0, resolution: row["resolution"]?.string ?? "slot") + } + + static func summarize(_ rows: [Day]) -> Period { + let priced = rows.filter { $0.resolution != "no_prices" } + return Period(savedMinor: priced.reduce(0) { $0 + $1.savedOre }, pricedDays: priced.count, totalDays: rows.count) + } + + public static func periods(_ days: [Day]) -> Periods { + let rows = days.sorted { $0.day < $1.day } + let month = rows.last.map { String($0.day.prefix(7)) } ?? "" + return Periods( + today: summarize(Array(rows.suffix(1))), + week: summarize(Array(rows.suffix(7))), + month: summarize(month.isEmpty ? [] : rows.filter { $0.day.hasPrefix(month + "-") }) + ) + } + + /// Signed major units: "+12.4", "−3.10". The currency sits in the heading. + public static func compact(_ minor: Double) -> String { + let major = minor / 100 + let a = abs(major) + let digits = a >= 100 ? 0 : a >= 10 ? 1 : 2 + return (major >= 0 ? "+" : "−") + PowerFormat.fixed(a, digits) + } +} + +/// Sentences for a command's fate. One table for every op: the codes are +/// about the door, not about what was asked. +public enum CommandText { + public static func help(_ r: CmdResult) -> String { + switch r.errorCode { + case "E_PRECONDITION": return "Your home changed while that was sending. Have another go." + case "E_CONFLICT": return "Something else changed the setting first. Try again." + case "E_SCOPE_DENIED": return "You don't have permission to change how this home runs." + case "E_CMD_EXPIRED": return "That took too long to reach your box. Try again." + case "E_BOOTING": return "Your box is still starting. Give it a minute." + case "E_UNAVAILABLE": + if r.errorArgs["op"]?.string == Contract.opLoadpointSurplusOnlySet { return "Solar rule not saved. Your previous choice is unchanged. Try again." } + return "Your box can't reach the charger right now. Try again shortly." + default: return "That didn't go through. Try again." + } + } + + /// The boost's own refusals before the door's. + public static func boostHelp(_ r: CmdResult) -> String { + if r.errorCode == "E_UNAVAILABLE", r.errorArgs["op"]?.string != nil { + return "Your box won't boost right now — the house battery or the site isn't ready for it." + } + if r.errorCode == "E_UNKNOWN_OP", r.errorArgs["arg"]?.string == "lease" { + return "Your box refused that reserve and time. Try other values." + } + return help(r) + } + + /// A level for a car that is not on the cable is refused by name. + public static func socHelp(_ r: CmdResult) -> String { + if r.errorCode == "E_UNAVAILABLE", r.errorArgs["reason"]?.string == "unplugged" { + return "Plug the car in first — your box has no car to set a level for." + } + return help(r) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Power.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Power.swift new file mode 100644 index 0000000..bbf4bea --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Power.swift @@ -0,0 +1,124 @@ +import Foundation + +/// Power in words and figures. +/// +/// FTW's convention is right for the wire and wrong for a person: nobody +/// reads "-4200 W" and thinks "the battery is covering the house". So the +/// UI never shows a raw minus sign. It shows a direction word and a +/// magnitude, and each screen supplies its own vocabulary. +public enum PowerFormat { + public enum Direction: Equatable, Sendable { + case into, out, idle + } + + /// Below this a reading is sensor noise. One threshold for the headline + /// and the cards, or they contradict each other on the same screen. + public static let noiseW: Double = 50 + + public struct Parts: Equatable, Sendable { + /// Magnitude in `unit`, never negative. + public let value: Double + public let unit: String + public let direction: Direction + /// Ready to show: "4.2", with decimals that keep digits from jumping. + public let text: String + + public var joined: String { "\(text) \(unit)" } + } + + public static func direction(_ watts: Double) -> Direction { + guard watts.isFinite, abs(watts) >= noiseW else { return .idle } + return watts > 0 ? .into : .out + } + + public static func parts(_ watts: Double) -> Parts { + let dir = direction(watts) + let a = watts.isFinite ? abs(watts) : 0 + if a < 1000 { + let w = a.rounded() + return Parts(value: w, unit: "W", direction: dir, text: String(Int(w))) + } + if a < 1_000_000 { + let kw = a / 1000 + return Parts(value: kw, unit: "kW", direction: dir, text: fixed(kw, kw < 10 ? 1 : 0)) + } + let mw = a / 1_000_000 + return Parts(value: mw, unit: "MW", direction: dir, text: fixed(mw, mw < 10 ? 2 : 1)) + } + + /// "4.2 kW", magnitude only. + public static func text(_ watts: Double) -> String { parts(watts).joined } + + /// A chart rung: round by construction, so no forced decimal. + public static func scale(_ watts: Double) -> String { + guard watts.isFinite else { return "" } + let a = abs(watts) + if a < 1000 { return "\(Int(a.rounded())) W" } + if a < 1_000_000 { return "\(trim(a / 1000)) kW" } + return "\(trim(a / 1_000_000)) MW" + } + + /// Permille on the wire, whole percent on screen. + public static func soc(_ permille: Double) -> String { + guard permille.isFinite else { return "—" } + return String(Int((permille / 10).rounded())) + } + + /// How old a reading is, coarse on purpose: a number ticking up every + /// second draws the eye to the staleness rather than the reading. + public static func age(_ ms: Double?) -> String { + guard let ms, ms.isFinite, ms >= 0 else { return "unknown" } + let s = Int(ms / 1000) + if s < 5 { return "just now" } + if s < 60 { return "\(s)s ago" } + let m = s / 60 + if m < 60 { return "\(m) min ago" } + let h = m / 60 + if h < 24 { return "\(h) h ago" } + return "\(h / 24) d ago" + } + + /// JavaScript's toFixed, which rounds half away from zero on the + /// decimal digits it keeps. + public static func fixed(_ v: Double, _ digits: Int) -> String { + let scale = pow(10, Double(digits)) + let r = (v * scale).rounded(.toNearestOrAwayFromZero) / scale + return String(format: "%.\(digits)f", r) + } + + static func trim(_ v: Double) -> String { + let r = (v * 10).rounded() / 10 + return r.rounded() == r ? String(Int(r)) : String(r) + } +} + +/// Energy in words. Watt-hours cross the wire and kilowatt-hours go on +/// screen, converted here and nowhere else, so the bars and the total over +/// them always add up. +public enum EnergyFormat { + /// Integer watt-hours from whatever the box sent. Every daily figure is + /// a magnitude, so a negative is a counter fault and reads as zero. + public static func wholeWh(_ value: Double?) -> Double { + guard let v = value, v.isFinite, v > 0 else { return 0 } + return v.rounded() + } + + /// "12.3" and "kWh": one decimal below ten, none above, as the box's page. + public static func parts(_ wh: Double) -> (text: String, unit: String) { + let kwh = wh / 1000 + return (PowerFormat.fixed(kwh, kwh < 10 ? 1 : 0), "kWh") + } + + public static func label(_ wh: Double) -> String { + let p = parts(wh) + return "\(p.text) \(p.unit)" + } + + /// Compact kWh for a bubble line, the dashboard's fmtKwhShort. + public static func short(_ kwh: Double) -> String { + let v = abs(kwh) + if v >= 100 { return PowerFormat.fixed(kwh, 0) } + if v >= 10 { return PowerFormat.fixed(kwh, 1) } + return PowerFormat.fixed(kwh, 2) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/PriceStrip.swift b/appleApp/FTWKit/Sources/FTWKit/Format/PriceStrip.swift new file mode 100644 index 0000000..e04c0ac --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/PriceStrip.swift @@ -0,0 +1,83 @@ +import Foundation + +/// The price card's reading of a window: the price now, the cheapest two +/// hours ahead, and a tone for every slot still to come. The box's +/// price-summary.js, price-strip.js and price-math.js, on the consumer +/// total the box already computed. +public enum PriceStrip { + public enum Tone: Sendable { case dear, cheap, flat, negative } + + public struct Bar: Identifiable, Equatable, Sendable { + public let startMs: Double + public let endMs: Double + /// Minor units per kWh, the consumer total. + public let minor: Double + public let tone: Tone + public let current: Bool + public var id: Double { startMs } + } + + public struct Block: Equatable, Sendable { + public let meanMinor: Double + public let startMs: Double + public let endMs: Double + } + + public struct Summary: Equatable, Sendable { + public let current: PriceSlot? + /// Every slot not yet over, the current one included. + public let bars: [Bar] + public let meanMinor: Double? + /// The cheapest two whole hours in a row, not the cheapest single + /// slot: a dishwasher or a car top-up runs in blocks, not troughs. + public let cheapest: Block? + } + + public static func summary(_ prices: Prices, nowMs: Double) -> Summary { + let slots = prices.slots.filter { $0.totalMinor.isFinite }.sorted { $0.startMs < $1.startMs } + let current = slots.first { nowMs >= $0.startMs && nowMs < $0.startMs + $0.durationMs } + let upcoming = slots.filter { $0.startMs + $0.durationMs > nowMs } + guard upcoming.count >= 2 else { + return Summary(current: current, bars: [], meanMinor: nil, cheapest: bestBlock(upcoming, hours: 2, cheapest: true)) + } + let values = upcoming.map(\.totalMinor) + let mean = values.reduce(0, +) / Double(values.count) + let hi = values.max() ?? 0, lo = values.min() ?? 0 + let flat = max(hi - lo, 1) * 0.05 + let bars = upcoming.map { s -> Bar in + let tone: Tone = s.totalMinor < 0 ? .negative : abs(s.totalMinor - mean) < flat ? .flat : s.totalMinor > mean ? .dear : .cheap + return Bar(startMs: s.startMs, endMs: s.startMs + s.durationMs, minor: s.totalMinor, tone: tone, current: s.startMs == current?.startMs) + } + return Summary(current: current, bars: bars, meanMinor: mean, cheapest: bestBlock(upcoming, hours: 2, cheapest: true)) + } + + /// The contiguous run of `hours` with the lowest (or highest) mean. + public static func bestBlock(_ slots: [PriceSlot], hours: Double, cheapest: Bool) -> Block? { + let need = hours * 3_600_000 + var best: Block? + for i in slots.indices { + var span = 0.0, sum = 0.0, count = 0.0 + var j = i + while j < slots.count, span < need { + if j > i, slots[j].startMs != slots[j - 1].startMs + slots[j - 1].durationMs { break } + span += slots[j].durationMs + sum += slots[j].totalMinor + count += 1 + j += 1 + } + guard span >= need, count > 0 else { continue } + let mean = sum / count + if best == nil || (cheapest ? mean < best!.meanMinor : mean > best!.meanMinor) { + best = Block(meanMinor: mean, startMs: slots[i].startMs, endMs: slots[i].startMs + span) + } + } + return best + } + + /// The card's figure: no decimals from a hundred up, else the unit's. + public static func text(_ minor: Double?, _ currency: String?) -> String { + guard let minor, minor.isFinite else { return "—" } + let shown = PriceUnits.display(minor, currency) + return PowerFormat.fixed(shown, abs(shown) >= 100 ? 0 : PriceUnits.unit(currency).decimals) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift new file mode 100644 index 0000000..63b28b1 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift @@ -0,0 +1,70 @@ +import Foundation + +/// The box code: eight characters somebody reads out loud from the box's +/// screen. They decode to the same five bytes a scanned code's payload +/// carries and are spent in the same place, handshake message 1. +/// +/// Crockford base32 without I, L, O and U. Every fold happens here, before an +/// attempt is spent: the box burns a code after five wrong tries, so a typo +/// this file could have normalised must never cost the household its code. +/// Mirrors DecodeSpokenCode in the box's appenroll/boxcode.go. +public enum BoxCode { + public static let bytes = 5 + public static let chars = 8 + + static let alphabet = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ") + static let ignored: Set = [" ", "-", "\t"] + + public struct BoxCodeError: Error, Equatable, HelpfulError { + public let message: String + public let help: String + } + + static let notACode = "That is not a code from your box. It is eight characters, like 04HM-ASW9." + + private static func fold(_ c: Character) -> Character { + switch c { + case "I", "L": return "1" + case "O": return "0" + default: return c + } + } + + /// Fold what someone typed onto the alphabet, dropping what does not + /// belong. For the input field, on every keystroke. + public static func fold(_ typed: String) -> String { + var out = "" + for raw in typed.uppercased() { + if ignored.contains(raw) { continue } + let c = fold(raw) + if alphabet.contains(c) { out.append(c) } + } + return String(out.prefix(chars)) + } + + /// XXXX-XXXX. The hyphen is for the reader. + public static func group(_ folded: String) -> String { + folded.count > 4 ? "\(folded.prefix(4))-\(folded.dropFirst(4))" : folded + } + + /// The five bytes the box drew. A character outside the alphabet means + /// the person is reading the wrong line, so it is refused rather than + /// dropped. Nothing here reaches the box. + public static func decode(_ typed: String) throws -> Bytes { + var chars = [Character]() + for raw in typed.uppercased() { + if ignored.contains(raw) { continue } + let c = fold(raw) + guard alphabet.contains(c) else { + throw BoxCodeError(message: "\(raw) is not in the alphabet", help: notACode) + } + chars.append(c) + } + guard chars.count == Self.chars else { + throw BoxCodeError(message: "\(chars.count) characters, expected \(Self.chars)", help: notACode) + } + var n: UInt64 = 0 + for c in chars { n = n << 5 | UInt64(alphabet.firstIndex(of: c)!) } + return (0..> UInt64(8 * (bytes - 1 - $0))) } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift new file mode 100644 index 0000000..5d03d9a --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift @@ -0,0 +1,150 @@ +import Foundation + +/// The pairing payload. +/// +/// https://app.ftw.energy/p#v2.... +/// +/// Everything after `#` is a fragment and never sent in a request, so the +/// trust anchor reaches the phone optically and reaches no server on the way. +public struct Enrollment: Equatable, Sendable { + /// The box's Noise static public key. The trust anchor. + public var boxStaticPublic: Bytes + /// Single use. The box refuses it a second time. Never log this. + public var pairingCode: Bytes + /// Where the box believes it can be reached on the LAN. May be empty. + public var lanHint: String + /// The long-lived secret the rotating relay handle is derived from. + public var rendezvousSecret: Bytes + + public static let version = "v2" + public static let host = Origin.appHost + public static let path = "/p" + public static let boxKeyBytes = 32 + public static let pairingCodeBytes = 16 + public static let rendezvousSecretBytes = 32 + public static let maxLanHintChars = 64 + + public init(boxStaticPublic: Bytes, pairingCode: Bytes, lanHint: String, rendezvousSecret: Bytes) { + self.boxStaticPublic = boxStaticPublic + self.pairingCode = pairingCode + self.lanHint = lanHint + self.rendezvousSecret = rendezvousSecret + } +} + +/// What the user does next. Never which check failed. +public struct EnrollmentError: Error, Equatable, HelpfulError { + public let code: String + public let message: String + public let help: String + + static let scanAgain = "That code did not read cleanly. Hold the phone steady and scan it again." + static let wrongCode = "That is not an FTW pairing code. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR shown there." + static let appTooOld = "This box needs a newer version of the app. Update the app, then scan again." + static let boxTooOld = "This box needs a software update before it can pair. Update the box, then scan again." +} + +/// An error that carries a sentence the person holding the phone can act on. +public protocol HelpfulError: Error { + var help: String { get } +} + +extension Enrollment { + /// Parse whatever a camera, a paste or a link handed over: a full URL or + /// just the fragment. + public static func parse(scanned raw: String) throws -> Enrollment { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "nothing scanned", help: "That code did not scan. Try again.") + } + return text.hasPrefix("#") ? try parse(fragment: text) : try parse(url: text) + } + + public static func parse(url raw: String) throws -> Enrollment { + guard let components = URLComponents(string: raw), let scheme = components.scheme else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "not a URL", help: EnrollmentError.wrongCode) + } + guard scheme.lowercased() == "https" else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "scheme \(scheme) is not https", help: EnrollmentError.wrongCode) + } + guard components.host?.lowercased() == host, components.port == nil, components.user == nil else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "host is not FTW", help: EnrollmentError.wrongCode) + } + guard components.path == path else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "path \(components.path) is not \(path)", help: EnrollmentError.wrongCode) + } + // The fragment as written, not percent-decoded: every segment is + // base64url and a decoded one would be a different string. + guard let hashIndex = raw.firstIndex(of: "#") else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "no fragment", help: EnrollmentError.wrongCode) + } + return try parse(fragment: String(raw[hashIndex...])) + } + + public static func parse(fragment raw: String) throws -> Enrollment { + let body = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw + if body.isEmpty { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "empty fragment", help: EnrollmentError.wrongCode) + } + let parts = body.split(separator: ".", omittingEmptySubsequences: false).map(String.init) + let v = parts[0] + + // Version before shape, and which side is behind decides the + // sentence. Telling someone to update the wrong thing is worse than + // saying nothing. + if v != version { + if v.count > 1, v.hasPrefix("v"), let n = Int(v.dropFirst()), v.dropFirst().allSatisfy(\.isNumber) { + let ours = Int(version.dropFirst())! + throw EnrollmentError(code: "E_QR_VERSION", message: "payload version \(v)", help: n < ours ? EnrollmentError.boxTooOld : EnrollmentError.appTooOld) + } + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "fragment does not start with a version", help: EnrollmentError.wrongCode) + } + guard parts.count == 5 else { + throw EnrollmentError(code: "E_QR_SHAPE", message: "\(parts.count) segments, expected 5", help: EnrollmentError.scanAgain) + } + + let box = try segment(parts[1]) + let code = try segment(parts[2]) + let hintBytes = try segment(parts[3]) + let secret = try segment(parts[4]) + + guard box.count == boxKeyBytes else { + throw EnrollmentError(code: "E_QR_KEY", message: "box key is \(box.count) bytes", help: EnrollmentError.scanAgain) + } + guard code.count == pairingCodeBytes else { + throw EnrollmentError(code: "E_QR_CODE", message: "pairing code is \(code.count) bytes", help: EnrollmentError.scanAgain) + } + // The hint becomes a connection target later, so it is checked here. + guard let hint = String(bytes: hintBytes, encoding: .utf8), + hint.count <= maxLanHintChars, + hint.unicodeScalars.allSatisfy({ $0.value >= 0x21 && $0.value <= 0x7e }) else { + throw EnrollmentError(code: "E_QR_HINT", message: "lan hint is not a plain address", help: EnrollmentError.scanAgain) + } + // Downstream this is HKDF input keying material, and a short secret + // there is a handle an attacker can enumerate. + guard secret.count == rendezvousSecretBytes else { + throw EnrollmentError(code: "E_QR_SECRET", message: "rendezvous secret is \(secret.count) bytes", help: EnrollmentError.scanAgain) + } + return Enrollment(boxStaticPublic: box, pairingCode: code, lanHint: hint, rendezvousSecret: secret) + } + + /// The inverse, for tests and the loopback box. + public func url() -> String { + let fragment = [ + Enrollment.version, + Base64url.encode(boxStaticPublic), + Base64url.encode(pairingCode), + Base64url.encode(Array(lanHint.utf8)), + Base64url.encode(rendezvousSecret), + ].joined(separator: ".") + return "https://\(Enrollment.host)\(Enrollment.path)#\(fragment)" + } + + private static func segment(_ s: String) throws -> Bytes { + do { + return try Base64url.decode(s) + } catch { + throw EnrollmentError(code: "E_QR_ENCODING", message: "segment is not base64url", help: EnrollmentError.scanAgain) + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift new file mode 100644 index 0000000..e4370f5 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift @@ -0,0 +1,296 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// The sealed copy Sourceful holds, so a new phone is not a dead end. +/// +/// "Sourceful holds a sealed copy it cannot open, with an opaque id and +/// nothing beside it." Sealed under a key derived from PRF output; the id is +/// an HKDF sibling of that key; a write is signed, a read is not, because a +/// fresh install has a passkey and nothing else. The wire is the web app's, +/// so either app reads what the other wrote. +/// +/// Losing the service costs a QR scan. Nothing depends on it. +@MainActor +public final class Escrow { + /// Every request body is exactly this long; the service refuses others. + static let requestBytes = 1024 + + public enum SaveOutcome: Equatable, Sendable { + case saved, cleared, unsupported, unreachable, failed, conflict + } + + public enum RemoveOutcome: Equatable, Sendable { + case removed, nothingToRemove, declined, kept + } + + public struct EscrowError: Error, Equatable, HelpfulError { + public let code: String + public let message: String + public let help: String + } + + /// One POST to one path. Injected by tests. + public typealias Transport = @MainActor (_ body: Bytes) async throws -> (status: Int, body: Bytes) + + private let transport: Transport + private let vault: Vault + private let sites: SiteList + + public init(vault: Vault, sites: SiteList, transport: Transport? = nil) { + self.vault = vault + self.sites = sites + self.transport = transport ?? Escrow.urlSessionTransport(Origin.escrowURL.appendingPathComponent("e")) + } + + // MARK: Which homes + + /// The homes this household asked to be held, oldest first. + public func escrowedHomes() -> [RecoveryBlob.Home] { + sites.all() + .filter { $0.escrow && $0.rendezvousSecret != nil } + .sorted { $0.addedAtMs < $1.addedAtMs } + .map { RecoveryBlob.Home(siteId: $0.siteId, label: $0.label, boxStaticKey: $0.boxStaticKey.byteArray, rendezvousSecret: $0.rendezvousSecret!.byteArray) } + } + + public func mark(_ siteId: String, _ on: Bool) { + sites.update(siteId) { $0.escrow = on } + } + + // MARK: Writing + + /// Put the marked homes in the escrow, replacing what was there. Reports + /// rather than throws: a device with no spare copy is the device everyone + /// had yesterday. + public func save(_ wrapping: WrappingKey, pending: RecoveryBlob.Home? = nil) async -> SaveOutcome { + guard let keys = wrapping.escrow else { return .unsupported } + do { + var homes = escrowedHomes() + if let pending { + homes = homes.filter { $0.siteId != pending.siteId } + [pending] + } + if homes.isEmpty { + return try await clear(keys) ? .cleared : .conflict + } + var scalar = try vault.exportDeviceSecret(wrapping) + defer { wipe(&scalar) } + // Twice at most. The version is bound into the seal, so losing a + // race means sealing again under the new number. + for _ in 0..<2 { + let held = try await readHeld(keys.lookupID) + let version = (held?.version ?? 0) + 1 + let sealed = try RecoveryBlob.seal(key: keys.sealKey, .init(deviceScalar: scalar, homes: homes), escrowVersion: version) + if try await put(keys, version: version, blob: sealed) { return .saved } + } + return .conflict + } catch is EscrowError { + return .unreachable + } catch { + return .failed + } + } + + /// Take the copy away. Costs nothing at all when no home opted in. + public func remove(_ passkeys: PasskeyAuthenticator?) async -> RemoveOutcome { + if escrowedHomes().isEmpty { return .nothingToRemove } + let wrapping: WrappingKey + do { + wrapping = try await vault.unlockWrappingKey(passkeys) + } catch is PasskeyCancelled { + return .declined + } catch { + return .kept + } + guard let keys = wrapping.escrow else { return .kept } + do { + return try await clear(keys) ? .removed : .kept + } catch { + return .kept + } + } + + /// Empty the copy rather than delete the row: a delete would restart the + /// version at 1, and a kept old blob could then be written straight back. + private func clear(_ keys: EscrowKeys) async throws -> Bool { + for _ in 0..<2 { + guard let held = try await readHeld(keys.lookupID) else { return true } + if held.blob.isEmpty { return true } + if try await put(keys, version: held.version + 1, blob: []) { return true } + } + return false + } + + // MARK: Bringing a home back + + public struct Recovered: Identifiable, Sendable { + public let siteId: String + public let label: String + public let fingerprint: String + let deviceScalar: Bytes + let wrapping: WrappingKey + let everyHome: [RecoveryBlob.Home] + public var id: String { siteId } + } + + /// One ceremony yields the id and the key. An empty result is the + /// ordinary answer for a passkey that never saved anything; a copy that is + /// there and will not open throws, because that one means something + /// replaced it. + public func recover(_ passkeys: PasskeyAuthenticator?) async throws -> [Recovered] { + guard let passkeys, passkeys.isAvailable else { return [] } + let outcome = try await passkeys.assert(credentialIDs: []) + guard let prf = outcome.prfOutput else { + throw RecoveryBlob.BlobError(code: "E_BLOB_LOCKED", message: "no PRF output", help: "This passkey cannot unlock what was saved. Open your box's local dashboard and use Settings → FTW app → Show pairing code.") + } + let wrapping = PRF.wrappingKey(credentialID: outcome.credentialID, prfOutput: prf) + guard let keys = wrapping.escrow else { return [] } + guard let held = try await readHeld(keys.lookupID), !held.blob.isEmpty else { return [] } + let contents = try RecoveryBlob.open(key: keys.sealKey, held.blob, escrowVersion: held.version) + return contents.homes.map { + Recovered(siteId: $0.siteId, label: $0.label, fingerprint: SiteList.fingerprint($0.boxStaticKey), deviceScalar: contents.deviceScalar, wrapping: wrapping, everyHome: contents.homes) + } + } + + /// Write a recovered home down. The device key is put back rather than + /// minted, wrapped under the local key so connecting stays silent and + /// under the passkey that just answered. Every home in the copy is + /// written, because the next save rebuilds the copy from this disk. + @discardableResult + public func adopt(_ home: Recovered, nowMs: Double) throws -> String { + let local = vault.localWrappingKey() + try vault.restoreDeviceKey(local, scalar: home.deviceScalar) + try vault.addCredential(current: local, next: home.wrapping) + for each in home.everyHome { + sites.put(StoredSite( + siteId: each.siteId, + label: each.label, + boxStaticKey: Data(each.boxStaticKey), + rendezvousSecret: Data(each.rendezvousSecret), + pairingCode: nil, + lanHint: nil, + escrow: true, + addedAtMs: nowMs, + lastSeenAtMs: nowMs + )) + } + return home.siteId + } + + // MARK: The wire + + struct Held { + var version: UInt32 + var blob: Bytes + } + + private func readHeld(_ lookupID: String) async throws -> Held? { + let response = try await request([("op", .string("get")), ("id", .string(lookupID))]) + if response.status == 404 { return nil } + guard response.status == 200 else { throw refused(response.status) } + guard let object = try? JSONSerialization.jsonObject(with: Data(response.body)) as? [String: Any], + let version = (object["version"] as? NSNumber)?.uint32Value, + let text = object["blob"] as? String, + let blob = Data(base64Encoded: text)?.byteArray, + blob.count == RecoveryBlob.maxBytes || blob.isEmpty else { + throw refused(response.status) + } + return Held(version: version, blob: blob) + } + + /// The bytes a write is signed over; the service's `writeMessage`. + static func writeMessage(lookupID: String, version: UInt32, blob: Bytes) -> Bytes { + Array("ftw-escrow:v1:\(lookupID):\(version):\(Primitives.sha256(blob).hex)".utf8) + } + + /// True when it landed; false is a lost race and nothing else. + private func put(_ keys: EscrowKeys, version: UInt32, blob: Bytes) async throws -> Bool { + let signature = try keys.sign(Self.writeMessage(lookupID: keys.lookupID, version: version, blob: blob)) + let response = try await request([ + ("op", .string("put")), + ("id", .string(keys.lookupID)), + ("version", .number(Int(version))), + ("blob", .string(Data(blob).base64EncodedString())), + ("pub", .string(Base64url.encode(keys.writeKey))), + ("sig", .string(Base64url.encode(signature))), + ]) + if response.status == 200 { return true } + if response.status == 409 { return false } + throw refused(response.status) + } + + enum Field { + case string(String) + case number(Int) + } + + /// `body` as JSON with a `pad` member that makes the whole exactly + /// `bytes` long, the way the web app pads it. The id never goes in a URL: + /// a URL is what every layer writes down. + static func padded(_ fields: [(String, Field)], to bytes: Int = requestBytes) -> Bytes { + func render(_ pad: String) -> String { + var parts = fields.map { key, value -> String in + switch value { + case .string(let s): return "\(jsonString(key)):\(jsonString(s))" + case .number(let n): return "\(jsonString(key)):\(n)" + } + } + parts.append("\"pad\":\(jsonString(pad))") + return "{" + parts.joined(separator: ",") + "}" + } + let empty = render("") + return Array(render(String(repeating: "A", count: max(0, bytes - empty.utf8.count))).utf8) + } + + private func request(_ fields: [(String, Field)]) async throws -> (status: Int, body: Bytes) { + do { + return try await transport(Self.padded(fields)) + } catch { + throw EscrowError(code: "E_ESCROW_UNREACHABLE", message: "the escrow could not be reached", help: "The saved copy could not be reached. Your home works either way — try again when you are back online.") + } + } + + private func refused(_ status: Int) -> EscrowError { + EscrowError(code: "E_ESCROW_REFUSED", message: "the escrow answered \(status)", help: "The saved copy could not be read. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR instead.") + } + + /// No cookies, no cache, nothing an origin could be recognised by. + static func urlSessionTransport(_ url: URL) -> Transport { + { body in + let config = URLSessionConfiguration.ephemeral + config.httpCookieStorage = nil + config.urlCache = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + let session = URLSession(configuration: config) + defer { session.finishTasksAndInvalidate() } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = Data(body) + let (data, response) = try await session.data(for: request) + return ((response as? HTTPURLResponse)?.statusCode ?? 0, data.byteArray) + } + } +} + +/// A JSON string literal. Only what JSON requires is escaped, which is what +/// JavaScript's JSON.stringify does for these ASCII values. +func jsonString(_ s: String) -> String { + var out = "\"" + for scalar in s.unicodeScalars { + switch scalar { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if scalar.value < 0x20 { + out += String(format: "\\u%04x", scalar.value) + } else { + out.unicodeScalars.append(scalar) + } + } + } + return out + "\"" +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift new file mode 100644 index 0000000..4a90b5e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Where FTW lives, decided once. The QR host, the passkey's relying party +/// and the web app's origin are the same string by construction. +public enum Origin { + /// The one origin, and the host in every pairing QR. + public static let appHost = "app.ftw.energy" + + /// The passkey relying party. The app subdomain, never the registrable + /// domain: PRF output is bound to the RP ID, so its scope decides who may + /// derive the keys. Changing it strands every passkey. + public static let rpID = appHost + public static let rpName = "FTW" + + /// The relay. One value, no setting. + public static let relayURL = URL(string: "wss://relay.ftw.energy")! + + /// The sealed recovery copy. Its own host, neither the relay's nor the app's. + public static let escrowURL = URL(string: "https://escrow.ftw.energy")! + + /// Source and licence, as the AGPL asks a network client to offer them. + public static let sourceURL = URL(string: "https://github.com/srcfl/ftw-app")! + public static let licenseURL = URL(string: "https://github.com/srcfl/ftw-app/blob/main/LICENSE")! +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift new file mode 100644 index 0000000..49cb62b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift @@ -0,0 +1,106 @@ +import Foundation + +/// The passkey as a key derivation function. +/// +/// There is no account and nothing verifies an assertion. The credential +/// exists because the PRF extension hands back a secret only after the user +/// verifies, and that secret is turned into keys here, the same way the web +/// app turns it into keys. Same RP ID, same salt, same HKDF: a passkey that +/// sealed a recovery copy in the web app opens it in this app, and the +/// reverse. +public enum PRF { + /// One fixed salt per purpose, forever. The authenticator is only ever + /// asked for the vault salt; the escrow salt is an HKDF salt. + public static let vaultSalt = Array("ftw.prf.v1.vault".utf8) + public static let escrowSalt = Array("ftw.prf.v1.escrow".utf8) + + static let wrapInfo = Array("ftw.wrap.v1".utf8) + static let escrowIDInfo = Array("ftw.escrow.id.v1".utf8) + static let escrowKeyInfo = Array("ftw.escrow.key.v1".utf8) + static let escrowWriteInfo = Array("ftw.escrow.write.v1".utf8) + + /// The AES-256-GCM key that wraps the device key, from one PRF output. + public static func wrappingKey(credentialID: String, prfOutput: Bytes) -> WrappingKey { + WrappingKey( + credentialID: credentialID, + source: .prf, + key: Primitives.hkdf(ikm: prfOutput, salt: vaultSalt, info: wrapInfo, length: 32), + escrow: EscrowKeys(prfOutput: prfOutput) + ) + } +} + +/// Where a wrapping key came from. The UI states this; it never hides it. +public enum WrappingSource: String, Codable, Sendable { + case prf + case local +} + +public struct WrappingKey: Sendable { + /// base64url credential id, or `Vault.localCredentialID`. + public let credentialID: String + public let source: WrappingSource + /// 32 bytes of AES-256-GCM key. + let key: Bytes + /// Present only where PRF is. The local key derives nothing that may + /// leave this device. + public let escrow: EscrowKeys? +} + +/// What the escrow needs, as HKDF siblings of the vault key: the id says +/// nothing about the key, and neither can be produced from the other. +public struct EscrowKeys: Sendable { + /// base64url of 32 bytes. The only name the service ever learns. + public let lookupID: String + let sealKey: Bytes + /// Ed25519 public key the service pins on first write. + public let writeKey: Bytes + private let writeSeed: Bytes + + init(prfOutput: Bytes) { + let salt = PRF.escrowSalt + lookupID = Base64url.encode(Primitives.hkdf(ikm: prfOutput, salt: salt, info: PRF.escrowIDInfo, length: 32)) + sealKey = Primitives.hkdf(ikm: prfOutput, salt: salt, info: PRF.escrowKeyInfo, length: 32) + writeSeed = Primitives.hkdf(ikm: prfOutput, salt: salt, info: PRF.escrowWriteInfo, length: 32) + // A 32-byte seed is always a valid Ed25519 key. + writeKey = (try? Primitives.ed25519PublicKey(seed: writeSeed)) ?? [] + } + + func sign(_ message: Bytes) throws -> Bytes { + try Primitives.ed25519Sign(seed: writeSeed, message: message) + } +} + +/// The platform side of a passkey ceremony, injected so this package never +/// touches AuthenticationServices. +@MainActor +public protocol PasskeyAuthenticator: AnyObject { + /// Register a new passkey and ask for PRF. `prfOutput` is nil when the + /// platform registered the passkey but gave no PRF. + func register(label: String, userHandle: Bytes, excludeCredentialIDs: [String]) async throws -> PasskeyOutcome + /// Assert with PRF. An empty list means any discoverable credential for + /// the RP, which is what a fresh install recovering its home needs. + func assert(credentialIDs: [String]) async throws -> PasskeyOutcome + /// Whether this device can run a ceremony at all. + var isAvailable: Bool { get } +} + +public struct PasskeyOutcome: Sendable { + public let credentialID: String + public let prfOutput: Bytes? + /// At registration: the platform says PRF is supported even though it + /// gave no output yet, so an assertion will produce one. + public let prfEnabled: Bool + + public init(credentialID: String, prfOutput: Bytes?, prfEnabled: Bool = false) { + self.credentialID = credentialID + self.prfOutput = prfOutput + self.prfEnabled = prfEnabled + } +} + +/// The person dismissed the sheet. A decline is an answer, not a fault, and +/// the two need different sentences. +public struct PasskeyCancelled: Error, Equatable { + public init() {} +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift new file mode 100644 index 0000000..3f3f358 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift @@ -0,0 +1,91 @@ +import Foundation + +/// From a scanned code to a paired home, in the order that keeps it to two +/// taps and fails before the passkey prompt whenever it can: +/// +/// 1. Parse the QR. A bad code fails before anyone is asked for Face ID. +/// 2. Ask for the passkey, once. +/// 3. Unwrap or create the device key, and make the local copy. +/// 4. Store the home, pinned to the box key from the QR. +/// +/// Only then does anything touch the network, and the spare copy is written +/// in the background without delaying the house. +@MainActor +public final class Pairing { + private let vault: Vault + private let sites: SiteList + private let escrow: Escrow + private let passkeys: PasskeyAuthenticator? + private let now: () -> Double + + public init(vault: Vault, sites: SiteList, escrow: Escrow, passkeys: PasskeyAuthenticator?, now: @escaping () -> Double = { Date().timeIntervalSince1970 * 1000 }) { + self.vault = vault + self.sites = sites + self.escrow = escrow + self.passkeys = passkeys + self.now = now + } + + public struct Paired: Sendable { + public let site: StoredSite + /// The background seal of the spare copy, for tests to await. + public let sealed: Task + } + + /// Throws `EnrollmentError` with a sentence, or `PasskeyCancelled`. + public func pair(scanned: String, holdCopy: Bool = true) async throws -> Paired { + let enrollment = try Enrollment.parse(scanned: scanned) + + let wrapping = vault.isEnrolled + ? try await vault.unlockWrappingKey(passkeys) + : try await vault.enrollWrappingKey(passkeys) + _ = try vault.deviceKey(wrapping) + // The last prompt reading ever costs. PRF keeps guarding enrollment + // and privileged writes; looking at your own house is not one. + try vault.ensureLocalCopy(wrapping) + + let at = now() + let siteId = SiteList.siteID(enrollment.boxStaticPublic) + let site = StoredSite( + siteId: siteId, + label: sites.get(siteId)?.label ?? "Home", + boxStaticKey: Data(enrollment.boxStaticPublic), + rendezvousSecret: Data(enrollment.rendezvousSecret), + pairingCode: Data(enrollment.pairingCode), + lanHint: enrollment.lanHint.isEmpty ? nil : enrollment.lanHint, + escrow: sites.get(siteId)?.escrow ?? false, + addedAtMs: at, + lastSeenAtMs: at + ) + sites.put(site) + + // Hold a sealed copy from the first device, by default and in the + // background. The mark follows the write, so the Box screen never + // says a copy is held when it is not. + let escrow = self.escrow + let sealed = Task { @MainActor () -> Escrow.SaveOutcome? in + guard holdCopy, wrapping.escrow != nil else { return nil } + let outcome = await escrow.save(wrapping, pending: RecoveryBlob.Home( + siteId: site.siteId, + label: site.label, + boxStaticKey: site.boxStaticKey.byteArray, + rendezvousSecret: site.rendezvousSecret?.byteArray ?? [] + )) + escrow.mark(site.siteId, outcome == .saved) + return outcome + } + return Paired(site: site, sealed: sealed) + } + + /// Arm the next handshake with a code read off the box's screen. For a + /// phone that has been here before: a box code carries no box key and no + /// rendezvous secret, so a phone with no row for this home scans instead. + /// The decode happens before the disk is touched, so a typo costs nothing. + public func redeemBoxCode(siteId: String, typed: String) throws { + let code = try BoxCode.decode(typed) + guard sites.get(siteId) != nil else { + throw BoxCode.BoxCodeError(message: "no site \(siteId)", help: "This phone has no record of that home. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR instead.") + } + sites.update(siteId) { $0.pairingCode = Data(code) } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift new file mode 100644 index 0000000..320e458 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift @@ -0,0 +1,143 @@ +import Foundation + +/// The sealed copy of a household: the bytes, and nothing about where they +/// are kept. The same format the web app writes, so a copy either one +/// sealed opens in the other. +/// +/// sealed: [version 1][nonce 12][ciphertext + tag] +/// plain: [device scalar 32][home count 1] +/// per home: [id len 1][id][name len 1][name][box key 32][rendezvous secret 32] +/// then zeros out to the fixed plaintext length +/// AAD: [version 1][escrow version 4, big-endian] +/// +/// Padded to one length so whoever holds the ciphertext cannot count homes. +/// The escrow version is bound in as additional data, so a service that pairs +/// an old blob with a new number gets a refusal here rather than a household +/// getting back a home it had removed. +public enum RecoveryBlob { + public static let maxBytes = 512 + public static let version: UInt8 = 2 + public static let escrowVersionNone: UInt32 = 0 + static let nonceBytes = 12 + static let headerBytes = 1 + nonceBytes + static let tagBytes = 16 + public static let plainBytes = maxBytes - headerBytes - tagBytes + /// The name is for telling two homes apart, not a record. + public static let maxNameChars = 32 + + public struct Home: Equatable, Sendable { + public var siteId: String + public var label: String + public var boxStaticKey: Bytes + public var rendezvousSecret: Bytes + + public init(siteId: String, label: String, boxStaticKey: Bytes, rendezvousSecret: Bytes) { + self.siteId = siteId + self.label = label + self.boxStaticKey = boxStaticKey + self.rendezvousSecret = rendezvousSecret + } + } + + public struct Contents: Equatable, Sendable { + public var deviceScalar: Bytes + public var homes: [Home] + } + + public struct BlobError: Error, Equatable, HelpfulError { + public let code: String + public let message: String + public let help: String + } + + static func format(_ message: String) -> BlobError { + BlobError(code: "E_BLOB_FORMAT", message: message, help: "The saved copy of this home could not be read. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR instead.") + } + + public static func encode(_ contents: Contents) throws -> Bytes { + guard contents.deviceScalar.count == 32 else { throw format("device key is not 32 bytes") } + guard contents.homes.count <= 0xff else { throw format("more homes than a byte counts") } + var out = Bytes() + out.reserveCapacity(plainBytes) + out += contents.deviceScalar + out.append(UInt8(contents.homes.count)) + for home in contents.homes { + guard home.boxStaticKey.count == Enrollment.boxKeyBytes else { throw format("box key is not 32 bytes") } + guard home.rendezvousSecret.count == Enrollment.rendezvousSecretBytes else { throw format("rendezvous secret is not 32 bytes") } + let id = Array(home.siteId.utf8) + // Whole characters: a cut between the halves of an emoji would + // keep one of them. + let name = Array(String(String.UnicodeScalarView(home.label.unicodeScalars.prefix(maxNameChars))).utf8) + guard id.count <= 0xff, name.count <= 0xff else { throw format("a name is longer than a byte") } + out.append(UInt8(id.count)) + out += id + out.append(UInt8(name.count)) + out += name + out += home.boxStaticKey + out += home.rendezvousSecret + } + guard out.count <= plainBytes else { + throw BlobError(code: "E_BLOB_TOO_BIG", message: "payload is \(out.count) bytes", help: "There are more homes on this phone than a saved copy can carry. They all still work here.") + } + out += Bytes(repeating: 0, count: plainBytes - out.count) + return out + } + + /// Strict: anything short, long or unaccounted for is refused rather than + /// read half-way. + public static func decode(_ plain: Bytes) throws -> Contents { + guard plain.count == plainBytes else { throw format("payload is \(plain.count) bytes") } + let scalar = Array(plain[0..<32]) + var at = 32 + let count = Int(plain[at]) + at += 1 + var homes = [Home]() + + func slice() throws -> Bytes { + guard at < plain.count else { throw format("payload ends in the middle of a home") } + let n = Int(plain[at]) + at += 1 + guard at + n <= plain.count else { throw format("payload ends in the middle of a name") } + defer { at += n } + return Array(plain[at.. Bytes { + [version, UInt8(escrowVersion >> 24), UInt8(escrowVersion >> 16 & 0xff), UInt8(escrowVersion >> 8 & 0xff), UInt8(escrowVersion & 0xff)] + } + + public static func seal(key: Bytes, _ contents: Contents, escrowVersion: UInt32) throws -> Bytes { + var plain = try encode(contents) + defer { wipe(&plain) } + let nonce = randomBytes(nonceBytes) + let ct = try Primitives.aesGCMSeal(key: key, nonce: nonce, ad: aad(escrowVersion), plaintext: plain) + return [version] + nonce + ct + } + + public static func open(key: Bytes, _ sealed: Bytes, escrowVersion: UInt32) throws -> Contents { + guard sealed.count > headerBytes else { throw format("blob is too short to be sealed") } + guard sealed[0] == version else { throw format("blob version \(sealed[0])") } + var plain: Bytes + do { + plain = try Primitives.aesGCMOpen(key: key, nonce: Array(sealed[1.. Primitives.KeyPair { + guard let record else { return try create(wrapping) } + guard let copy = record.copies.first(where: { $0.credentialId == wrapping.credentialID }) else { + throw noCopy(wrapping.credentialID) + } + var pkcs8 = try unseal(wrapping.key, copy) + defer { wipe(&pkcs8) } + return try Primitives.keyPair(fromSecret: Array(pkcs8.suffix(32))) + } + + /// The raw scalar, for the one caller allowed to copy it off this device: + /// the sealed recovery copy. The caller wipes what it gets. + public func exportDeviceSecret(_ wrapping: WrappingKey) throws -> Bytes { + guard let record else { throw emptyVault() } + guard let copy = record.copies.first(where: { $0.credentialId == wrapping.credentialID }) else { + throw noCopy(wrapping.credentialID) + } + var pkcs8 = try unseal(wrapping.key, copy) + defer { wipe(&pkcs8) } + return Array(pkcs8.suffix(32)) + } + + /// Put back a device key from a sealed copy: the Noise static the box + /// already trusts, which is why no pairing code is needed. Refuses to sit + /// on top of a different identity. + @discardableResult + public func restoreDeviceKey(_ wrapping: WrappingKey, scalar: Bytes) throws -> Primitives.KeyPair { + let pair = try Primitives.keyPair(fromSecret: scalar) + let existing = record + if let existing, existing.publicKey.byteArray != pair.publicKey { + throw VaultError(code: "E_VAULT_OTHER_KEY", message: "a different device key is already stored", help: "This phone is already set up for a home. Sign out of it first.") + } + var pkcs8 = Self.pkcs8Prefix + scalar + defer { wipe(&pkcs8) } + var copies = (existing?.copies ?? []).filter { $0.credentialId != wrapping.credentialID } + copies.append(try seal(wrapping, pkcs8)) + write(Record(publicKey: Data(pair.publicKey), copies: copies)) + return pair + } + + /// Wrap the device key under another credential. `current` must already + /// open it: prove you can unlock before you widen access. + public func addCredential(current: WrappingKey, next: WrappingKey) throws { + guard let record else { throw emptyVault() } + guard let copy = record.copies.first(where: { $0.credentialId == current.credentialID }) else { + throw noCopy(current.credentialID) + } + var pkcs8 = try unseal(current.key, copy) + defer { wipe(&pkcs8) } + var copies = record.copies.filter { $0.credentialId != next.credentialID } + copies.append(try seal(next, pkcs8)) + write(Record(publicKey: record.publicKey, copies: copies)) + } + + /// Refuses the last copy, which would strand the device key for good. + public func removeCredential(_ credentialID: String) throws { + guard let record else { throw emptyVault() } + let copies = record.copies.filter { $0.credentialId != credentialID } + if copies.count == record.copies.count { return } + if copies.isEmpty { + throw VaultError(code: "E_VAULT_LAST_COPY", message: "refusing to remove the only wrapped copy", help: "That is the only passkey that can unlock this device. Add another one first.") + } + write(Record(publicKey: record.publicKey, copies: copies)) + } + + /// Forget this device's identity. Callers leaving a home clear the sites + /// and the cached readings as well; see `AppModel.leave`. + public func reset() { + store.remove(Self.vaultKey) + store.remove(Self.localWrapKey) + store.remove(Self.userHandleKey) + } + + // MARK: Wrapping keys + + /// The local key: random, in the Keychain, no ceremony in front of it. + public func localWrappingKey() -> WrappingKey { + if let existing = store.get(Self.localWrapKey), existing.count == 32 { + return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: existing, escrow: nil) + } + let key = randomBytes(32) + store.put(Self.localWrapKey, key) + return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: key, escrow: nil) + } + + /// The key for connecting, without a prompt, or nil when only passkey + /// copies exist. Reading your own house is not a privilege. + public func silentWrappingKey() throws -> WrappingKey? { + guard let record else { throw emptyVault() } + return record.copies.contains { $0.credentialId == Self.localCredentialID } ? localWrappingKey() : nil + } + + /// Add the local copy, so the next start never prompts. + public func ensureLocalCopy(_ current: WrappingKey) throws { + guard let record else { throw emptyVault() } + if record.copies.contains(where: { $0.credentialId == Self.localCredentialID }) { return } + try addCredential(current: current, next: localWrappingKey()) + } + + /// A key for a device enrolling now: one prompt, no questions. Where PRF + /// is missing this still returns a key, the local one, and `source` says + /// so. A decline is rethrown; any other platform failure falls back. + public func enrollWrappingKey(_ passkeys: PasskeyAuthenticator?, label: String = Origin.rpName) async throws -> WrappingKey { + guard let passkeys, passkeys.isAvailable else { return localWrappingKey() } + do { + let outcome = try await passkeys.register(label: label, userHandle: userHandle(), excludeCredentialIDs: credentialIDs) + if let prf = outcome.prfOutput { + return PRF.wrappingKey(credentialID: outcome.credentialID, prfOutput: prf) + } + // Some platforms register the passkey and only evaluate PRF on an + // assertion. One more prompt now, while the person is looking, + // beats a vault nothing can open later. + guard outcome.prfEnabled else { return localWrappingKey() } + let asserted = try await passkeys.assert(credentialIDs: [outcome.credentialID]) + if let prf = asserted.prfOutput { + return PRF.wrappingKey(credentialID: asserted.credentialID, prfOutput: prf) + } + return localWrappingKey() + } catch is PasskeyCancelled { + throw PasskeyCancelled() + } catch { + return localWrappingKey() + } + } + + /// The key for a device already enrolled: one prompt, or the local copy + /// when no passkey can answer. + public func unlockWrappingKey(_ passkeys: PasskeyAuthenticator?) async throws -> WrappingKey { + guard let record else { throw emptyVault() } + let ids = passkeyCredentialIDs + if !ids.isEmpty, let passkeys, passkeys.isAvailable { + do { + let outcome = try await passkeys.assert(credentialIDs: ids) + if let prf = outcome.prfOutput { + return PRF.wrappingKey(credentialID: outcome.credentialID, prfOutput: prf) + } + } catch is PasskeyCancelled { + // A decline stays a decline. "Set this device up again" would + // tell someone who tapped cancel to throw away a working phone. + throw PasskeyCancelled() + } catch {} + } + if record.copies.contains(where: { $0.credentialId == Self.localCredentialID }) { + return localWrappingKey() + } + throw locked() + } + + // MARK: Private + + private var record: Record? { + store.getJSON(Record.self, Self.vaultKey) + } + + private func write(_ record: Record) { + store.putJSON(Self.vaultKey, record) + } + + func userHandle() -> Bytes { + if let existing = store.get(Self.userHandleKey) { return existing } + // Stable per install, so a second passkey replaces the first in the + // platform's list instead of stacking up identical entries. + let handle = randomBytes(16) + store.put(Self.userHandleKey, handle) + return handle + } + + private func create(_ wrapping: WrappingKey) throws -> Primitives.KeyPair { + let pair = Primitives.generateKeyPair() + var pkcs8 = Self.pkcs8Prefix + pair.secretKey + defer { wipe(&pkcs8) } + write(Record(publicKey: Data(pair.publicKey), copies: [try seal(wrapping, pkcs8)])) + return pair + } + + private func seal(_ wrapping: WrappingKey, _ plain: Bytes) throws -> Copy { + let iv = randomBytes(12) + let ct = try Primitives.aesGCMSeal(key: wrapping.key, nonce: iv, plaintext: plain) + return Copy(credentialId: wrapping.credentialID, source: wrapping.source, iv: Data(iv), ct: Data(ct)) + } + + private func unseal(_ key: Bytes, _ copy: Copy) throws -> Bytes { + do { + return try Primitives.aesGCMOpen(key: key, nonce: copy.iv.byteArray, ciphertext: copy.ct.byteArray) + } catch { + throw locked() + } + } + + private func locked() -> VaultError { + VaultError(code: "E_VAULT_LOCKED", message: "no wrapping key opens the stored copy", help: "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR.") + } + + private func noCopy(_ credentialID: String) -> VaultError { + VaultError(code: "E_VAULT_NO_COPY", message: "no wrapped copy for credential \(credentialID)", help: "This passkey has not been given access yet. Unlock with the one you set up first.") + } + + private func emptyVault() -> VaultError { + VaultError(code: "E_VAULT_EMPTY", message: "no device key has been created", help: "Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR.") + } +} + +/// Proving, right now, that the person holding the phone is its owner. +/// +/// The box cannot verify a ceremony happened; `stepUp: true` is this app's +/// word. What it stops is a phone left unlocked on a table being used to +/// reconfigure a house, because this app will not say `true` without the +/// ceremony below running. It never falls back to the local key. +public enum StepUpOutcome: Equatable, Sendable { + case done + case declined + case unavailable + + public var help: String? { + switch self { + case .done: return nil + case .declined: return "That needs your face or fingerprint. Try again when you are ready." + case .unavailable: return "This phone cannot confirm it is yours. Changes have to be made on your box." + } + } +} + +extension Vault { + public func stepUp(_ passkeys: PasskeyAuthenticator?) async -> StepUpOutcome { + guard let passkeys, passkeys.isAvailable else { return .unavailable } + let ids = passkeyCredentialIDs + if ids.isEmpty { return .unavailable } + do { + // The ceremony is what is asked for, not its PRF output: reaching + // the next line without throwing is what says it ran. + _ = try await passkeys.assert(credentialIDs: ids) + return .done + } catch is PasskeyCancelled { + return .declined + } catch { + return .unavailable + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/CBOR.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/CBOR.swift new file mode 100644 index 0000000..1580879 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/CBOR.swift @@ -0,0 +1,416 @@ +import Foundation + +/// A CBOR value, as much of RFC 8949 as the box and the web app use. +/// +/// Maps keep their order. The web app's encoder writes keys in insertion +/// order and the box accepts any order, so keeping order is what lets this +/// encoder produce the same bytes as the web app for the same message, and +/// the shared interop vectors prove it. +public indirect enum CBOR: Equatable, Sendable { + case unsigned(UInt64) + /// The value is `-1 - n`. + case negative(UInt64) + case bytes(Bytes) + case text(String) + case array([CBOR]) + case map([Entry]) + case tagged(UInt64, CBOR) + case bool(Bool) + case null + case undefined + case double(Double) + + public struct Entry: Equatable, Sendable { + public var key: CBOR + public var value: CBOR + public init(_ key: CBOR, _ value: CBOR) { + self.key = key + self.value = value + } + } + + public struct DecodeError: Error, Equatable { + public let message: String + } +} + +// MARK: - Building + +extension CBOR { + public static func int(_ v: Int64) -> CBOR { + v >= 0 ? .unsigned(UInt64(v)) : .negative(UInt64(-1 - v)) + } + + public static func int(_ v: Int) -> CBOR { .int(Int64(v)) } + + public static let emptyMap = CBOR.map([Entry]()) + + /// A time in whole milliseconds. The box decodes every time into an + /// int64 and refuses a fraction, where JavaScript's clock never has one. + public static func ms(_ v: Double) -> CBOR { .int(Int64(v.rounded())) } + + /// A number as JavaScript would put it on the wire: an integer when it is + /// one, a double otherwise. The web app sends `hz: 1` and `hz: 0.2`, and + /// the box reads either into a float. + public static func number(_ v: Double) -> CBOR { + if v.rounded() == v, abs(v) < 9_007_199_254_740_992 { return .int(Int64(v)) } + return .double(v) + } + + /// A text-keyed map, in the order given. + public static func map(_ pairs: KeyValuePairs) -> CBOR { + .map(pairs.map { Entry(.text($0.key), $0.value) }) + } + + public static func map(_ pairs: [(String, CBOR)]) -> CBOR { + .map(pairs.map { Entry(.text($0.0), $0.1) }) + } +} + +// MARK: - Reading + +extension CBOR { + /// The value under a text key, or nil. Unknown keys are simply never + /// asked for, which is the protocol's forward-compatibility rule. + public subscript(key: String) -> CBOR? { + guard case .map(let entries) = self else { return nil } + for e in entries where e.key == .text(key) { return e.value } + return nil + } + + public var int64: Int64? { + switch self { + case .unsigned(let u): return u <= UInt64(Int64.max) ? Int64(u) : nil + case .negative(let n): return n <= UInt64(Int64.max) ? -1 - Int64(n) : nil + case .double(let d): return d.rounded() == d && abs(d) < 9.2e18 ? Int64(d) : nil + case .tagged(_, let inner): return inner.int64 + default: return nil + } + } + + public var int: Int? { int64.flatMap { Int(exactly: $0) } } + + public var double: Double? { + switch self { + case .unsigned(let u): return Double(u) + case .negative(let n): return -1 - Double(n) + case .double(let d): return d + case .tagged(_, let inner): return inner.double + default: return nil + } + } + + public var string: String? { + if case .text(let s) = self { return s } + return nil + } + + public var byteString: Bytes? { + if case .bytes(let b) = self { return b } + return nil + } + + public var bool: Bool? { + if case .bool(let b) = self { return b } + return nil + } + + public var array: [CBOR]? { + if case .array(let a) = self { return a } + return nil + } + + public var entries: [Entry]? { + if case .map(let m) = self { return m } + return nil + } + + public var isNull: Bool { + self == .null || self == .undefined + } + + /// A text-keyed map as a dictionary. Non-text keys are dropped; a + /// duplicate keeps the first, though the box never sends one. + public var textMap: [String: CBOR]? { + guard case .map(let entries) = self else { return nil } + var out = [String: CBOR]() + for e in entries { + if case .text(let k) = e.key, out[k] == nil { out[k] = e.value } + } + return out + } + + public var stringArray: [String]? { + array?.compactMap(\.string) + } +} + +// MARK: - Encoding + +public func encodeCBOR(_ value: CBOR) -> Bytes { + var out = Bytes() + encode(value, into: &out) + return out +} + +private func head(_ major: UInt8, _ n: UInt64, into out: inout Bytes) { + let m = major << 5 + switch n { + case 0..<24: + out.append(m | UInt8(n)) + case 24...0xff: + out.append(m | 24) + out.append(UInt8(n)) + case 0x100...0xffff: + out.append(m | 25) + out.append(UInt8(n >> 8)) + out.append(UInt8(n & 0xff)) + case 0x1_0000...0xffff_ffff: + out.append(m | 26) + for shift in stride(from: 24, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: n >> UInt64(shift))) } + default: + out.append(m | 27) + for shift in stride(from: 56, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: n >> UInt64(shift))) } + } +} + +private func encode(_ value: CBOR, into out: inout Bytes) { + switch value { + case .unsigned(let u): head(0, u, into: &out) + case .negative(let n): head(1, n, into: &out) + case .bytes(let b): + head(2, UInt64(b.count), into: &out) + out.append(contentsOf: b) + case .text(let s): + let utf8 = Array(s.utf8) + head(3, UInt64(utf8.count), into: &out) + out.append(contentsOf: utf8) + case .array(let items): + head(4, UInt64(items.count), into: &out) + for i in items { encode(i, into: &out) } + case .map(let entries): + head(5, UInt64(entries.count), into: &out) + for e in entries { + encode(e.key, into: &out) + encode(e.value, into: &out) + } + case .tagged(let tag, let inner): + head(6, tag, into: &out) + encode(inner, into: &out) + case .bool(let b): out.append(b ? 0xf5 : 0xf4) + case .null: out.append(0xf6) + case .undefined: out.append(0xf7) + case .double(let d): + // Shortest form that keeps the value exactly, as preferred + // serialisation asks. Lengths never matter to lane 0 — it is padded — + // but agreeing with the other encoders byte for byte is what makes a + // vector test worth having. + let f = Float(d) + if Double(f) == d || d.isNaN { + let half = Float16Bits(f) + if let h = half { + out.append(0xf9) + out.append(UInt8(h >> 8)) + out.append(UInt8(h & 0xff)) + } else { + out.append(0xfa) + let bits = f.bitPattern + for shift in stride(from: 24, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: bits >> UInt32(shift))) } + } + } else { + out.append(0xfb) + let bits = d.bitPattern + for shift in stride(from: 56, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: bits >> UInt64(shift))) } + } + } +} + +/// The IEEE half-precision bits for a float that half precision holds +/// exactly, or nil when it would lose anything. +private func Float16Bits(_ f: Float) -> UInt16? { + if f.isNaN { return 0x7e00 } + let bits = f.bitPattern + let sign = UInt16((bits >> 16) & 0x8000) + let exp = Int((bits >> 23) & 0xff) + let mant = bits & 0x7f_ffff + if exp == 0xff { return sign | 0x7c00 } // infinity + if exp == 0 && mant == 0 { return sign } + let e = exp - 127 + if e >= -14 && e <= 15 { + // Normal half: 10 mantissa bits. + if mant & 0x1fff != 0 { return nil } + return sign | UInt16(e + 15) << 10 | UInt16(mant >> 13) + } + if e >= -24 && e < -14 { + // Subnormal half. + let full = mant | 0x80_0000 + let shift = UInt32(-e - 14 + 13) + if full & ((1 << shift) - 1) != 0 { return nil } + return sign | UInt16(full >> shift) + } + return nil +} + +// MARK: - Decoding + +/// Decode exactly one value. Trailing bytes are an error: a frame's payload +/// length is explicit, so anything left over means the length was wrong. +public func decodeCBOR(_ bytes: Bytes) throws -> CBOR { + var reader = CBORReader(bytes: bytes) + let value = try reader.read(depth: 0) + if reader.at != bytes.count { + throw CBOR.DecodeError(message: "\(bytes.count - reader.at) trailing bytes") + } + return value +} + +private struct CBORReader { + let bytes: Bytes + var at = 0 + + /// Deep enough for every message in the protocol, shallow enough that a + /// hostile frame cannot exhaust the stack. + static let maxDepth = 64 + + mutating func byte() throws -> UInt8 { + guard at < bytes.count else { throw CBOR.DecodeError(message: "unexpected end") } + defer { at += 1 } + return bytes[at] + } + + mutating func take(_ n: Int) throws -> Bytes { + guard n >= 0, at + n <= bytes.count else { throw CBOR.DecodeError(message: "unexpected end") } + defer { at += n } + return Array(bytes[at.. UInt64 { + switch info { + case 0..<24: return UInt64(info) + case 24: return UInt64(try byte()) + case 25: return try take(2).reduce(0) { $0 << 8 | UInt64($1) } + case 26: return try take(4).reduce(0) { $0 << 8 | UInt64($1) } + case 27: return try take(8).reduce(0) { $0 << 8 | UInt64($1) } + default: throw CBOR.DecodeError(message: "reserved additional info \(info)") + } + } + + func count(_ n: UInt64) throws -> Int { + guard n <= UInt64(bytes.count - at) else { throw CBOR.DecodeError(message: "length \(n) overruns the input") } + return Int(n) + } + + mutating func read(depth: Int) throws -> CBOR { + guard depth < Self.maxDepth else { throw CBOR.DecodeError(message: "nested too deeply") } + let initial = try byte() + let major = initial >> 5 + let info = initial & 0x1f + + if info == 31 { + return try readIndefinite(major: major, depth: depth) + } + + switch major { + case 0: return .unsigned(try argument(info)) + case 1: return .negative(try argument(info)) + case 2: return .bytes(try take(try count(try argument(info)))) + case 3: + let raw = try take(try count(try argument(info))) + guard let s = String(bytes: raw, encoding: .utf8) else { throw CBOR.DecodeError(message: "text is not UTF-8") } + return .text(s) + case 4: + let n = try argument(info) + guard n <= UInt64(bytes.count - at) else { throw CBOR.DecodeError(message: "array overruns the input") } + var items = [CBOR]() + items.reserveCapacity(Int(n)) + for _ in 0.. CBOR { + switch major { + case 2, 3: + var chunks = Bytes() + while true { + if at < bytes.count, bytes[at] == 0xff { at += 1; break } + let chunk = try read(depth: depth + 1) + switch (major, chunk) { + case (2, .bytes(let b)): chunks.append(contentsOf: b) + case (3, .text(let s)): chunks.append(contentsOf: Array(s.utf8)) + default: throw CBOR.DecodeError(message: "mixed chunk in an indefinite string") + } + } + if major == 2 { return .bytes(chunks) } + guard let s = String(bytes: chunks, encoding: .utf8) else { throw CBOR.DecodeError(message: "text is not UTF-8") } + return .text(s) + case 4: + var items = [CBOR]() + while true { + if at < bytes.count, bytes[at] == 0xff { at += 1; break } + items.append(try read(depth: depth + 1)) + } + return .array(items) + case 5: + var entries = [CBOR.Entry]() + while true { + if at < bytes.count, bytes[at] == 0xff { at += 1; break } + let k = try read(depth: depth + 1) + let v = try read(depth: depth + 1) + if entries.contains(where: { $0.key == k }) { throw CBOR.DecodeError(message: "duplicate map key") } + entries.append(CBOR.Entry(k, v)) + } + return .map(entries) + default: + throw CBOR.DecodeError(message: "indefinite length on major type \(major)") + } + } +} + +private func halfToFloat(_ h: UInt16) -> Float { + let sign: Float = h & 0x8000 != 0 ? -1 : 1 + let exp = Int(h >> 10 & 0x1f) + let mant = Float(h & 0x3ff) + if exp == 0 { return sign * mant * pow(2, -24) } + if exp == 31 { return mant == 0 ? sign * .infinity : .nan } + return sign * (1 + mant / 1024) * pow(2, Float(exp - 15)) +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift new file mode 100644 index 0000000..86d3b1d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Names from protocol/registry.yaml, in Swift. +/// +/// The registry is one file shared byte for byte with srcfl/ftw and +/// srcfl/ftw-webapp. There is no generator here, so this is written by hand +/// and `ContractTests` reads the registry back and fails when anything has +/// stopped matching. Add nothing here that is not in the registry. +public enum Contract { + /// One object axis, two verb axes: `.`. + public static let scopes = [ + "ftw.live.read", + "ftw.history.read", + "ftw.plan.read", + "ftw.health.read", + "ftw.assets.read", + "ftw.dispatch.write", + "ftw.mode.write", + "ftw.members.read", + "ftw.members.write", + ] + + public static let roleOwner = "owner" + public static let roleViewer = "viewer" + + /// What each role carries, with the registry's `*` expanded. Used only + /// for a box from before roles, which sends no scope list. + public static let roleScopes: [String: [String]] = [ + roleOwner: scopes, + roleViewer: ["ftw.live.read"], + ] + + public static let roleLabels: [String: String] = [ + roleOwner: "Owner", + roleViewer: "Viewer", + ] + + public static let scopeModeWrite = "ftw.mode.write" + + public static let capabilities = [ + "status.core", "status.phases", "status.drivers", + "history.5m", "history.1h", "history.etag", + "cmd.lease", "cmd.precondition", "cmd.readback", + "der.battery", "der.ev", "der.v2x", + "plan.dispatch", "net.webrtc", "price.spot", "api.passthrough", + ] + + public static let capPlanDispatch = "plan.dispatch" + public static let capDerEV = "der.ev" + public static let capPriceSpot = "price.spot" + public static let capAPIPassthrough = "api.passthrough" + + public static let opSetMode = "site.mode.set" + public static let opBatteryHold = "battery.hold" + public static let opLoadpointHold = "loadpoint.hold" + public static let opLoadpointBoost = "loadpoint.boost" + public static let opLoadpointSocSet = "loadpoint.soc.set" + public static let opLoadpointSurplusOnlySet = "loadpoint.surplus_only.set" + + public static let ops = [opSetMode, opBatteryHold, opLoadpointHold, opLoadpointBoost, opLoadpointSocSet, opLoadpointSurplusOnlySet] + + /// Field ids frozen permanently as of v1. + public enum FID { + public static let mode = 1 + public static let gridW = 2 + public static let pvW = 3 + public static let batteryW = 4 + public static let batterySoc = 5 + public static let loadW = 6 + public static let srcGrid = 7 + public static let srcPV = 8 + public static let srcBattery = 9 + /// Present only on a site with a charger. + public static let evW = 10 + } + + /// Whether a code is worth retrying, from the registry's `errors` and + /// `client_errors`. Unknown codes are not: guessing yes offers a button + /// that cannot help. + static let retryable: [String: Bool] = [ + "E_BOOTING": true, + "E_UNKNOWN_OP": false, + "E_CMD_EXPIRED": false, + "E_PRECONDITION": false, + "E_CONFLICT": true, + "E_SCOPE_DENIED": false, + "E_GRANT_REVOKED": false, + "E_LAST_OWNER_PROTECTED": false, + "E_RANGE_TOO_LARGE": false, + "E_UNAVAILABLE": true, + "E_NEEDS_STEP_UP": true, + "E_USE_CMD": false, + "E_UNSUPPORTED_MEDIA": false, + "E_WHOLE_DOCUMENT": false, + "E_LOCAL_ONLY": false, + "E_RESPONSE_TOO_LARGE": false, + "E_NO_ACK": true, + "E_NO_ANSWER": true, + "E_BAD_BODY": false, + ] + + public static func isRetryable(_ code: String) -> Bool { + retryable[code] ?? false + } + + public static let sourceStates = ["live", "lagging", "stale", "down", "never"] + public static let carrierStates = ["webrtc", "relay", "cache", "none"] +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Frame.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Frame.swift new file mode 100644 index 0000000..50de668 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Frame.swift @@ -0,0 +1,139 @@ +import Foundation + +/// The frame codec. +/// +/// Each Noise transport message carries exactly one frame: +/// +/// offset field type note +/// 0 ver u8 frame layout version +/// 1 lane u8 0 = telemetry/control, 1 = bulk +/// 2 flags u8 0x02 TRUNC +/// 3 rsvd u8 0 +/// 4 len u16 BE payload bytes +/// 6 payload u8[len] CBOR +/// 6+len pad u8[] zeros to the bucket size +/// +/// The padding is a privacy control. A 1 Hz power stream whose frame length +/// varied with what happened in the house would hand the relay operator the +/// household's load pattern through perfect encryption, so lane 0 frames are +/// always exactly one bucket and never fragment. +public enum Frame { + public static let version: UInt8 = 1 + public static let headerBytes = 6 + public static let maxPayload = 0xffff + + public static let laneControl: UInt8 = 0 + public static let laneBulk: UInt8 = 1 + + /// The delta did not fit its bucket; the rest follows next tick. + public static let flagTrunc: UInt8 = 0x02 + + /// Lane 0 is one fixed size for the life of a session. + public static let controlBuckets = [256, 512] + /// Lane 1 steps, because bulk already leaks that a transfer is happening. + public static let bulkBuckets = [1024, 4096, 16384] + + public struct Decoded: Equatable, Sendable { + public var lane: UInt8 + public var flags: UInt8 + public var envelope: Envelope + + public var truncated: Bool { flags & Frame.flagTrunc != 0 } + } + + public struct FrameError: Error, Equatable { + public let code: String + public let message: String + } + + /// Encode one frame, zero-padded to `bucket`. + /// + /// Throws rather than growing the bucket: a larger frame than asked for + /// would defeat the padding entirely. + public static func encode(lane: UInt8, flags: UInt8 = 0, envelope: Envelope, bucket: Int) throws -> Bytes { + let payload = encodeCBOR(envelope.cbor) + if payload.count > maxPayload { + throw FrameError(code: "E_FRAME_TOO_LARGE", message: "payload \(payload.count) exceeds u16 length field") + } + let needed = headerBytes + payload.count + if needed > bucket { + throw FrameError(code: "E_FRAME_EXCEEDS_BUCKET", message: "frame needs \(needed) bytes, bucket is \(bucket)") + } + var out = Bytes(repeating: 0, count: bucket) + out[0] = version + out[1] = lane + out[2] = flags + out[3] = 0 + out[4] = UInt8(payload.count >> 8) + out[5] = UInt8(payload.count & 0xff) + out.replaceSubrange(headerBytes.. Bytes { + let payload = encodeCBOR(envelope.cbor) + guard let bucket = bulkBuckets.first(where: { $0 >= payload.count + headerBytes }) else { + throw FrameError(code: "E_FRAME_EXCEEDS_BUCKET", message: "bulk payload exceeds the largest bucket") + } + return try encode(lane: laneBulk, envelope: envelope, bucket: bucket) + } + + /// Decode one frame. Everything past `len` is ignored without inspection: + /// the padding is already covered by the AEAD, so checking it would only + /// add a way to reject a frame that is fine. + public static func decode(_ bytes: Bytes) throws -> Decoded { + guard bytes.count >= headerBytes else { + throw FrameError(code: "E_FRAME_SHORT", message: "frame is \(bytes.count) bytes") + } + guard bytes[0] == version else { + throw FrameError(code: "E_FRAME_VERSION", message: "unsupported frame version \(bytes[0])") + } + let len = Int(bytes[4]) << 8 | Int(bytes[5]) + guard headerBytes + len <= bytes.count else { + throw FrameError(code: "E_FRAME_TRUNCATED", message: "declared length \(len) overruns the frame") + } + let value: CBOR + do { + value = try decodeCBOR(Array(bytes[headerBytes..= 0, raw <= Int64(UInt32.max) { + id = UInt32(raw) + } + b = value["b"] + } + + /// Keys in the web app's order: t, id, b. + var cbor: CBOR { + var pairs: [(String, CBOR)] = [("t", .text(t))] + if let id { pairs.append(("id", .unsigned(UInt64(id)))) } + if let b { pairs.append(("b", b)) } + return .map(pairs) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift new file mode 100644 index 0000000..102a014 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift @@ -0,0 +1,184 @@ +import Foundation + +/// History geometry: tiles, resolutions and the column packing. The same +/// numbers the box uses, so a tile asked for and a tile built are one tile. +/// +/// Downsampling always happens on the box. When a window is too wide the box +/// clamps to a coarser store and reports `resActual`; when even that is too +/// wide it averages whole buckets and widens `stepMs`. +public enum HistoryGeometry { + public struct Spec: Sendable { + public let stepMs: Double + public let tileSpanMs: Double + public let retentionMs: Double + } + + static let dayMs: Double = 86_400_000 + + /// Mirrors `resolutions` in protocol/registry.yaml. + public static func spec(_ res: Resolution) -> Spec { + switch res { + case .fiveMinutes: return Spec(stepMs: 300_000, tileSpanMs: 43_200_000, retentionMs: 30 * dayMs) + case .hour: return Spec(stepMs: 3_600_000, tileSpanMs: 604_800_000, retentionMs: 730 * dayMs) + } + } + + /// Fine to coarse: the order the box clamps along. + public static let order: [Resolution] = [.fiveMinutes, .hour] + + public static func pointsPerTile(_ res: Resolution) -> Int { + let s = spec(res) + return Int(s.tileSpanMs / s.stepMs) + } + + /// Tiles are epoch-aligned, so two peers never disagree where one starts. + public static func tileStart(_ res: Resolution, _ atMs: Double) -> Double { + let span = spec(res).tileSpanMs + return (atMs / span).rounded(.down) * span + } + + public struct PlannedTile: Equatable, Sendable { + public let tileId: String + public let startMs: Double + public let points: Int + } + + public struct Plan: Equatable, Sendable { + public let res: Resolution + public let stride: Int + public let stepMs: Double + public let tiles: [PlannedTile] + } + + /// Resolution, stride and tile list for a window, deterministically, so + /// the client plans the same tiles the box will send. + public static func plan(_ res: Resolution, fromMs: Double, toMs: Double, maxPoints: Int = 2000) -> Plan { + let cap = max(1, maxPoints) + // Transfer is whole tiles, so what must fit under the cap is the + // tile-aligned span, not the requested one. + func alignedSpan(_ r: Resolution) -> Double { + let first = tileStart(r, fromMs) + let last = tileStart(r, max(fromMs, toMs - 1)) + return last + spec(r).tileSpanMs - first + } + + // Clamp to a coarser store before aggregating: coarser stored data is + // real data, an aggregate of finer data is an average of it. + var chosen = order.last! + for candidate in order where order.firstIndex(of: candidate)! >= order.firstIndex(of: res)! { + chosen = candidate + if alignedSpan(candidate) / spec(candidate).stepMs <= Double(cap) { break } + } + + let base = spec(chosen).stepMs + let total = alignedSpan(chosen) + let n = pointsPerTile(chosen) + let options = (1...n).filter { n % $0 == 0 } + let stride = options.first { total / (base * Double($0)) <= Double(cap) } ?? options.last! + let span = spec(chosen).tileSpanMs + let firstStart = tileStart(chosen, fromMs) + let lastStart = tileStart(chosen, max(fromMs, toMs - 1)) + + var tiles = [PlannedTile]() + var start = firstStart + while start <= lastStart { + tiles.append(PlannedTile(tileId: tileID(chosen, stride: stride, startMs: start), startMs: start, points: n / stride)) + start += span + } + return Plan(res: chosen, stride: stride, stepMs: base * Double(stride), tiles: tiles) + } + + /// The stride is part of a tile's identity: six buckets averaged hold + /// different numbers from the same hours at full detail. + public static func tileID(_ res: Resolution, stride: Int, startMs: Double) -> String { + "\(res.rawValue)/\(stride)/\(Int64(startMs / spec(res).tileSpanMs))" + } + + /// One contiguous int32 LE block per series. + public static func pack(_ columns: [[Int32]]) -> Bytes { + var out = Bytes() + for column in columns { + for v in column { + let u = UInt32(bitPattern: v) + out += [UInt8(u & 0xff), UInt8(u >> 8 & 0xff), UInt8(u >> 16 & 0xff), UInt8(u >> 24)] + } + } + return out + } + + public static func unpack(_ data: Bytes, seriesCount: Int) -> [[Int32]] { + guard seriesCount > 0 else { return [] } + let points = data.count / 4 / seriesCount + guard points > 0 else { return Array(repeating: [], count: seriesCount) } + return (0.. Int32? { + guard let c = names.firstIndex(of: name), index >= 0, index < columns[c].count else { return nil } + let v = columns[c][index] + return v == missingSample ? nil : v + } + + public func time(at index: Int) -> Double { startMs + Double(index) * stepMs } + } + + /// Lay tiles end to end. A missing tile stays missing rather than + /// shifting what follows it: closing over a gap would show the wrong day. + public static func assemble(_ plan: Plan, names: [String], tiles: [String: HistChunk]) -> Frame { + let perTile = plan.tiles.first?.points ?? 0 + let points = perTile * plan.tiles.count + var columns = names.map { _ in [Int32](repeating: missingSample, count: points) } + for (index, planned) in plan.tiles.enumerated() { + guard let tile = tiles[planned.tileId] else { continue } + let unpacked = unpack(tile.data, seriesCount: tile.series.count) + let offset = index * perTile + for (target, name) in names.enumerated() { + guard let s = tile.series.firstIndex(of: name), s < unpacked.count else { continue } + let source = unpacked[s].prefix(perTile) + for (i, v) in source.enumerated() { columns[target][offset + i] = v } + } + } + return Frame(startMs: plan.tiles.first?.startMs ?? 0, stepMs: plan.stepMs, points: points, names: names, columns: columns) + } + + /// Trim a tile-aligned frame to the window asked for. + public static func clip(_ frame: Frame, fromMs: Double, toMs: Double) -> Frame { + guard frame.points > 0 else { return frame } + let first = max(0, Int(((fromMs - frame.startMs) / frame.stepMs).rounded(.down))) + let last = min(frame.points, Int(((toMs - frame.startMs) / frame.stepMs).rounded(.up))) + if first == 0 && last == frame.points { return frame } + if last <= first { return Frame(startMs: frame.startMs, stepMs: frame.stepMs, points: 0, names: frame.names, columns: frame.names.map { _ in [] }) } + return Frame( + startMs: frame.startMs + Double(first) * frame.stepMs, + stepMs: frame.stepMs, + points: last - first, + names: frame.names, + columns: frame.columns.map { Array($0[first.. String { + var h: UInt32 = 0x811c9dc5 + for b in data { + h ^= UInt32(b) + h = h &* 0x01000193 + } + let s = String(h, radix: 16) + return String(repeating: "0", count: 8 - s.count) + s + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift new file mode 100644 index 0000000..adb6cbc --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift @@ -0,0 +1,531 @@ +import Foundation + +/// The message shapes. Names shared with the box come from +/// protocol/registry.yaml, through `Contract`. +/// +/// Every decoder here ignores keys it does not know and defaults what an +/// older box leaves out. Every age is measured against the box's uptime, +/// never its wall clock: a Pi has no RTC and reads 1970 until NTP answers. +public enum Proto { + public static let min = 0 + public static let max = 1 + /// The frozen subset: an app too old for full mode still draws the core. + public static let floor = 0 +} + +public enum BoxMode: String, Sendable { + case full, floor, booting, readonly +} + +public enum SourceState: String, Sendable, Codable, Comparable { + case live, lagging, stale, down, never + + var rank: Int { + switch self { + case .live: return 0 + case .lagging: return 1 + case .stale: return 2 + case .down: return 3 + case .never: return 4 + } + } + + public static func < (a: SourceState, b: SourceState) -> Bool { a.rank < b.rank } +} + +public struct Source: Equatable, Sendable, Codable { + public var kind: String + public var name: String + /// Box uptime at the last good reading. Not wall clock. + public var lastOkMs: Double + public var staleAfterMs: Double + public var state: SourceState + + init?(_ c: CBOR) { + guard c.entries != nil else { return nil } + kind = c["kind"]?.string ?? "" + name = c["name"]?.string ?? "" + lastOkMs = c["lastOkMs"]?.double ?? 0 + staleAfterMs = c["staleAfterMs"]?.double ?? 0 + state = c["state"]?.string.flatMap(SourceState.init(rawValue:)) ?? .never + } + + public init(kind: String, name: String, lastOkMs: Double, staleAfterMs: Double, state: SourceState) { + self.kind = kind + self.name = name + self.lastOkMs = lastOkMs + self.staleAfterMs = staleAfterMs + self.state = state + } +} + +public struct FieldDef: Equatable, Sendable, Codable { + public var name: String + public var unit: String? + /// The source this field's freshness comes from. Nil for fields the box + /// computes itself, such as the mode. + public var srcId: String? + + public init(name: String, unit: String?, srcId: String?) { + self.name = name + self.unit = unit + self.srcId = srcId + } +} + +public struct ModeInfo: Equatable, Sendable, Identifiable { + public var key: String + public var label: String + public var tooltip: String + /// Placement, not permission: primary, advanced or hidden. + public var tier: String + public var id: String { key } + + public init(key: String, label: String, tooltip: String, tier: String) { + self.key = key + self.label = label + self.tooltip = tooltip + self.tier = tier + } +} + +public struct BootProgress: Equatable, Sendable { + public var phase: String + public var pct: Int + public var etaMs: Double? +} + +public struct BoxInfo: Equatable, Sendable { + public var id: String + public var build: String + public var tz: String +} + +public struct HelloOk: Equatable, Sendable { + public var proto: Int + public var mode: BoxMode + public var box: BoxInfo + public var clockSource: String + public var syncedAtMs: Double? + public var uptimeMs: Double + public var role: String? + public var scopes: [String]? + public var caps: [String] + public var modes: [ModeInfo] + public var boot: BootProgress? + public var hint: String? + public var subscribed: Bool + + init(_ c: CBOR) { + proto = c["proto"]?.int ?? Proto.max + mode = c["mode"]?.string.flatMap(BoxMode.init(rawValue:)) ?? .full + let b = c["box"] + box = BoxInfo(id: b?["id"]?.string ?? "", build: b?["build"]?.string ?? "", tz: b?["tz"]?.string ?? "") + let clock = c["clock"] + clockSource = clock?["source"]?.string ?? "none" + // Go sends 0 for never synced; 0 is a real 1970 timestamp, so it + // means nil here. + let synced = clock?["syncedAtMs"]?.double ?? 0 + syncedAtMs = synced > 0 ? synced : nil + uptimeMs = clock?["uptimeMs"]?.double ?? 0 + role = c["role"]?.string.flatMap { $0.isEmpty ? nil : $0 } + scopes = c["scopes"]?.stringArray + caps = c["caps"]?.stringArray ?? [] + modes = (c["modes"]?.array ?? []).compactMap { m in + guard let key = m["key"]?.string else { return nil } + return ModeInfo(key: key, label: m["label"]?.string ?? key, tooltip: m["tooltip"]?.string ?? "", tier: m["tier"]?.string ?? "advanced") + } + if let boot = c["boot"], boot.entries != nil { + self.boot = BootProgress(phase: boot["phase"]?.string ?? "", pct: boot["pct"]?.int ?? 0, etaMs: boot["etaMs"]?.double) + } + hint = c["hint"]?.string + subscribed = c["subscribed"]?.bool ?? false + } +} + +/// Fields arrive as a text-keyed map of integers: `{"2": -3000}`. +func decodeFields(_ c: CBOR?) -> [Int: Double] { + var out = [Int: Double]() + for e in c?.entries ?? [] { + let key: Int? + switch e.key { + case .text(let s): key = Int(s) + default: key = e.key.int + } + if let key, let v = e.value.double { out[key] = v } + } + return out +} + +func decodeSources(_ c: CBOR?) -> [String: Source] { + var out = [String: Source]() + for e in c?.entries ?? [] { + if let k = e.key.string, let s = Source(e.value) { out[k] = s } + } + return out +} + +func decodeDict(_ c: CBOR?) -> [Int: FieldDef] { + var out = [Int: FieldDef]() + for e in c?.entries ?? [] { + guard let k = e.key.string.flatMap(Int.init) ?? e.key.int else { continue } + out[k] = FieldDef( + name: e.value["name"]?.string ?? "", + unit: e.value["unit"]?.string, + srcId: e.value["srcId"]?.string + ) + } + return out +} + +public struct Snap: Sendable { + public var uptimeMs: Double + public var controlRev: UInt64 + public var dict: [Int: FieldDef] + public var fields: [Int: Double] + public var sources: [String: Source] + public var dispatchBlockedBy: [String] + + init(_ c: CBOR) { + uptimeMs = c["uptimeMs"]?.double ?? 0 + controlRev = UInt64(max(0, c["controlRev"]?.int64 ?? 0)) + dict = decodeDict(c["dict"]) + fields = decodeFields(c["fields"]) + sources = decodeSources(c["sources"]) + dispatchBlockedBy = c["dispatchBlockedBy"]?.stringArray ?? [] + } +} + +public struct Delta: Sendable { + public var seq: UInt64 + public var uptimeMs: Double + public var fields: [Int: Double] + public var sources: [String: Source]? + public var dispatchBlockedBy: [String]? + + init(_ c: CBOR) { + seq = UInt64(max(0, c["seq"]?.int64 ?? 0)) + uptimeMs = c["uptimeMs"]?.double ?? 0 + fields = decodeFields(c["fields"]) + sources = c["sources"].map { decodeSources($0) } + dispatchBlockedBy = c["dispatchBlockedBy"]?.stringArray + } +} + +// MARK: - Plan and prices + +public struct PlanSlot: Equatable, Sendable, Identifiable { + /// Wall clock: these are future times a person reads. + public var startMs: Double + public var durationMs: Double + /// Signed watts at the battery, site convention: positive charges. + public var batteryW: Double + /// Expected import at the meter; negative means expected export. + public var gridW: Double + /// Import price in minor units per kWh, nil when unknown. + public var priceMinor: Double? + /// A stable code, never prose. The app owns every word. + public var reason: String + public var id: Double { startMs } + + public init(startMs: Double, durationMs: Double, batteryW: Double, gridW: Double, priceMinor: Double?, reason: String) { + self.startMs = startMs + self.durationMs = durationMs + self.batteryW = batteryW + self.gridW = gridW + self.priceMinor = priceMinor + self.reason = reason + } +} + +public struct Plan: Equatable, Sendable { + public var rev: UInt64 + public var uptimeMs: Double + public var slots: [PlanSlot] + /// The planner could not run and the box fell back. "Nothing scheduled" + /// and "we do not know what is scheduled" are different sentences. + public var stale: Bool + public var ceilingW: Double? + + public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?) { + self.rev = rev + self.uptimeMs = uptimeMs + self.slots = slots + self.stale = stale + self.ceilingW = ceilingW + } + + init(_ c: CBOR) { + rev = UInt64(max(0, c["rev"]?.int64 ?? 0)) + uptimeMs = c["uptimeMs"]?.double ?? 0 + slots = (c["slots"]?.array ?? []).compactMap { s in + guard let start = s["startMs"]?.double else { return nil } + return PlanSlot( + startMs: start, + durationMs: s["durationMs"]?.double ?? 900_000, + batteryW: s["batteryW"]?.double ?? 0, + gridW: s["gridW"]?.double ?? 0, + priceMinor: s["priceMinor"]?.double, + reason: s["reason"]?.string ?? "idle" + ) + } + stale = c["stale"]?.bool ?? false + ceilingW = c["ceilingW"]?.double + } +} + +public struct PriceSlot: Equatable, Sendable, Identifiable { + public var startMs: Double + public var durationMs: Double + /// Integer minor units per kWh. Money never crosses as a float. + public var spotMinor: Double + /// What the household pays, tariff and tax included, computed by the box. + public var totalMinor: Double + public var id: Double { startMs } + + public init(startMs: Double, durationMs: Double, spotMinor: Double, totalMinor: Double) { + self.startMs = startMs + self.durationMs = durationMs + self.spotMinor = spotMinor + self.totalMinor = totalMinor + } +} + +public struct Prices: Equatable, Sendable { + public var zone: String + public var currency: String + public var slots: [PriceSlot] + /// The answer does not cover the window asked for. Which of the three + /// shapes it is has to be read off the slots. + public var stale: Bool + + public init(zone: String, currency: String, slots: [PriceSlot], stale: Bool) { + self.zone = zone + self.currency = currency + self.slots = slots + self.stale = stale + } + + init(_ c: CBOR) { + zone = c["zone"]?.string ?? "" + currency = c["currency"]?.string ?? "SEK" + slots = (c["slots"]?.array ?? []).compactMap { s in + guard let start = s["startMs"]?.double else { return nil } + return PriceSlot( + startMs: start, + durationMs: s["durationMs"]?.double ?? 3_600_000, + spotMinor: s["spotMinor"]?.double ?? 0, + totalMinor: s["totalMinor"]?.double ?? s["spotMinor"]?.double ?? 0 + ) + } + stale = c["stale"]?.bool ?? false + } +} + +// MARK: - History + +public enum Resolution: String, Sendable, CaseIterable { + case fiveMinutes = "5m" + case hour = "1h" +} + +public struct HistQuery: Sendable { + public var series: [String] + public var res: Resolution + public var fromMs: Double + public var toMs: Double + public var have: [(tileId: String, etag: String)] + public var maxPoints: Int? + + var cbor: CBOR { + var pairs: [(String, CBOR)] = [ + ("series", .array(series.map { .text($0) })), + ("res", .text(res.rawValue)), + // Whole milliseconds: the box reads these into int64 and drops a + // query whose times carry a fraction. + ("fromMs", .ms(fromMs)), + ("toMs", .ms(toMs)), + ] + if !have.isEmpty { + pairs.append(("have", .array(have.map { .map([("tileId", .text($0.tileId)), ("etag", .text($0.etag))]) }))) + } + if let maxPoints { pairs.append(("maxPoints", .int(maxPoints))) } + return .map(pairs) + } +} + +public struct HistChunk: Sendable { + public var tileId: String + public var etag: String + public var res: Resolution + public var startMs: Double + public var stepMs: Double + public var series: [String] + /// Column-packed int32 little-endian, one block per series. + public var data: Bytes + /// The trailing tile is still filling; never cache it. + public var partial: Bool + + init?(_ c: CBOR) { + guard let tileId = c["tileId"]?.string else { return nil } + self.tileId = tileId + etag = c["etag"]?.string ?? "" + res = c["res"]?.string.flatMap(Resolution.init(rawValue:)) ?? .fiveMinutes + startMs = c["startMs"]?.double ?? 0 + stepMs = c["stepMs"]?.double ?? 300_000 + series = c["series"]?.stringArray ?? [] + data = c["data"]?.byteString ?? [] + partial = c["partial"]?.bool ?? false + } + + public init(tileId: String, etag: String, res: Resolution, startMs: Double, stepMs: Double, series: [String], data: Bytes, partial: Bool) { + self.tileId = tileId + self.etag = etag + self.res = res + self.startMs = startMs + self.stepMs = stepMs + self.series = series + self.data = data + self.partial = partial + } +} + +public struct HistGap: Equatable, Sendable { + public var fromMs: Double + public var toMs: Double + public var reason: String +} + +public struct HistEnd: Sendable { + /// What the box actually served, which may be coarser than asked for. + public var resActual: Resolution + public var gaps: [HistGap] + + init(_ c: CBOR) { + resActual = c["resActual"]?.string.flatMap(Resolution.init(rawValue:)) ?? .fiveMinutes + gaps = (c["gaps"]?.array ?? []).compactMap { g in + guard let from = g["fromMs"]?.double, let to = g["toMs"]?.double else { return nil } + return HistGap(fromMs: from, toMs: to, reason: g["reason"]?.string ?? "no_data") + } + } +} + +/// Distinct from zero, which is a real reading. +public let missingSample = Int32.min + +// MARK: - The box's own API + +public enum APIMethod: String, Sendable { + case get = "GET", head = "HEAD", post = "POST", put = "PUT", patch = "PATCH", delete = "DELETE" +} + +/// A request against the box's own HTTP API, carried in the session. +/// +/// There is no headers field, deliberately: the caller's identity rides on +/// the request context inside the box, put there by the session that +/// authenticated it, so no byte this app sends becomes a claim about who is +/// asking. The query is parsed, never a raw string. +public struct APIRequest: Sendable { + public var method: APIMethod + public var path: String + public var query: [String: String] + public var body: Bytes? + public var stepUp: Bool + + public init(method: APIMethod, path: String, query: [String: String] = [:], body: Bytes? = nil, stepUp: Bool = false) { + self.method = method + self.path = path + self.query = query + self.body = body + self.stepUp = stepUp + } + + var cbor: CBOR { + var pairs: [(String, CBOR)] = [ + ("maxBytes", .int(Session.apiMaxBytes)), + ("method", .text(method.rawValue)), + ("path", .text(path)), + ] + if !query.isEmpty { + pairs.append(("query", .map(query.sorted { $0.key < $1.key }.map { ($0.key, CBOR.text($0.value)) }))) + } + if let body { pairs.append(("body", .bytes(body))) } + if stepUp { pairs.append(("stepUp", .bool(true))) } + return .map(pairs) + } +} + +public struct APIResponse: Sendable { + public var status: Int + public var headers: [String: String] + public var body: Bytes +} + +// MARK: - Commands + +public struct Guard: Sendable { + public var fid: Int + public var op: String + public var value: Double + + public init(fid: Int, op: String, value: Double) { + self.fid = fid + self.op = op + self.value = value + } +} + +public struct CmdResult: Equatable, Sendable { + public enum State: String, Sendable { + case applied, rejected, expired, superseded, unconfirmed + } + + public var cmdId: String + public var state: State + /// Present when the driver read the value back. The real proof. + public var observedValue: Double? + public var errorCode: String? + public var errorArgs: [String: CBOR] + + public init(cmdId: String, state: State, observedValue: Double? = nil, errorCode: String? = nil, errorArgs: [String: CBOR] = [:]) { + self.cmdId = cmdId + self.state = state + self.observedValue = observedValue + self.errorCode = errorCode + self.errorArgs = errorArgs + } + + init(_ c: CBOR) { + cmdId = c["cmdId"]?.string ?? "" + state = c["state"]?.string.flatMap(State.init(rawValue:)) ?? .rejected + observedValue = c["observed"]?["value"]?.double + errorCode = c["error"]?["code"]?.string + errorArgs = c["error"]?["args"]?.textMap ?? [:] + } +} + +// MARK: - Errors and teardown + +public struct ErrorMsg: Equatable, Sendable { + public var code: String + public var retryable: Bool + public var retryAfterMs: Double? + public var args: [String: CBOR] + + public init(code: String, retryable: Bool? = nil, args: [String: CBOR] = [:]) { + self.code = code + self.retryable = retryable ?? Contract.isRetryable(code) + self.args = args + } + + init(_ c: CBOR) { + code = c["code"]?.string ?? "E_UNKNOWN" + retryable = c["retryable"]?.bool ?? Contract.isRetryable(code) + retryAfterMs = c["retryAfterMs"]?.double + args = c["args"]?.textMap ?? [:] + } +} + +public enum TerminateReason: String, Sendable { + case revoked, epochChanged = "epoch_changed", boxShutdown = "box_shutdown", superseded +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift new file mode 100644 index 0000000..7ae4e18 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -0,0 +1,775 @@ +import Foundation + +/// The session: handshake, subscription and the field register. +/// +/// Owns exactly one carrier at a time and turns frames into state. +/// Everything above reads state; nothing above touches a frame. +/// +/// Freshness is two facts, never one: `carrier`, how frames are reaching us, +/// and `sources`, whether the box's own devices are answering. +public enum SessionPhase: String, Sendable { + case idle, handshaking, subscribing, streaming, booting, terminated, failed +} + +public struct SessionState: Sendable { + public var phase: SessionPhase = .idle + public var carrier: CarrierKind = .none + public var proto: Int = Proto.max + public var mode: BoxMode = .full + public var caps: Set = [] + /// What this enrolment may do, as the box named it. Read only to decide + /// what to draw. A box from before roles sends nothing and treats every + /// paired phone as an owner, so that is what absent means. + public var role: String = Contract.roleOwner + /// That role expanded, as the box expanded it. What a control is checked + /// against; `role` is what a sentence names. + public var scopes: Set = Set(Contract.roleScopes[Contract.roleOwner] ?? []) + /// Whether the box has answered a hello since the app opened. Before + /// that, role and caps are this app's opening assumptions, and a screen + /// that states something about the box from them would be inventing it. + public var heardFromBox = false + public var box: BoxInfo? + /// Box uptime at the last frame. All ages are deltas against this. + public var uptimeMs: Double = 0 + public var controlRev: UInt64 = 0 + public var fields: [Int: Double] = [:] + public var dict: [Int: FieldDef] = [:] + public var sources: [String: Source] = [:] + public var dispatchBlockedBy: [String] = [] + public var boot: BootProgress? + public var lastError: ErrorMsg? + public var terminated: TerminateReason? + /// The box says this app is too old for everything. + public var needsUpdate = false + /// What the box intends to do. The box pushes a fresh one unasked after + /// a mode change, so it lives here rather than only as an answer. + public var plan: Plan? + /// The last delta could not carry every changed field. Not a fault. + public var truncated = false + /// Every mode the box accepts, in its order. Field 1 indexes this list. + public var modes: [ModeInfo] = [] + + public init() {} +} + +public struct Subscription: Equatable, Sendable { + /// Lane 0 bucket, fixed for the session. + public var bucket: Int = 512 + /// 1 while someone can see it, 0.2 while hidden. + public var hz: Double = 1 + + public init(bucket: Int = 512, hz: Double = 1) { + self.bucket = bucket + self.hz = hz + } + + var cbor: CBOR { .map([("bucket", .int(bucket)), ("hz", .number(hz))]) } +} + +/// Errors carry what a screen needs, never a code it would have to render. +public enum SessionError: Error, Equatable { + case noCarrier + case carrierClosed + case timedOut(String) + case outOfOrder + case noStatus + case queueTimedOut +} + +/// The box answered a request with a stable code instead of an answer. +public struct BoxRefusal: Error, Equatable { + public let detail: ErrorMsg +} + +/// A command's fate as a sentence the person can act on. +public struct CommandError: Error, Equatable, HelpfulError { + public let code: String + public let help: String +} + +@MainActor +public final class Session { + /// History over a relay can be dozens of bulk frames; the point is that + /// the request always settles, not that it fails fast. + public static let historyTimeoutMs: Double = 20_000 + public static let apiTimeoutMs: Double = 20_000 + public static let planTimeoutMs: Double = 8_000 + public static let priceTimeoutMs: Double = 8_000 + /// A box that is starting refuses a subscription and does not announce + /// when it is ready, so the session asks again on its own. + public static let bootRetryMs: Double = 5_000 + /// Two minutes of box uptime: survives a slow relay, and a command queued + /// in a tunnel is refused rather than acted on as though new. + public static let cmdValidForMs: Double = 120_000 + public static let cmdAckTimeoutMs: Double = 5_000 + public static let cmdConfirmTimeoutMs: Double = 15_000 + public nonisolated static let apiMaxBytes = 8 * 1024 * 1024 + + public private(set) var state = SessionState() { + didSet { onChange?(state) } + } + + /// One listener: the site model. Called after every change. + public var onChange: (@MainActor (SessionState) -> Void)? + + private let build: String + private let ua: String + private let locales: [String] + private let scheduler: Scheduler + private var carrier: Carrier? + private var subscription: Subscription + private var helloSub: Subscription? + private var nextRequestID: UInt32 = 1 + private var bootRetry: Cancellable? + + private struct PendingHistory { + var onChunk: @MainActor (HistChunk) -> Void + var continuation: CheckedContinuation + var timer: Cancellable? + } + + private struct PendingSingle { + var continuation: CheckedContinuation + var timer: Cancellable? + } + + private struct PendingAPI { + var continuation: CheckedContinuation + var timer: Cancellable? + var head: (status: Int, headers: [String: String])? + var chunks: Bytes = [] + var nextSeq: Int = 0 + } + + private struct PendingCommand { + var continuation: CheckedContinuation + var ackTimer: Cancellable + var confirmTimer: Cancellable + var acked = false + } + + private var pendingHistory: [UInt32: PendingHistory] = [:] + private var pendingPlan: [UInt32: PendingSingle] = [:] + private var pendingPrices: [UInt32: PendingSingle] = [:] + private var pendingAPI: [UInt32: PendingAPI] = [:] + private var pendingCommands: [String: PendingCommand] = [:] + + // The api queue: one call on the wire at a time, because that is how + // many the box serves. + private var apiBusy = false + private var apiWaiters: [(id: Int, continuation: CheckedContinuation, timer: Cancellable)] = [] + private var apiWaiterSeq = 0 + private var apiGeneration = 0 + + public init(build: String, ua: String = "native", locales: [String] = ["en"], subscription: Subscription = Subscription(), scheduler: Scheduler) { + self.build = build + self.ua = ua + self.locales = locales + self.subscription = subscription + self.scheduler = scheduler + } + + // MARK: Lifecycle + + /// Seed state from the cache before any carrier exists. The readings + /// were true when captured and the band says how long ago; carrier stays + /// `cache`, which is a carrier and not a failure. + /// + /// The cache read races the connect. Landing mid-handshake, only the + /// data paints: the phase, carrier and clock belong to the connection. + public func restore(_ snapshot: CachedSnapshot) { + switch state.phase { + case .streaming: + return + case .handshaking, .subscribing, .booting: + if !state.fields.isEmpty { return } + var s = state + snapshot.apply(to: &s, includeClock: false) + state = s + default: + var s = state + snapshot.apply(to: &s, includeClock: true) + s.phase = .idle + s.carrier = .cache + state = s + } + } + + /// Attach a carrier and start the handshake. Replaces any current one. + public func connect(_ next: Carrier) { + detach() + // Keep the readings, but drop the old transport claim now: an old + // stream must never read as live while the new carrier dials. + var s = state + s.phase = .idle + s.carrier = .none + state = s + carrier = next + next.setHandlers( + onFrame: { [weak self] bytes in self?.onFrame(bytes) }, + onStatus: { [weak self, weak next] status in + guard let self, let next, self.carrier === next else { return } + self.onCarrierStatus(status, kind: next.kind) + } + ) + if next.status.isOpen { + onCarrierStatus(next.status, kind: next.kind) + } + } + + public func close() { + detach() + var s = state + s.phase = .idle + s.carrier = .none + state = s + } + + /// Match lane 0 to whether anybody can see it. The bucket stays fixed. + public func setTelemetryHz(_ hz: Double) { + if subscription.hz == hz { return } + subscription.hz = hz + if state.phase == .subscribing || state.phase == .streaming { sendSub() } + } + + /// Ask the carrier stack to replace a path that may have slept stale. + @discardableResult + public func wake() -> Bool { + guard let carrier else { return false } + carrier.wake() + return true + } + + public var hasCarrier: Bool { carrier != nil } + + // MARK: Freshness + + /// Age of a source's last good reading, in ms of box uptime. Nil when the + /// two numbers come from different boots: unknown, not "just now". + public func ageOf(_ srcId: String) -> Double? { + guard let src = state.sources[srcId] else { return nil } + let age = state.uptimeMs - src.lastOkMs + return age < 0 ? nil : age + } + + /// Worst state across the given sources. + public func worstSourceState(_ ids: [String]) -> SourceState { + ids.map { state.sources[$0]?.state ?? .never }.max() ?? .live + } + + // MARK: Requests + + private func takeRequestID() -> UInt32 { + let id = nextRequestID + // u32, and wrapping is harmless: a request that old has settled. + nextRequestID = nextRequestID == UInt32.max ? 1 : nextRequestID + 1 + return id + } + + /// A history window. Chunks arrive as they land; the call returns on + /// `hist.end`. Bulk lane: a query carrying a `have` list varies in length. + public func history(_ query: HistQuery, onChunk: @escaping @MainActor (HistChunk) -> Void) async throws -> HistEnd { + guard carrier != nil else { throw SessionError.noCarrier } + let id = takeRequestID() + let frame = try Frame.encodeBulk(envelope: Envelope(t: "hist.query", id: id, b: query.cbor)) + // Registered before the frame leaves, so an answer can never arrive + // for a request nobody is holding. + return try await withCheckedThrowingContinuation { cont in + pendingHistory[id] = PendingHistory(onChunk: onChunk, continuation: cont, timer: nil) + armHistory(id) + carrier?.send(frame) + } + } + + public func plan() async throws -> Plan { + guard carrier != nil else { throw SessionError.noCarrier } + let id = takeRequestID() + let frame = try Frame.encodeBulk(envelope: Envelope(t: "plan.get", id: id)) + return try await withCheckedThrowingContinuation { cont in + let timer = scheduler.after(Self.planTimeoutMs) { [weak self] in + guard let p = self?.pendingPlan.removeValue(forKey: id) else { return } + p.continuation.resume(throwing: SessionError.timedOut("plan")) + } + pendingPlan[id] = PendingSingle(continuation: cont, timer: timer) + carrier?.send(frame) + } + } + + /// Prices across a window. Wall clock, unlike every age: prices are + /// about hours a person plans around. + public func prices(fromMs: Double, toMs: Double) async throws -> Prices { + guard carrier != nil else { throw SessionError.noCarrier } + let id = takeRequestID() + let frame = try Frame.encodeBulk(envelope: Envelope(t: "price.get", id: id, b: .map([("fromMs", .ms(fromMs)), ("toMs", .ms(toMs))]))) + return try await withCheckedThrowingContinuation { cont in + let timer = scheduler.after(Self.priceTimeoutMs) { [weak self] in + guard let p = self?.pendingPrices.removeValue(forKey: id) else { return } + p.continuation.resume(throwing: SessionError.timedOut("prices")) + } + pendingPrices[id] = PendingSingle(continuation: cont, timer: timer) + carrier?.send(frame) + } + } + + /// Call the box's own HTTP API. A 404 is an answer here, not a failure; + /// `BoxRefusal` means the passthrough refused and no handler ran. + /// + /// One call on the wire at a time. The queue is released on settle, not + /// success, and a call never crosses a disconnect into a new session. + public func api(_ req: APIRequest) async throws -> APIResponse { + let generation = apiGeneration + try await acquireAPISlot() + defer { releaseAPISlot() } + for attempt in 0... { + if generation != apiGeneration { throw SessionError.carrierClosed } + do { + return try await dispatchAPI(req) + } catch let refusal as BoxRefusal { + // The box can send its last frame before releasing its slot. + // Only this explicit refusal proves no handler ran; never + // retry anything else. + guard refusal.detail.code == "E_UNAVAILABLE", + refusal.detail.args["reason"]?.string == "busy", + refusal.detail.retryable, attempt < 3 else { throw refusal } + try await sleep(250 * pow(2, Double(attempt))) + } + } + throw SessionError.carrierClosed + } + + private func sleep(_ ms: Double) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + scheduler.after(ms) { cont.resume() } + } + } + + private func acquireAPISlot() async throws { + if !apiBusy { + apiBusy = true + return + } + apiWaiterSeq += 1 + let mine = apiWaiterSeq + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let timer = scheduler.after(Self.apiTimeoutMs) { [weak self] in + guard let self, let i = self.apiWaiters.firstIndex(where: { $0.id == mine }) else { return } + self.apiWaiters.remove(at: i) + cont.resume(throwing: SessionError.queueTimedOut) + } + apiWaiters.append((mine, cont, timer)) + } + } + + private func releaseAPISlot() { + if apiWaiters.isEmpty { + apiBusy = false + return + } + let next = apiWaiters.removeFirst() + next.timer.cancel() + next.continuation.resume() + } + + private func dispatchAPI(_ req: APIRequest) async throws -> APIResponse { + guard let carrier else { throw SessionError.noCarrier } + if case .closed = carrier.status { throw SessionError.carrierClosed } + let id = takeRequestID() + let frame = try Frame.encodeBulk(envelope: Envelope(t: "api.req", id: id, b: req.cbor)) + return try await withCheckedThrowingContinuation { cont in + pendingAPI[id] = PendingAPI(continuation: cont) + armAPI(id) + carrier.send(frame) + } + } + + /// Express an intent and follow it to its outcome. Three deadlines, + /// because they are three events: no ack means it never reached the box; + /// ack but no result means the hardware has not confirmed; a result is + /// what actually happened. + public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = []) async throws -> CmdResult { + guard let carrier else { throw SessionError.noCarrier } + let cmdId = Self.uuidv7(nowMs: scheduler.nowMs) + let body = CBOR.map([ + ("cmdId", .text(cmdId)), + ("op", .text(op)), + ("args", .map(args)), + // Box uptime, the only clock both ends agree on. + ("notValidAfterMs", .ms(state.uptimeMs + Self.cmdValidForMs)), + ("expect", .map([ + ("rev", .unsigned(state.controlRev)), + ("guards", .array(guards.map { .map([("fid", .int($0.fid)), ("op", .text($0.op)), ("value", .number($0.value))]) })), + ])), + ]) + let frame = try Frame.encode(lane: Frame.laneControl, envelope: Envelope(t: "cmd", b: body), bucket: subscription.bucket) + return try await withCheckedThrowingContinuation { cont in + let ackTimer = scheduler.after(Self.cmdAckTimeoutMs) { [weak self] in + guard let self, let p = self.pendingCommands[cmdId], !p.acked else { return } + self.pendingCommands[cmdId] = nil + p.confirmTimer.cancel() + p.continuation.resume(throwing: CommandError(code: "E_NO_ACK", help: "That didn't reach your box. Try again.")) + } + let confirmTimer = scheduler.after(Self.cmdConfirmTimeoutMs) { [weak self] in + guard let self, let p = self.pendingCommands[cmdId], p.acked else { return } + self.pendingCommands[cmdId] = nil + // Accepted, never confirmed: not a failure and not a success, + // and the screen has to be able to say so. + p.continuation.resume(returning: CmdResult(cmdId: cmdId, state: .unconfirmed)) + } + pendingCommands[cmdId] = PendingCommand(continuation: cont, ackTimer: ackTimer, confirmTimer: confirmTimer) + carrier.send(frame) + } + } + + /// UUIDv7: time-ordered, so the box can expire idempotency keys by prefix. + static func uuidv7(nowMs: Double) -> String { + var bytes = randomBytes(16) + let ms = UInt64(max(0, nowMs)) + for i in 0..<6 { bytes[i] = UInt8(truncatingIfNeeded: ms >> UInt64(8 * (5 - i))) } + bytes[6] = (bytes[6] & 0x0f) | 0x70 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + let h = bytes.hex + let c = Array(h) + return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20...]))" + } + + // MARK: Frames in + + private func onCarrierStatus(_ status: CarrierStatus, kind: CarrierKind) { + switch status { + case .open: + var s = state + s.phase = .handshaking + s.carrier = kind + state = s + sendHello() + case .closed: + // Losing the carrier does not clear the readings. They are still + // true, just older, and the band says so. + var s = state + s.phase = .failed + s.carrier = .none + state = s + bootRetry?.cancel() + // The ordinary way a carrier goes away: it reconnects inside + // itself, keeping its handlers. Every request in flight ends now, + // so no view waits out a deadline against a reply that cannot come. + settlePending() + case .connecting: + break + } + } + + private func onFrame(_ bytes: Bytes) { + // A frame that does not parse is not a reason to tear down a working + // session; the next one may be fine. + guard let frame = try? Frame.decode(bytes) else { return } + let env = frame.envelope + let body = env.b ?? CBOR.emptyMap + switch env.t { + case "hello_ok": onHelloOk(HelloOk(body)) + case "snap": onSnap(Snap(body)) + case "delta": onDelta(Delta(body), truncated: frame.truncated) + case "tick": + var s = state + s.uptimeMs = body["uptimeMs"]?.double ?? s.uptimeMs + state = s + case "hist.chunk": + guard let id = env.id, let chunk = HistChunk(body), let pending = pendingHistory[id] else { return } + // A window still arriving is not a window gone quiet. + armHistory(id) + pending.onChunk(chunk) + case "hist.end": + guard let id = env.id, let pending = pendingHistory.removeValue(forKey: id) else { return } + pending.timer?.cancel() + pending.continuation.resume(returning: HistEnd(body)) + case "plan": + let plan = Plan(body) + var s = state + s.plan = plan + state = s + if let id = env.id, let pending = pendingPlan.removeValue(forKey: id) { + pending.timer?.cancel() + pending.continuation.resume(returning: plan) + } + case "price": + guard let id = env.id, let pending = pendingPrices.removeValue(forKey: id) else { return } + pending.timer?.cancel() + pending.continuation.resume(returning: Prices(body)) + case "api.head": onAPIHead(env.id, body) + case "api.chunk": onAPIChunk(env.id, body) + case "api.end": onAPIEnd(env.id, body) + case "cmd.ack": + guard let cmdId = body["cmdId"]?.string, var p = pendingCommands[cmdId] else { return } + p.ackTimer.cancel() + p.acked = true + pendingCommands[cmdId] = p + case "cmd.result": + let result = CmdResult(body) + guard let p = pendingCommands.removeValue(forKey: result.cmdId) else { return } + p.ackTimer.cancel() + p.confirmTimer.cancel() + p.continuation.resume(returning: result) + case "error": onError(ErrorMsg(body), id: env.id) + case "session.terminate": + var s = state + s.phase = .terminated + s.terminated = body["reason"]?.string.flatMap(TerminateReason.init(rawValue:)) ?? .superseded + s.carrier = .none + state = s + detach() + default: + // Unknown types are ignored: a newer box talking to this app. + break + } + } + + private func onHelloOk(_ b: HelloOk) { + var s = state + s.proto = b.proto + s.mode = b.mode + s.caps = Set(b.caps) + s.role = b.role ?? Contract.roleOwner + // The box's own expansion, never this app's, except for a box from + // before roles that sends no list at all. + s.scopes = Set(b.scopes ?? Contract.roleScopes[b.role ?? Contract.roleOwner] ?? []) + s.heardFromBox = true + s.modes = b.modes + s.box = b.box + s.uptimeMs = b.uptimeMs + s.boot = b.boot + s.needsUpdate = b.hint == "app_update" || b.proto == Proto.floor + if b.mode == .booting { + s.phase = .booting + state = s + bootRetry?.cancel() + bootRetry = scheduler.after(Self.bootRetryMs) { [weak self] in self?.sendHello() } + return + } + s.phase = .subscribing + state = s + if b.subscribed { + // Visibility can change while hello crosses the relay; send only + // if the ask has moved since then. + if helloSub != subscription { sendSub() } + return + } + // An older box ignored hello.sub and needs the separate exchange. + sendSub() + } + + private func onSnap(_ b: Snap) { + var s = state + s.phase = .streaming + if let carrier { s.carrier = carrier.kind } + s.uptimeMs = b.uptimeMs + s.controlRev = b.controlRev + s.dict = b.dict + s.fields = b.fields + s.sources = b.sources + s.dispatchBlockedBy = b.dispatchBlockedBy + state = s + } + + private func onDelta(_ b: Delta, truncated: Bool) { + // A gap means frames were lost. The values held are still true, so + // what arrived is applied rather than blanking the screen. + var s = state + s.phase = .streaming + s.uptimeMs = b.uptimeMs + for (k, v) in b.fields { s.fields[k] = v } + s.truncated = truncated + if let sources = b.sources { s.sources = sources } + if let blocked = b.dispatchBlockedBy { s.dispatchBlockedBy = blocked } + state = s + } + + private func onAPIHead(_ id: UInt32?, _ body: CBOR) { + guard let id, var p = pendingAPI[id] else { return } + // A second status for one request: the first is the one committed to. + if p.head != nil { return } + var headers = [String: String]() + for e in body["headers"]?.entries ?? [] { + if let k = e.key.string, let v = e.value.string { headers[k.lowercased()] = v } + } + p.head = (body["status"]?.int ?? 0, headers) + pendingAPI[id] = p + armAPI(id) + } + + private func onAPIChunk(_ id: UInt32?, _ body: CBOR) { + guard let id, var p = pendingAPI[id] else { return } + // A body assembled out of order is bytes that were never sent, + // presented as the box's. Fail the request instead. + guard body["seq"]?.int == p.nextSeq else { + pendingAPI[id] = nil + p.timer?.cancel() + p.continuation.resume(throwing: SessionError.outOfOrder) + return + } + p.nextSeq += 1 + p.chunks += body["data"]?.byteString ?? [] + pendingAPI[id] = p + armAPI(id) + } + + private func onAPIEnd(_ id: UInt32?, _ body: CBOR) { + guard let id, let p = pendingAPI.removeValue(forKey: id) else { return } + p.timer?.cancel() + // Half a document is wrong in a way no caller above can see. + if body["truncated"]?.bool == true { + p.continuation.resume(throwing: BoxRefusal(detail: ErrorMsg(code: "E_RESPONSE_TOO_LARGE", retryable: false, args: ["bytes": body["bytes"] ?? .null]))) + return + } + guard let head = p.head else { + p.continuation.resume(throwing: SessionError.noStatus) + return + } + p.continuation.resume(returning: APIResponse(status: head.status, headers: head.headers, body: p.chunks)) + } + + /// An error carrying a request id belongs to that request alone, so one + /// window the box could not serve never makes the whole app look broken. + private func onError(_ b: ErrorMsg, id: UInt32?) { + if let id { + if let p = pendingHistory.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: BoxRefusal(detail: b)) + return + } + if let p = pendingPlan.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: BoxRefusal(detail: b)) + return + } + if let p = pendingPrices.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: BoxRefusal(detail: b)) + return + } + if let p = pendingAPI.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: BoxRefusal(detail: b)) + return + } + } + var s = state + s.lastError = b + state = s + } + + // MARK: Frames out + + private func sendHello() { + // Any hello supersedes a retry waiting to send one. + bootRetry?.cancel() + helloSub = subscription + send(Envelope(t: "hello", b: .map([ + ("proto", .map([("min", .int(Proto.min)), ("max", .int(Proto.max))])), + ("app", .map([("build", .text(build)), ("ua", .text(ua))])), + ("locales", .array(locales.map { .text($0) })), + ("sub", subscription.cbor), + ]))) + } + + private func sendSub() { + send(Envelope(t: "sub", b: subscription.cbor)) + } + + private func send(_ envelope: Envelope) { + guard let carrier, let frame = try? Frame.encode(lane: Frame.laneControl, envelope: envelope, bucket: subscription.bucket) else { return } + carrier.send(frame) + } + + // MARK: Deadlines + + /// The deadline measures silence, not total time: every chunk re-arms it. + private func armHistory(_ id: UInt32) { + guard var p = pendingHistory[id] else { return } + p.timer?.cancel() + p.timer = scheduler.after(Self.historyTimeoutMs) { [weak self] in + guard let p = self?.pendingHistory.removeValue(forKey: id) else { return } + p.continuation.resume(throwing: SessionError.timedOut("history")) + } + pendingHistory[id] = p + } + + private func armAPI(_ id: UInt32) { + guard var p = pendingAPI[id] else { return } + p.timer?.cancel() + p.timer = scheduler.after(Self.apiTimeoutMs) { [weak self] in + guard let p = self?.pendingAPI.removeValue(forKey: id) else { return } + p.continuation.resume(throwing: SessionError.timedOut("api")) + } + pendingAPI[id] = p + } + + private func detach() { + bootRetry?.cancel() + if let carrier { + carrier.setHandlers(onFrame: { _ in }, onStatus: { _ in }) + carrier.close(reason: "closed by client") + } + carrier = nil + settlePending() + } + + /// End everything waiting on a carrier that will not answer. A command + /// is settled the way its own deadlines would settle it: never acked + /// means it did not reach the box; acked means it may well have run. + private func settlePending() { + apiGeneration += 1 + for (_, p) in pendingHistory { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } + pendingHistory = [:] + for (_, p) in pendingPlan { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } + pendingPlan = [:] + for (_, p) in pendingPrices { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } + pendingPrices = [:] + for (_, p) in pendingAPI { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } + pendingAPI = [:] + let commands = pendingCommands + pendingCommands = [:] + for (cmdId, p) in commands { + p.ackTimer.cancel() + p.confirmTimer.cancel() + if p.acked { + p.continuation.resume(returning: CmdResult(cmdId: cmdId, state: .unconfirmed)) + } else { + p.continuation.resume(throwing: CommandError(code: "E_NO_ACK", help: "That didn't reach your box. Try again.")) + } + } + } +} + +/// What the app keeps of a house between launches: enough to paint the +/// first frame honestly, with its age. +public struct CachedSnapshot: Codable, Equatable, Sendable { + public var siteId: String + /// Wall clock when it was written. + public var savedAtMs: Double + public var uptimeMs: Double + public var controlRev: UInt64 + public var fields: [Int: Double] + public var sources: [String: Source] + public var dispatchBlockedBy: [String] + public var dict: [Int: FieldDef] + + public init(siteId: String, savedAtMs: Double, state: SessionState) { + self.siteId = siteId + self.savedAtMs = savedAtMs + uptimeMs = state.uptimeMs + controlRev = state.controlRev + fields = state.fields + sources = state.sources + dispatchBlockedBy = state.dispatchBlockedBy + dict = state.dict + } + + func apply(to s: inout SessionState, includeClock: Bool) { + if includeClock { s.uptimeMs = uptimeMs } + s.controlRev = controlRev + s.fields = fields + s.sources = sources + s.dispatchBlockedBy = dispatchBlockedBy + s.dict = dict + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift new file mode 100644 index 0000000..59ff3f6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -0,0 +1,270 @@ +import Foundation +import Observation + +/// Every model one home's screens read, built once per home and kept while +/// tabs switch, so a second visit is instant and keeps its state. +@MainActor +public final class HomeModels { + public let site: SiteModel + public let plan: PlanModel + public let prices: PriceModel + public let status: StatusWatch + public let charging: ChargingWatch + public let savings: SavingsModel + public let energy: EnergyModel + public let history: HistoryModel + public let loadpoints: LoadpointsModel + public let access: AccessModel + public let notify: NotifyModel + public let restart: RestartModel + + init(site: SiteModel, files: SealedFiles?, thisPhone: String?) { + self.site = site + plan = PlanModel(site: site) + prices = PriceModel(site: site) + status = StatusWatch(site: site) + charging = ChargingWatch(site: site) + savings = SavingsModel(site: site) + energy = EnergyModel(site: site) + history = HistoryModel(site: site, tiles: TileCache(files: site.isDemo ? nil : files, siteId: site.siteId ?? "demo")) + loadpoints = LoadpointsModel(site: site, charging: charging) + access = AccessModel(site: site, thisPhone: thisPhone) + notify = NotifyModel(site: site) + restart = RestartModel(site: site) + } +} + +/// No carrier could be built from what this phone holds. Every other +/// failure heals itself because the carrier reconnects from inside; these +/// are the ones with nothing to reconnect, so the screen offers a way back. +public struct ConnectError: Error, Equatable, HelpfulError { + public enum Kind: Sendable { case notPaired, notEnrolled, locked, stalePairing } + public let kind: Kind + public let help: String +} + +/// The shell: which home is open, how it is reached, pairing, the demo and +/// leaving. Paints before any data arrives and never blocks on the network. +@Observable +@MainActor +public final class AppModel { + public private(set) var home: HomeModels? + /// What to do when no carrier could be built. Nil while one exists. + public private(set) var connectHelp: String? + /// The pairing screen, opened from a home that has lost its way in. + public var recovering = false + /// The last sign-out could not clear the disk; the home was put back. + public private(set) var leaveFailed = false + /// A pairing link that arrived from outside, shown before it is trusted. + public var offeredLink: String? + public private(set) var isDemo = false + /// The Box screen's spare-key switch for the open home. Nil in the demo. + public private(set) var sealedCopy: SealedCopyModel? + + @ObservationIgnored public let vault: Vault + @ObservationIgnored public let sites: SiteList + @ObservationIgnored public let escrow: Escrow + @ObservationIgnored public let pairing: Pairing + @ObservationIgnored public let passkeys: PasskeyAuthenticator? + @ObservationIgnored let store: SecureStore + @ObservationIgnored let files: SealedFiles? + @ObservationIgnored let scheduler: Scheduler + @ObservationIgnored let build: String + @ObservationIgnored let ua: String + @ObservationIgnored let relayURL: URL + @ObservationIgnored let makeSocket: WebSocketFactory + @ObservationIgnored private var demoBox: SimulatedBox? + @ObservationIgnored private var connectGeneration = 0 + + public init( + store: SecureStore, + files: SealedFiles?, + passkeys: PasskeyAuthenticator?, + scheduler: Scheduler = LiveScheduler.shared, + build: String, + ua: String, + relayURL: URL = Origin.relayURL, + escrowTransport: Escrow.Transport? = nil, + makeSocket: @escaping WebSocketFactory = URLSessionWebSocket.factory() + ) { + self.store = store + self.files = files + self.passkeys = passkeys + self.scheduler = scheduler + self.build = build + self.ua = ua + self.relayURL = relayURL + self.makeSocket = makeSocket + vault = Vault(store: store) + sites = SiteList(store: store) + escrow = Escrow(vault: vault, sites: sites, transport: escrowTransport) + pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys, now: { [scheduler] in scheduler.nowMs }) + } + + public var site: SiteModel? { home?.site } + + /// Nothing paired and nothing to show: the only screen is pairing. + public var needsPairing: Bool { home == nil || recovering || offeredLink != nil } + + /// The launch path: a paired phone paints its cached home at once and + /// connects behind it. + public func launch() { + guard home == nil, let current = sites.current() else { return } + open(current.siteId) + } + + /// Point the app at a home and connect to it. + public func open(_ siteId: String) { + exitDemo() + home?.site.destroy() + let site = SiteModel(siteId: siteId, build: build, ua: ua, scheduler: scheduler, files: files) + site.stepUp = { [weak self] in await self?.stepUp() ?? .unavailable } + home = HomeModels(site: site, files: files, thisPhone: vault.deviceIDOnBox) + sealedCopy = SealedCopyModel(app: self, siteId: siteId) + sites.setCurrent(siteId) + site.start() + recovering = false + Task { await connect() } + } + + func stepUp() async -> StepUpOutcome { + await vault.stepUp(passkeys) + } + + /// Build the carrier stack silently. Reading your own house is not a + /// privilege; a device enrolled before the local copy pays one last + /// prompt, and never again. + public func connect() async { + guard let site = home?.site, let siteId = site.siteId else { return } + connectGeneration += 1 + let mine = connectGeneration + do { + guard let stored = sites.get(siteId) else { + throw ConnectError(kind: .notPaired, help: "This phone has no record of that home.") + } + guard vault.isEnrolled else { + throw ConnectError(kind: .notEnrolled, help: "This device has no key for that home.") + } + // Read from the QR, never derived: a handle derived from the box + // key would be one household identifier good for years. + guard let secret = stored.rendezvousSecret?.byteArray else { + throw ConnectError(kind: .stalePairing, help: "This home was paired before this app could reach it privately.") + } + var wrapping = try vault.silentWrappingKey() + if wrapping == nil { + let prompted = try await vault.unlockWrappingKey(passkeys) + try vault.ensureLocalCopy(prompted) + wrapping = prompted + } + let device = try vault.deviceKey(wrapping!) + guard mine == connectGeneration, home?.site === site else { return } + + let box = stored.boxStaticKey.byteArray + let relay = RelayCarrier(url: relayURL, secret: secret, scheduler: scheduler, makeSocket: makeSocket) + let noise = NoiseCarrier( + inner: relay, + staticKey: device, + remoteStatic: box, + prologue: NoiseCarrier.prologue(boxStaticKey: box), + handshakePayload: stored.pairingCode?.byteArray ?? [], + scheduler: scheduler + ) + if site.connect(noise) { connectHelp = nil } + } catch let e as HelpfulError { + guard mine == connectGeneration else { return } + connectHelp = e.help + } catch { + guard mine == connectGeneration else { return } + connectHelp = "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR." + } + } + + /// Pull to refresh: a fresh stream in place, or a new carrier if there + /// was none. + public func refresh() async { + if isDemo { + demoBox?.tick() + return + } + if connectHelp != nil || site?.core.hasCarrier == false { + await connect() + } else { + site?.refresh() + } + } + + public func setVisible(_ visible: Bool) { + site?.setVisible(visible) + } + + public func networkOnline() { + site?.networkOnline() + } + + /// A pairing link arrived. It is an offer, never an instruction: anyone + /// can send a link, so it is shown with the box it names first. A link + /// to the box this phone already has is a leftover and is dropped. + public func offer(_ link: String) { + guard let enrollment = try? Enrollment.parse(scanned: link) else { + offeredLink = link + return + } + if let current = site?.siteId, SiteList.siteID(enrollment.boxStaticPublic) == current, sites.get(current) != nil { + return + } + offeredLink = link + } + + public func paired(_ siteId: String) { + offeredLink = nil + recovering = false + open(siteId) + } + + // MARK: The demo + + /// A simulated home in this process. Nothing is written and no passkey + /// is asked for; leaving it touches no saved home. + public func startDemo() { + home?.site.destroy() + let site = SiteModel(siteId: nil, build: build, ua: ua, scheduler: scheduler, files: nil, isDemo: true) + site.ceilingW = 11_000 + let box = SimulatedBox(scheduler: scheduler) + demoBox = box + home = HomeModels(site: site, files: nil, thisPhone: "Qm94T3du") + sealedCopy = nil + isDemo = true + recovering = false + offeredLink = nil + connectHelp = nil + site.start() + site.connect(LoopbackCarrier(box: box, scheduler: scheduler)) + } + + public func exitDemo() { + guard isDemo else { return } + home?.site.destroy() + home = nil + sealedCopy = nil + demoBox = nil + isDemo = false + if let current = sites.current() { open(current.siteId) } + } + + // MARK: Leaving + + /// Sign out on this phone: the stream stops first and the disk is + /// cleared second, so a reading landing mid-clear cannot write the home + /// back. The sealed copy at Sourceful stays; removing it is its own act. + public func leave() { + home?.site.destroy() + home = nil + sealedCopy = nil + vault.reset() + sites.clear() + files?.clear() + connectHelp = nil + leaveFailed = false + recovering = false + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift new file mode 100644 index 0000000..2b34642 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift @@ -0,0 +1,158 @@ +import Foundation + +/// Something a screen can put in front of a person. `code` is kept for the +/// few callers that branch on it; no caller renders it. +public struct BoxAPIError: Error, Equatable, HelpfulError { + public let code: String + public let help: String + /// The HTTP status when a handler answered. Nil when none ran. + public let status: Int? +} + +extension SiteModel { + /// Call the box's own API and get JSON back, with sentences. + /// + /// It runs the step-up: a write refused with E_NEEDS_STEP_UP is asked + /// again once, after the passkey ceremony, and never with the flag + /// before one has run. The box prices each route; this app carries no + /// list of them. + public func callBox(_ method: APIMethod, _ path: String, query: [String: String] = [:], body: JSON? = nil) async throws -> JSON? { + let res = try await sendBox(method, path, query: query, body: body, stepUp: false) + guard (200..<300).contains(res.status) else { + let code = Self.codeFromBody(res.body) + throw BoxAPIError(code: code ?? "HTTP_\(res.status)", help: Self.statusHelp(res.status, code), status: res.status) + } + if res.body.isEmpty { return nil } + do { + return try JSON(parsing: res.body) + } catch { + throw BoxAPIError(code: "E_BAD_BODY", help: "Your box sent something this app couldn't read.", status: res.status) + } + } + + private func sendBox(_ method: APIMethod, _ path: String, query: [String: String], body: JSON?, stepUp: Bool) async throws -> APIResponse { + do { + return try await api(APIRequest(method: method, path: path, query: query, body: body?.encoded(), stepUp: stepUp)) + } catch let refusal as BoxRefusal { + let code = refusal.detail.code + // The one place a step-up runs, and only once. A second refusal + // after a ceremony is the box saying something else. + if code == "E_NEEDS_STEP_UP", !stepUp { + let outcome = await self.stepUp?() ?? .unavailable + guard outcome == .done else { + throw BoxAPIError(code: code, help: outcome.help ?? "", status: nil) + } + return try await sendBox(method, path, query: query, body: body, stepUp: true) + } + throw BoxAPIError(code: code, help: Self.refusalHelp(code, refusal.detail.args), status: nil) + } catch { + // No code and no status: the wire went away or the deadline + // passed. Both heal on their own. + throw BoxAPIError(code: "E_NO_ANSWER", help: "Your box didn't answer. Still trying.", status: nil) + } + } + + static func codeFromBody(_ body: Bytes) -> String? { + guard !body.isEmpty, let code = (try? JSON(parsing: body))?["code"]?.string, code.hasPrefix("E_") else { return nil } + return code + } + + /// What happens now, for a refusal that never reached a handler. + public static func refusalHelp(_ code: String, _ args: [String: CBOR] = [:]) -> String { + switch code { + case "E_UNKNOWN_OP": return "Your box doesn't have that yet — it may be running older software." + case "E_UNAVAILABLE": + return args["reason"]?.string == "busy" ? "Your box is busy with something else. This will fill in shortly." : "Your box can't answer that right now. Still trying." + case "E_SCOPE_DENIED": return "Only the owner of this home can change that." + case "E_GRANT_REVOKED": return "Your access to this home was withdrawn by its owner." + case "E_USE_CMD": return "That has to be done from the controls on the home screen." + case "E_UNSUPPORTED_MEDIA", "E_WHOLE_DOCUMENT", "E_LOCAL_ONLY": return "That one is only available on your box's own page, from home." + case "E_RESPONSE_TOO_LARGE": return "That's more than we can send over your connection — narrow the range." + case "E_NEEDS_STEP_UP": return "Your box needs more proof than this phone can give. Do it on your box." + default: return "That didn't work. Nothing on your box has changed." + } + } + + /// What happens now, for a status a handler answered with. + public static func statusHelp(_ status: Int, _ code: String?) -> String { + if code == "E_LAST_OWNER_PROTECTED" { return "Someone has to own this home, so the last owner cannot be removed." } + switch status { + case 404: return "That's no longer on your box." + case 403: return "Your box refused that from this phone." + case 409: return "Something changed on your box first. Nothing here was applied." + case 503: return "Your box can't answer that yet. Still trying." + case 500...: return "Something went wrong on your box. Nothing has changed." + default: return "Your box couldn't do that." + } + } +} + +/// One rule for everything a screen asks the box for: ask while the session +/// streams, again every time it comes back, and again after a failure, with +/// a wait that doubles up to a quarter of an hour. A carrier that drops +/// settles every request at once, and nothing else would ever ask again. +@MainActor +public final class LiveAsk { + static let retryMs: Double = 30_000 + static let ceilingMs: Double = 15 * 60_000 + + private unowned let site: SiteModel + private let want: @MainActor () -> String? + private let ask: @MainActor () async throws -> Void + private var asked: String? + private var tries = 0 + private var waitMs = LiveAsk.retryMs + private var timer: Cancellable? + private var generation = 0 + private var token: Int? + + /// `want` names what to ask for; the same name is not asked twice, and + /// nil means there is nothing to ask. `ask` rejects to be asked again. + public init(site: SiteModel, want: @escaping @MainActor () -> String?, ask: @escaping @MainActor () async throws -> Void) { + self.site = site + self.want = want + self.ask = ask + token = site.observe { [weak self] in self?.evaluate() } + } + + public func stop() { + generation += 1 + timer?.cancel() + if let token { site.unobserve(token) } + token = nil + } + + /// Check whether to ask now. Called on every session change and tick, + /// and by the owner when what it wants changes. + public func evaluate() { + guard token != nil else { return } + guard site.session.phase == .streaming, let name = want() else { + // Whatever was asked is unanswered now; the next live moment asks + // again rather than waiting out a backoff. + asked = nil + waitMs = Self.retryMs + timer?.cancel() + return + } + let key = "\(name) \(tries)" + if asked == key { return } + asked = key + timer?.cancel() + generation += 1 + let mine = generation + Task { @MainActor [weak self] in + guard let self else { return } + do { + try await self.ask() + if mine == self.generation { self.waitMs = Self.retryMs } + } catch { + guard mine == self.generation else { return } + self.timer = self.site.scheduler.after(self.waitMs) { [weak self] in + self?.tries += 1 + self?.evaluate() + } + self.waitMs = min(self.waitMs * 2, Self.ceilingMs) + } + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift new file mode 100644 index 0000000..7477c8c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift @@ -0,0 +1,335 @@ +import Foundation +import Observation + +/// Who can see this home, through the box's own roster. Reading is a GET; +/// inviting and removing are writes, so the box asks for a ceremony first. +/// +/// An invite is the ordinary pairing code minted with the viewer role and a +/// shorter life. The role is never in the payload: a role its holder could +/// edit is not a role. +@Observable +@MainActor +public final class AccessModel: Activatable { + public struct Member: Equatable, Sendable, Identifiable { + /// The box's name for the row: the first eight characters of its key. + public let id: String + public let role: String + public let addedAtMs: Double? + public let lastSeenMs: Double? + public let isThisPhone: Bool + } + + public struct Invite: Equatable, Sendable { + /// The whole QR payload. It may only leave as a square for a camera. + public let url: String + public let role: String + public let expiresAtMs: Double + } + + public enum Busy: Sendable { case none, inviting, revoking } + + public private(set) var members: [Member] = [] + public private(set) var loading = false + public private(set) var loaded = false + public private(set) var error: String? + public private(set) var invite: Invite? + public private(set) var busy: Busy = .none + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private let thisPhone: String? + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var token = 0 + + public init(site: SiteModel, thisPhone: String?) { + self.site = site + self.thisPhone = thisPhone + } + + public var canManage: Bool { site.canConfigure && site.hasPassthrough } + public var owners: Int { members.filter { $0.role == Contract.roleOwner }.count } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.canManage else { return nil } + return "members" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + func load() async throws { + token += 1 + let mine = token + loading = true + error = nil + defer { if mine == token { loading = false } } + do { + let wire = try await site.callBox(.get, "/api/app-link/devices") + guard mine == token else { return } + members = (wire?["devices"]?.array ?? []).map { d in + let id = d["id"]?.string ?? "" + // A row from a box before roles is an owner. + let role = d["role"]?.string.flatMap { $0.isEmpty ? nil : $0 } ?? Contract.roleOwner + return Member(id: id, role: role, addedAtMs: d["added_at_ms"]?.number, lastSeenMs: d["last_seen_ms"]?.number, isThisPhone: id == thisPhone) + } + loaded = true + } catch { + guard mine == token else { return } + self.error = (error as? BoxAPIError)?.help ?? "Your box didn't answer. Still trying." + throw error + } + } + + /// Mint a code that admits a viewer. The role goes in the body, where the + /// box reads it, and an answer naming any other role is refused here. + @discardableResult + public func inviteViewer() async -> Bool { + busy = .inviting + error = nil + defer { busy = .none } + do { + let wire = try await site.callBox(.post, "/api/app-link/pairing", body: ["role": .string(Contract.roleViewer)]) + guard wire?["role"]?.string == Contract.roleViewer, let url = wire?["url"]?.string else { + throw BoxAPIError(code: "E_BAD_BODY", help: "Your box didn't make a view-only invitation. Nothing has been shared.", status: nil) + } + invite = Invite(url: url, role: Contract.roleViewer, expiresAtMs: wire?["expires_at_ms"]?.number ?? 0) + return true + } catch { + self.error = (error as? BoxAPIError)?.help ?? "That did not work. Nothing has changed." + return false + } + } + + public func dismissInvite() { invite = nil } + + /// Withdraw a phone's access. The row goes on the box's answer alone. + @discardableResult + public func revoke(_ id: String) async -> Bool { + busy = .revoking + error = nil + defer { busy = .none } + do { + _ = try await site.callBox(.delete, "/api/app-link/devices/\(LoadpointsModel.escape(id))") + members.removeAll { $0.id == id } + return true + } catch let e as BoxAPIError where e.status == 404 { + // Already gone is what was asked for. + members.removeAll { $0.id == id } + return true + } catch { + self.error = (error as? BoxAPIError)?.help ?? "That did not work. Nothing has changed." + return false + } + } + + public static func roleLabel(_ role: String) -> String { + Contract.roleLabels[role] ?? role + } + + public func seen(_ ms: Double?) -> String { + guard let ms, ms > 0 else { return "not seen yet" } + let since = site.nowMs - ms + if since < 120_000 { return "here now" } + if since < 3_600_000 { return "\(Int((since / 60_000).rounded())) min ago" } + if since < 86_400_000 { return "\(Int((since / 3_600_000).rounded())) h ago" } + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("dMMM") + return f.string(from: Date(timeIntervalSince1970: ms / 1000)) + } +} + +/// What the box sends when something at home matters, and what it has sent. +/// +/// The rules are the box's document and govern every phone it can reach. +/// Each entry goes out whole on a save, flipped only on `enabled`, because +/// the box replaces a rule wholesale and a partial one would wipe the +/// thresholds it seeded. +@Observable +@MainActor +public final class NotifyModel: Activatable { + /// The kinds the rules govern. box.unreachable is not one: the box cannot + /// gate a message about its own absence. + public static let ruleKinds = [ + "charging.connected", "charging.session_complete", "charging.interrupted", + "update.installed", "driver.offline", "fuse.over_limit", + ] + + public static let labels: [String: String] = [ + "charging.connected": "When the car is plugged in", + "charging.session_complete": "When the car finishes charging", + "charging.interrupted": "If charging stops before it is done", + "update.installed": "When your box updates itself", + "driver.offline": "If a device goes quiet", + "fuse.over_limit": "If the house draws more than the fuse allows", + "box.unreachable": "If your box goes out of reach", + ] + + public struct Sent: Equatable, Sendable, Identifiable { + public let title: String + public let atMs: Double? + public var id: String { "\(title)\(atMs ?? 0)" } + } + + public enum Busy: Sendable { case none, saving, testing } + + public private(set) var boxEnabled = false + public private(set) var rules: [String: Bool] = [:] + public private(set) var availableKinds: [String] = [] + public private(set) var history: [Sent] = [] + public private(set) var oldBox = false + public private(set) var busy: Busy = .none + public private(set) var error: String? + public private(set) var testSent = false + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var doc: [JSON] = [] + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + + public init(site: SiteModel) { + self.site = site + } + + public var canManage: Bool { site.canConfigure && site.hasPassthrough } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.canManage, !self.oldBox else { return nil } + return "push-history" + }, ask: { [weak self] in + try await self?.loadHistory() + try await self?.loadRules() + }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + private func apply(_ wire: JSON?) { + guard let events = wire?["events"]?.array else { return } + doc = events.filter { $0["type"]?.string != nil && $0["enabled"]?.bool != nil } + boxEnabled = wire?["enabled"]?.bool == true + availableKinds = doc.compactMap { $0["type"]?.string } + var next = [String: Bool]() + for k in Self.ruleKinds { next[k] = false } + for r in doc { if let t = r["type"]?.string, next[t] != nil { next[t] = r["enabled"]?.bool == true } } + rules = next + } + + func loadRules() async throws { + do { + apply(try await site.callBox(.get, "/api/notifications/rules")) + } catch let e as BoxAPIError where e.code == "E_UNKNOWN_OP" { + oldBox = true + } + } + + func loadHistory() async throws { + do { + let wire = try await site.callBox(.get, "/api/notifications/history") + history = (wire?["events"]?.array ?? []).map { Sent(title: $0["title"]?.string ?? "", atMs: $0["at_ms"]?.number) } + } catch let e as BoxAPIError where e.code == "E_UNKNOWN_OP" { + oldBox = true + } + } + + /// One PUT for the whole set, so one ceremony. Kinds the box's document + /// does not carry are not sent: an older box must not meet a kind it + /// would refuse by name. + @discardableResult + public func save(_ next: [String: Bool]) async -> Bool { + guard busy == .none else { return false } + busy = .saving + error = nil + testSent = false + defer { busy = .none } + do { + if doc.isEmpty { try await loadRules() } + guard !doc.isEmpty else { + error = "Your box didn't answer. Nothing has changed." + return false + } + let events: [JSON] = doc.compactMap { rule in + guard let t = rule["type"]?.string, Self.ruleKinds.contains(t), let on = next[t] else { return nil } + return rule.setting("enabled", .bool(on)) + } + apply(try await site.callBox(.put, "/api/notifications/rules", body: ["enabled": true, "events": .array(events)])) + return true + } catch { + fail(error) + return false + } + } + + /// One real push through the whole pipe, to the phones the box can reach. + public func sendTest() async { + guard busy == .none else { return } + busy = .testing + error = nil + testSent = false + defer { busy = .none } + do { + _ = try await site.callBox(.post, "/api/notifications/test") + testSent = true + } catch { + fail(error) + } + } + + private func fail(_ err: Error) { + if let e = err as? BoxAPIError, e.code == "E_UNKNOWN_OP" { oldBox = true } + error = (err as? BoxAPIError)?.help ?? "That didn't work. Nothing has changed." + } + + public func when(_ ms: Double?) -> String { + guard let ms, ms > 0 else { return "" } + let since = site.nowMs - ms + if since < 3_600_000 { return "\(max(1, Int((since / 60_000).rounded()))) min ago" } + if since < 86_400_000 { return "\(Int((since / 3_600_000).rounded())) h ago" } + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("dMMM") + return f.string(from: Date(timeIntervalSince1970: ms / 1000)) + } +} + +/// Ask the box to come back up: a recovery act, owner only, confirmed. +@Observable +@MainActor +public final class RestartModel { + public enum Stage: Sendable { case idle, confirming, restarting } + public var stage: Stage = .idle + public private(set) var error: String? + @ObservationIgnored private unowned let site: SiteModel + + public init(site: SiteModel) { + self.site = site + } + + public var canAsk: Bool { site.heardFromBox && site.canConfigure && site.hasPassthrough } + + public func restart() async { + guard stage != .restarting else { return } + error = nil + stage = .restarting + do { + _ = try await site.callBox(.post, "/api/restart") + } catch { + stage = .idle + self.error = (error as? BoxAPIError)?.help ?? "That didn't work. Nothing has changed." + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift b/appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift new file mode 100644 index 0000000..2f055a5 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift @@ -0,0 +1,288 @@ +import Foundation +import Observation + +/// What the house used, made, bought and sold, day by day, from +/// GET /api/energy/daily. Integer watt-hours throughout, rounded once at the +/// door, so the total over the chart is the sum of the bars under it. +@Observable +@MainActor +public final class EnergyModel: Activatable { + public enum Range: String, CaseIterable, Sendable, Identifiable { + case today, week = "7d", month = "30d" + public var id: String { rawValue } + public var label: String { self == .today ? "Today" : self == .week ? "7 days" : "30 days" } + public var title: String { self == .today ? "Today" : self == .week ? "Last 7 days" : "Last 30 days" } + public var days: Int { self == .today ? 1 : self == .week ? 7 : 30 } + } + + public struct Day: Equatable, Sendable, Identifiable { + public let day: String + public let loadWh: Double + public let pvWh: Double + public let importWh: Double + public let exportWh: Double + public let batChargedWh: Double + public let batDischargedWh: Double + public var id: String { day } + } + + public struct Totals: Equatable, Sendable { + public var loadWh: Double = 0 + public var pvWh: Double = 0 + public var importWh: Double = 0 + public var exportWh: Double = 0 + public var batChargedWh: Double = 0 + public var batDischargedWh: Double = 0 + } + + public private(set) var range: Range = .week + /// Oldest first, today last. + public private(set) var days: [Day] = [] + public private(set) var loading = false + /// Whether the box has answered for this period. A period not yet read + /// is not a period with nothing in it. + public private(set) var loaded = false + public private(set) var error: String? + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var token = 0 + @ObservationIgnored private let calendar: Calendar + + public init(site: SiteModel, calendar: Calendar = .current) { + self.site = site + self.calendar = calendar + } + + public var totals: Totals { + days.reduce(into: Totals()) { t, d in + t.loadWh += d.loadWh; t.pvWh += d.pvWh; t.importWh += d.importWh + t.exportWh += d.exportWh; t.batChargedWh += d.batChargedWh; t.batDischargedWh += d.batDischargedWh + } + } + + /// "As much as", never "of": the sun and the kettle rarely coincide. + public var solarSharePct: Int? { + let t = totals + guard t.loadWh > 0, t.pvWh > 0 else { return nil } + return Int((t.pvWh / t.loadWh * 100).rounded()) + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.site.documentVisible else { return nil } + guard self.site.hasPassthrough else { + if !self.days.isEmpty { self.days = [] } + return nil + } + // Today's column fills all day, so the hour is in the name. + let now = Date(timeIntervalSince1970: self.site.nowMs / 1000) + return "energy \(self.range.rawValue) \(self.calendar.startOfDay(for: now).timeIntervalSince1970) \(self.calendar.component(.hour, from: now))" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + public func select(_ next: Range) { + guard next != range else { return } + token += 1 + range = next + days = [] + loaded = false + loading = false + error = nil + ask?.evaluate() + } + + func load() async throws { + token += 1 + let mine = token + loading = true + error = nil + defer { if mine == token { loading = false } } + do { + let wire = try await site.callBox(.get, "/api/energy/daily", query: ["days": String(range.days)]) + guard mine == token else { return } + days = (wire?["days"]?.array ?? []).map { row in + Day( + day: row["day"]?.string ?? "", + loadWh: EnergyFormat.wholeWh(row["load_wh"]?.number), + pvWh: EnergyFormat.wholeWh(row["pv_wh"]?.number), + importWh: EnergyFormat.wholeWh(row["import_wh"]?.number), + exportWh: EnergyFormat.wholeWh(row["export_wh"]?.number), + batChargedWh: EnergyFormat.wholeWh(row["bat_charged_wh"]?.number), + batDischargedWh: EnergyFormat.wholeWh(row["bat_discharged_wh"]?.number) + ) + } + loaded = true + } catch { + guard mine == token else { return } + if let e = error as? BoxAPIError, days.isEmpty { + self.error = e.help + } else { + self.error = "Not up to date — your box is out of reach" + } + throw error + } + } +} + +/// Power over time: cache first, box second, then the difference only. +@Observable +@MainActor +public final class HistoryModel: Activatable { + public enum Range: String, CaseIterable, Sendable, Identifiable { + case day = "24h", week = "7d", month = "30d", year = "1y" + public var id: String { rawValue } + public var label: String { + switch self { + case .day: return "24 h" + case .week: return "7 d" + case .month: return "30 d" + case .year: return "1 y" + } + } + var spanMs: Double { + switch self { + case .day: return 86_400_000 + case .week: return 7 * 86_400_000 + case .month: return 30 * 86_400_000 + case .year: return 365 * 86_400_000 + } + } + var res: Resolution { self == .year ? .hour : .fiveMinutes } + } + + /// Names from the registry, in the order they are drawn. + public static let series = ["grid_w", "pv_w", "battery_w", "load_w"] + static let maxPoints = 1500 + + public private(set) var range: Range = .day + public private(set) var frame: HistoryGeometry.Frame? + public private(set) var resActual: Resolution? + public private(set) var gaps: [HistGap] = [] + public private(set) var loading = false + public private(set) var loaded = false + public private(set) var error: String? + /// The sample under the finger, as an index into `frame`. + public var cursor: Int? + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private let tiles: TileCache + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var token = 0 + + public init(site: SiteModel, tiles: TileCache) { + self.site = site + self.tiles = tiles + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.site.documentVisible else { return nil } + // The window ends now, so which five minutes "now" is belongs + // in the name; otherwise the right edge says "now" all day. + return "\(self.range.rawValue) \(Int(self.site.nowMs / 300_000))" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + public func select(_ next: Range) { + // The cursor is an index into this frame, and the same index in + // another range is another moment. + if next != range { cursor = nil } + range = next + ask?.evaluate() + } + + /// The time under the cursor. + public var cursorAtMs: Double? { + guard let frame, let cursor else { return nil } + return frame.time(at: cursor) + } + + /// The value under the cursor, or the latest. + public func value(_ name: String) -> Double? { + guard let frame, frame.points > 0 else { return nil } + return frame.value(name, at: cursor ?? frame.points - 1).map(Double.init) + } + + public var missingMs: Double { gaps.reduce(0) { $0 + ($1.toMs - $1.fromMs) } } + + func load() async throws { + token += 1 + let mine = token + let names = Self.series + let to = site.scheduler.nowMs + let from = to - range.spanMs + let plan = HistoryGeometry.plan(range.res, fromMs: from, toMs: to, maxPoints: Self.maxPoints) + let cached = tiles.get(plan.tiles.map(\.tileId)) + var have = cached + func show() { frame = HistoryGeometry.clip(HistoryGeometry.assemble(plan, names: names, tiles: have), fromMs: from, toMs: to) } + // Whatever is on disk goes up now. + if !cached.isEmpty { show() } + + loading = true + error = nil + defer { if mine == token { loading = false } } + do { + let end = try await site.history( + HistQuery(series: names, res: range.res, fromMs: from, toMs: to, have: cached.values.map { ($0.tileId, $0.etag) }, maxPoints: Self.maxPoints) + ) { [weak self] chunk in + guard let self, mine == self.token else { return } + have[chunk.tileId] = chunk + self.tiles.put(chunk) + } + guard mine == token else { return } + show() + resActual = end.resActual + gaps = end.gaps + loaded = true + tiles.flush(nowMs: to) + } catch { + guard mine == token else { return } + if !have.isEmpty { show() } + // Both sentences are about the wire, never about the house: an + // answer that did not arrive says nothing about what was recorded. + self.error = have.isEmpty ? "Nothing through yet — your box is out of reach" : "Not up to date — your box is out of reach" + throw error + } + } + + /// "5 min trend · 5 min box readings" or "One point every hour". + public var note: String { + if let error { return error } + if loading { return "Reading your box…" } + guard let frame, resActual != nil else { return "" } + let step = frame.stepMs + var text: String + if step < 3_600_000 { + text = "One point every \(Int((step / 60_000).rounded())) minutes, from your box" + } else { + let hours = Int((step / 3_600_000).rounded()) + text = "One point every \(hours == 1 ? "hour" : hours == 24 ? "day" : "\(hours) hours"), from your box" + } + if missingMs > 0 { + let m = missingMs + text += " · \(m < 3_600_000 ? "\(max(1, Int((m / 60_000).rounded()))) min" : "\(Int((m / 3_600_000).rounded())) h") not recorded" + } + return text + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift new file mode 100644 index 0000000..a8b17ea --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -0,0 +1,347 @@ +import Foundation +import Observation + +/// The charger sheet. Everything on it is a fact the box served; the +/// controls express intent with an expiry, and the box decides. After any +/// outcome the charger is read again, because the box's account is the +/// truth to repaint from. +@Observable +@MainActor +public final class LoadpointsModel: Activatable { + public enum Control: Sendable { case hold, boost, soc, surplus } + public enum Outcome: Sendable { case hold, release, pause, boost, unboost, soc, surplusOn, surplusOff } + + public enum Command: Sendable { + case idle + case sending(Control) + case applied(Control, Outcome) + case unconfirmed(Control) + case failed(Control, String) + + public var isSending: Bool { if case .sending = self { return true }; return false } + } + + public private(set) var points: [Loadpoint] = [] + public private(set) var windows: [String: [Loadpoint.Window]] = [:] + public private(set) var planMissing = false + public private(set) var planPending = false + public private(set) var planOutdated = false + public private(set) var loading = false + /// Whether the box has ever answered. No answer is not an empty bay. + public private(set) var loaded = false + public private(set) var readAtMs: Double? + public private(set) var error: String? + public private(set) var command: Command = .idle + public private(set) var commandLoadpointID: String? + /// Applied choices stay on screen until a later read confirms them. + public private(set) var acceptedSoc: [String: Double] = [:] + public private(set) var acceptedSurplus: [String: Bool] = [:] + + // The sheet's drafts, held here so a reread never snaps a control from + // under a finger. + public var ampsDraft: [String: Int] = [:] + public var socDraft: [String: Double] = [:] + public var surplusDraft: [String: Bool] = [:] + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private let charging: ChargingWatch? + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var token = 0 + @ObservationIgnored private var settle: Cancellable? + @ObservationIgnored private var inlinePlan = false + @ObservationIgnored private var reading: Task? + + public init(site: SiteModel, charging: ChargingWatch?) { + self.site = site + self.charging = charging + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.site.documentVisible, self.site.hasPassthrough else { return nil } + return "loadpoints \(Int(self.site.nowMs / 5_000))" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + /// Out of date: the read failed, the stream stopped, or it is old. + public var stale: Bool { + error != nil || site.session.phase != .streaming || (readAtMs.map { site.nowMs - $0 >= 15_000 } ?? false) + } + + // MARK: Reads + + public func loadChargers() async throws { + if let reading { + try await reading.value + return + } + let task = Task { @MainActor in try await self.readChargers() } + reading = task + defer { reading = nil } + try await task.value + } + + private func readChargers() async throws { + token += 1 + let mine = token + let socChoices = acceptedSoc + let surplusChoices = acceptedSurplus + loading = true + defer { if mine == token { loading = false } } + do { + let wire = try await site.callBox(.get, "/api/loadpoints") + guard mine == token else { return } + let list = wire?["loadpoints"]?.array ?? [] + points = list.map(Loadpoint.init) + inlinePlan = !points.isEmpty && points.allSatisfy(\.inlinePlan) + if inlinePlan { + planPending = points.contains { $0.planPending } + planOutdated = points.contains { $0.planOutdated } + windows = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0.planWindows) }) + planMissing = false + } + // A read that started before a command cannot confirm its choice. + acceptedSoc = acceptedSoc.filter { socChoices[$0.key] != $0.value } + acceptedSurplus = acceptedSurplus.filter { surplusChoices[$0.key] != $0.value } + loaded = true + readAtMs = site.scheduler.nowMs + error = nil + } catch { + guard mine == token else { return } + if let e = error as? BoxAPIError, points.isEmpty { + self.error = e.help + } else { + self.error = "Not up to date — your box is out of reach" + } + throw error + } + } + + /// The chargers, then the plan's windows for a box that reports them + /// separately. A failed plan read is a note, not a failure. + public func load() async throws { + try await loadChargers() + if inlinePlan { return } + let mine = token + do { + let wire = try await site.callBox(.get, "/api/mpc/plan") + guard mine == token else { return } + planPending = wire?["meta"]?["replanning"]?.bool == true + planOutdated = wire?["meta"]?["outdated"]?.bool == true + let actions = wire?["plan"]?["actions"]?.array ?? [] + var out = [String: [Loadpoint.Window]]() + for lp in points { + out[lp.id] = planPending || planOutdated || lp.planPending || lp.planOutdated ? [] : Self.chargeWindows(actions, loadpointID: lp.id) + } + windows = out + planMissing = false + } catch { + guard mine == token else { return } + planMissing = true + } + } + + /// Adjacent charging slots fold into one window; a person asks when it + /// will charge, not when the reason changes. + static func chargeWindows(_ actions: [JSON], loadpointID: String) -> [Loadpoint.Window] { + var out = [Loadpoint.Window]() + for a in actions { + guard let start = a["slot_start_ms"]?.number, let len = a["slot_len_min"]?.number, + let w = a["loadpoint_power_w"]?[loadpointID]?.number, w > 0 else { continue } + let end = start + len * 60_000 + if var last = out.last, start <= last.toMs { + last.toMs = end + last.peakW = max(last.peakW ?? 0, w) + out[out.count - 1] = last + } else { + out.append(Loadpoint.Window(fromMs: start, toMs: end, peakW: w, energyWh: nil)) + } + } + return out + } + + // MARK: Commands + + /// Charge now: a hold at a chosen current until Stop or an unplug. + public func chargeNow(_ lp: Loadpoint, amps: Int) async { + await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("power_w", .number(EVText.watts(lp, amps: amps))), ("hold_s", .int(0)), ("phase_mode", .text(EVText.current(lp).phases == 1 ? "1p" : "3p"))], .hold, .hold, CommandText.help) + } + + public func pauseCharging(_ lp: Loadpoint) async { + await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("power_w", .int(0)), ("hold_s", .int(0))], .hold, .pause, CommandText.help) + } + + /// Release the hold; the plan takes back over. + public func stopCharging(_ lp: Loadpoint) async { + await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("clear", .bool(true))], .hold, .release, CommandText.help) + } + + /// Let the house battery push the car for a bounded while. + public func boost(_ lp: Loadpoint, reservePct: Int, durationS: Int) async { + await send(Contract.opLoadpointBoost, lp.id, [("id", .text(lp.id)), ("min_battery_soc_pct", .int(reservePct)), ("duration_s", .int(durationS))], .boost, .boost, CommandText.boostHelp) + } + + public func stopBoost(_ lp: Loadpoint) async { + await send(Contract.opLoadpointBoost, lp.id, [("id", .text(lp.id)), ("cancel", .bool(true))], .boost, .unboost, CommandText.boostHelp) + } + + /// Whole percent from the slider, a fraction to the box. + public func setSoc(_ lp: Loadpoint, pct: Double) async { + await send(Contract.opLoadpointSocSet, lp.id, [("id", .text(lp.id)), ("soc", .number(pct / 100))], .soc, .soc, CommandText.socHelp) + } + + public func setSurplusOnly(_ lp: Loadpoint, _ on: Bool) async { + await send(Contract.opLoadpointSurplusOnlySet, lp.id, [("id", .text(lp.id)), ("surplus_only", .bool(on))], .surplus, on ? .surplusOn : .surplusOff, CommandText.help) + } + + private func send(_ op: String, _ id: String, _ args: [(String, CBOR)], _ of: Control, _ did: Outcome, _ help: (CmdResult) -> String) async { + if command.isSending { return } + settle?.cancel() + commandLoadpointID = id + command = .sending(of) + do { + let result = try await site.command(op, args: args) + switch result.state { + case .applied: + command = .applied(of, did) + if of == .soc, let soc = args.first(where: { $0.0 == "soc" })?.1.double { acceptedSoc[id] = (soc * 100).rounded() } + if of == .surplus, let on = args.first(where: { $0.0 == "surplus_only" })?.1.bool { acceptedSurplus[id] = on } + case .unconfirmed: + command = .unconfirmed(of) + default: + command = .failed(of, help(result)) + } + } catch let e as CommandError { + command = .failed(of, e.help) + } catch { + command = .failed(of, "FTW did not confirm the request. Reading its current state…") + } + if case .applied = command { + settle = site.scheduler.after(6_000) { [weak self] in self?.command = .idle } + } + try? await loadChargers() + charging?.refresh() + } + + // MARK: Configure routes: schedule, vehicle + + /// Save a goal in one PUT, so it costs one ceremony at most. + public func saveSchedule(_ lp: Loadpoint, socPct: Double, hour: Int, minute: Int, recurring: Bool, days: Int, surplusUnlockPct: Double, calendar: Calendar = .current) async throws { + guard let minUTC = EVText.minuteUTC(hour: hour, minute: minute, calendar: calendar) else { + throw BoxAPIError(code: "E_BAD_TIME", help: "Choose a valid ready time.", status: nil) + } + guard !recurring || days != 0 else { throw BoxAPIError(code: "E_NO_DAYS", help: "Choose at least one day.", status: nil) } + guard (10...100).contains(socPct) else { throw BoxAPIError(code: "E_BAD_SOC", help: "Choose a charge target from 10 to 100 %.", status: nil) } + guard (0...100).contains(surplusUnlockPct) else { throw BoxAPIError(code: "E_BAD_SOC", help: "Choose a home battery level from 1 to 100 %.", status: nil) } + _ = try await site.callBox(.put, "/api/loadpoints/\(Self.escape(lp.id))/schedule", body: [ + "soc": .number(socPct / 100), + "time_of_day_min_utc": .number(Double(minUTC)), + "recurring": .bool(recurring), + "days": .number(Double(!recurring || days == 0x7f ? 0 : days & 0x7f)), + "surplus_unlock_bat_soc": .number(surplusUnlockPct / 100), + ]) + try? await loadChargers() + charging?.refresh() + } + + public func removeSchedule(_ lp: Loadpoint) async throws { + _ = try await site.callBox(.delete, "/api/loadpoints/\(Self.escape(lp.id))/schedule") + // The DELETE confirms the removal even if the reread fails. + if let i = points.firstIndex(where: { $0.id == lp.id }) { + points[i].schedule = nil + points[i].targetSocPct = nil + } + try? await loadChargers() + charging?.refresh() + } + + /// The car's usable battery size, 1 to 300 kWh. + public func setCapacity(_ lp: Loadpoint, kwh: Double) async throws -> String { + guard kwh.isFinite, (1...300).contains(kwh) else { + throw BoxAPIError(code: "E_BAD_CAPACITY", help: "Enter the usable battery size from 1 to 300 kWh.", status: nil) + } + let wh = (kwh * 1000).rounded() + _ = try await site.callBox(.post, "/api/loadpoints/\(Self.escape(lp.id))/vehicle", body: ["capacity_wh": .number(wh)]) + do { + try await load() + } catch { + return "Battery size saved. Current charging status is unavailable." + } + charging?.refresh() + if let active = points.first(where: { $0.id == lp.id })?.vehicleCapacityWh, active != wh { + return "Saved as the usual battery size. This session uses \(EnergyFormat.short(active / 1000)) kWh." + } + return "Battery size saved. The plan uses this size for its estimates." + } + + static func escape(_ id: String) -> String { + id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))) ?? id + } + + // MARK: What the sheet shows + + public func socShown(_ lp: Loadpoint) -> Double { + socDraft[lp.id] ?? acceptedSoc[lp.id] ?? lp.socPct ?? EVText.socDefaultPct + } + + public func surplusShown(_ lp: Loadpoint) -> Bool { + surplusDraft[lp.id] ?? acceptedSurplus[lp.id] ?? lp.surplusOnly + } + + /// The slider's amps: the draft, else the running hold, else the ceiling. + public func ampsShown(_ lp: Loadpoint) -> Int { + let range = EVText.current(lp) + let held: Int? = lp.manualActive && !lp.manualRestoreUnconfirmed && !EVText.isPaused(lp) ? lp.manualChargeW.map { EVText.amps(lp, watts: $0) } : nil + return min(range.maxA, max(range.minA, ampsDraft[lp.id] ?? held ?? range.maxA)) + } + + /// One sentence per thing the box did, under the control that asked. + public func outcomeSentence(_ did: Outcome, _ lp: Loadpoint) -> String { + switch did { + case .hold: return stale ? "Waiting for current charger status." : lp.manual != nil ? EVText.status(lp) : "FTW received your charge request. Waiting for charger status." + case .release: return "The plan decides when to charge." + case .pause: return EVText.status(lp) + case .boost: return "Battery boost selected. The power readings show what the house battery supplies." + case .unboost: return "Boost stopped — the plan decides again." + case .soc: + if let v = acceptedSoc[lp.id] { return "Charge level accepted: \(Int(v)) %. Waiting for updated charging status." } + let pct = Int(lp.socPct ?? socShown(lp)) + let base = lp.socRetention == "error" ? "Charge level updated: \(pct) %. It could not be saved for a box restart." : "Charge level saved: \(pct) %." + let tail = lp.schedule == nil && !lp.manualActive && !lp.surplusOnly ? " Set a ready time, or choose Charge now." : planMissing ? " Charging times are not available yet." : "" + return base + tail + case .surplusOn: return acceptedSurplus[lp.id] != nil ? "Solar rule accepted. Waiting for updated charging status." : "Solar rule saved. The plan uses spare solar only." + case .surplusOff: return acceptedSurplus[lp.id] != nil ? "Solar rule accepted. Waiting for updated charging status." : "Solar rule saved. The plan may use grid power again." + } + } + + /// The outcome line under one control, or nil. + public func outcome(for control: Control, _ lp: Loadpoint) -> String? { + guard commandLoadpointID == lp.id else { + if control == .soc, acceptedSoc[lp.id] != nil { return outcomeSentence(.soc, lp) } + if control == .surplus, acceptedSurplus[lp.id] != nil { return "Solar rule accepted. Waiting for updated charging status." } + return nil + } + switch command { + case .applied(let of, let did) where of == control && (of != .hold || did == .release): + return outcomeSentence(did, lp) + case .unconfirmed(let of) where of == control: + return "FTW received the request. Its result is not confirmed yet." + case .failed(let of, let help) where of == control: + return help + default: + if control == .soc, acceptedSoc[lp.id] != nil { return outcomeSentence(.soc, lp) } + if control == .surplus, acceptedSurplus[lp.id] != nil { return "Solar rule accepted. Waiting for updated charging status." } + return nil + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift new file mode 100644 index 0000000..fa38b09 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -0,0 +1,295 @@ +import Foundation +import Observation + +/// A screen that is on screen. Models only ask the box for what someone can +/// see; a hidden tab keeps its state and stops asking. +@MainActor +public protocol Activatable: AnyObject { + func activate() + func deactivate() +} + +/// Repeats a read on a fixed period while `shouldRun` holds, and reports a +/// retained answer as out of date once it is older than `maxAgeMs`. +@MainActor +final class Poller { + private let scheduler: Scheduler + private let periodMs: Double + private let shouldRun: @MainActor () -> Bool + private let work: @MainActor () async -> Void + private var timer: Cancellable? + private var running = false + private var busy = false + private var again = false + + init(scheduler: Scheduler, periodMs: Double, shouldRun: @escaping @MainActor () -> Bool, work: @escaping @MainActor () async -> Void) { + self.scheduler = scheduler + self.periodMs = periodMs + self.shouldRun = shouldRun + self.work = work + } + + func start() { + guard !running else { return } + running = true + tick() + } + + func stop() { + running = false + timer?.cancel() + } + + /// Read now, rather than at the next period. A confirmed action should + /// reach the screen before its next timer. + func refresh() { + if busy { again = true } else if running { tick() } + } + + private func tick() { + timer?.cancel() + guard running else { return } + busy = true + again = false + Task { @MainActor [weak self] in + guard let self else { return } + if self.shouldRun() { await self.work() } + self.busy = false + guard self.running else { return } + self.timer = self.scheduler.after(self.again ? 0 : self.periodMs) { [weak self] in self?.tick() } + } + } +} + +/// The dashboard's own live document, GET /api/status, every two seconds +/// while the Now screen is up. Per-driver nodes, energy today, the fuse. +@Observable +@MainActor +public final class StatusWatch: Activatable { + public static let maxAgeMs: Double = 15_000 + + public private(set) var status: JSON? + public private(set) var receivedAtMs: Double? + public private(set) var fresh = false + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var poller: Poller! + @ObservationIgnored private var expiry: Cancellable? + @ObservationIgnored private var active = 0 + + public init(site: SiteModel) { + self.site = site + poller = Poller(scheduler: site.scheduler, periodMs: 2_000, shouldRun: { [weak self] in + guard let self else { return false } + return self.site.documentVisible && self.site.session.phase == .streaming && self.site.hasPassthrough + }, work: { [weak self] in await self?.read() }) + } + + public func activate() { + active += 1 + if active == 1 { poller.start() } + } + + public func deactivate() { + active = max(0, active - 1) + if active == 0 { + poller.stop() + fresh = false + } + } + + private func read() async { + do { + let started = site.scheduler.nowMs + guard let json = try await site.callBox(.get, "/api/status"), json.object != nil else { throw BoxAPIError(code: "E_BAD_BODY", help: "", status: nil) } + let took = site.scheduler.nowMs - started + status = json + receivedAtMs = site.scheduler.nowMs + fresh = took < Self.maxAgeMs && site.session.phase == .streaming + expiry?.cancel() + expiry = site.scheduler.after(max(0, Self.maxAgeMs - took)) { [weak self] in self?.fresh = false } + } catch { + expiry?.cancel() + fresh = false + } + } +} + +/// The chargers, read every five seconds for the Now diagram and the car +/// notice. Hidden phones stop polling. +@Observable +@MainActor +public final class ChargingWatch: Activatable { + public private(set) var points: [Loadpoint] = [] + public private(set) var fresh = false + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var poller: Poller! + @ObservationIgnored private var expiry: Cancellable? + @ObservationIgnored private var active = 0 + + public init(site: SiteModel) { + self.site = site + poller = Poller(scheduler: site.scheduler, periodMs: 5_000, shouldRun: { [weak self] in + guard let self else { return false } + if !(self.site.documentVisible && self.site.session.phase == .streaming && self.site.hasPassthrough) { + self.fresh = false + return false + } + return true + }, work: { [weak self] in await self?.read() }) + } + + public func activate() { + active += 1 + if active == 1 { poller.start() } + } + + public func deactivate() { + active = max(0, active - 1) + if active == 0 { + poller.stop() + fresh = false + } + } + + public func refresh() { poller.refresh() } + + /// Charger watts for the diagram, zero unless fresh. + public var chargeW: Double { fresh ? Flow.loadpointChargeW(points) : 0 } + + private func read() async { + do { + guard let list = try await site.callBox(.get, "/api/loadpoints")?["loadpoints"]?.array else { + throw BoxAPIError(code: "E_BAD_BODY", help: "", status: nil) + } + let next = list.map(Loadpoint.init).map { lp -> Loadpoint in + // A charger that went out of touch keeps its last known cable. + var lp = lp + if lp.charger?.available == false, points.first(where: { $0.id == lp.id })?.pluggedIn == true { lp.pluggedIn = true } + return lp + } + points = next + fresh = site.documentVisible && site.session.phase == .streaming + expiry?.cancel() + expiry = site.scheduler.after(15_000) { [weak self] in self?.fresh = false } + } catch { + fresh = false + } + } +} + +/// Prices across today and tomorrow, from local midnight, asked for again +/// when the day turns and when tomorrow's rates publish. +@Observable +@MainActor +public final class PriceModel: Activatable { + static let horizonMs: Double = 48 * 3_600_000 + static let publishHour = 14 + + public private(set) var prices: Prices? + public private(set) var fromMs: Double = 0 + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var generation = 0 + @ObservationIgnored private let calendar: Calendar + + public init(site: SiteModel, calendar: Calendar = .current) { + self.site = site + self.calendar = calendar + } + + public var currency: String { prices?.currency ?? "SEK" } + public var hasHole: Bool { prices.map { PriceUnits.hasHole($0.slots, fromMs: fromMs) } ?? false } + + func midnight(_ ms: Double) -> Double { + calendar.startOfDay(for: Date(timeIntervalSince1970: ms / 1000)).timeIntervalSince1970 * 1000 + } + + /// The window, named by what would make it out of date. + var wanted: String? { + guard active > 0, site.session.caps.contains(Contract.capPriceSpot) else { return nil } + let now = Date(timeIntervalSince1970: site.nowMs / 1000) + let hour = calendar.component(.hour, from: now) + return "\(midnight(site.nowMs))/\(hour >= Self.publishHour ? "published" : "pending")" + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self else { return nil } + // A box that stopped offering prices shows none; a window + // from yesterday is not today's. + if !self.site.session.caps.contains(Contract.capPriceSpot) { self.prices = nil } + if self.prices != nil, self.midnight(self.site.nowMs) != self.fromMs { self.prices = nil } + return self.wanted + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + func load() async throws { + generation += 1 + let mine = generation + let from = midnight(site.nowMs) + do { + let wire = try await site.prices(fromMs: from, toMs: from + Self.horizonMs) + guard mine == generation else { return } + prices = wire + fromMs = from + } catch { + // A window already drawn stays drawn; today's prices are still + // today's. Rethrown so the ask heals. + guard mine == generation else { return } + throw error + } + } +} + +/// Money saved today and this week, GET /api/savings/daily. +@Observable +@MainActor +public final class SavingsModel: Activatable { + public private(set) var periods: Savings.Periods? + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private let calendar: Calendar + + public init(site: SiteModel, calendar: Calendar = .current) { + self.site = site + self.calendar = calendar + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.site.hasPassthrough else { return nil } + return "savings \(self.calendar.startOfDay(for: Date(timeIntervalSince1970: self.site.nowMs / 1000)).timeIntervalSince1970)" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + func load() async throws { + let wire = try await site.callBox(.get, "/api/savings/daily", query: ["days": "7"]) + let days = (wire?["days"]?.array ?? []).compactMap(Savings.day) + let next = Savings.periods(days) + periods = next.today.available || next.week.available ? next : nil + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift new file mode 100644 index 0000000..7de035c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift @@ -0,0 +1,134 @@ +import Foundation +import Observation + +/// Pairing, the first thing anyone sees. Two taps: scan, Face ID. Everything +/// that can fail does so before the passkey prompt. +@Observable +@MainActor +public final class PairModel { + public enum Stage: Equatable, Sendable { + case intro, scanning, pairing, recovering, choosing, demoing + } + + public var stage: Stage = .intro + /// A sentence, when something did not work. + public private(set) var message: String? + /// The box a link points at, by the same six characters the Box screen + /// names a paired box by. + public private(set) var offeredFingerprint: String? + public private(set) var recovered: [Escrow.Recovered] = [] + /// This phone already holds a key and a home: something local went + /// missing, not a stranger arriving. + public private(set) var known: StoredSite? + + @ObservationIgnored private unowned let app: AppModel + + public init(app: AppModel) { + self.app = app + if app.vault.isEnrolled { known = app.sites.all().first } + if let link = app.offeredLink { + do { + offeredFingerprint = SiteList.fingerprint(try Enrollment.parse(scanned: link).boxStaticPublic) + } catch { + message = "That link is not an FTW pairing code." + } + } + } + + /// Opening the saved home needs a key and a row, and no problem that + /// says one of them is broken. + public var canOpen: Bool { known != nil && app.connectHelp == nil } + + /// Scanned with the camera or pasted: a deliberate act, so it pairs. + public func pair(_ raw: String) async { + stage = .pairing + message = nil + do { + let result = try await app.pairing.pair(scanned: raw) + app.paired(result.site.siteId) + } catch is PasskeyCancelled { + // A dismissed sheet is not a fault and gets no error voice. + stage = .intro + } catch let e as HelpfulError { + stage = .intro + message = e.help + } catch { + stage = .intro + message = "That didn't work. Try scanning again." + } + } + + /// Pair with the link that arrived, now that someone said so. + public func acceptOffer() async { + guard let link = app.offeredLink else { return } + await pair(link) + } + + public func declineOffer() { + app.offeredLink = nil + offeredFingerprint = nil + } + + /// Ask the passkey what Sourceful is holding. Costs a prompt, so it is a + /// button and never a check on arrival. + public func recover() async { + stage = .recovering + message = nil + do { + recovered = try await app.escrow.recover(app.passkeys) + if recovered.isEmpty { + stage = .intro + message = "Nothing was saved for this passkey. Open Settings → FTW app in your box dashboard and scan a new pairing code instead." + return + } + stage = .choosing + } catch is PasskeyCancelled { + stage = .intro + } catch let e as HelpfulError { + stage = .intro + message = e.help + } catch { + stage = .intro + message = "That didn't work. Open Settings → FTW app in your box dashboard and scan a new pairing code instead." + } + } + + public func adopt(_ home: Escrow.Recovered) { + stage = .pairing + do { + let id = try app.escrow.adopt(home, nowMs: app.scheduler.nowMs) + app.paired(id) + } catch let e as HelpfulError { + stage = .intro + message = e.help + } catch { + stage = .intro + message = "That didn't work. Try scanning the code instead." + } + } + + public func openKnown() { + guard let known else { return } + app.paired(known.siteId) + } + + /// A picture was read and held no pairing code. + public func noCodeFound() { + stage = .intro + message = "There is no FTW pairing code in that picture. Try a closer screenshot of the QR." + } + + public func tryDemo() { + stage = .demoing + app.startDemo() + } + + public func cancel() { + stage = .intro + message = nil + } + + public func dismiss() { + app.recovering = false + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift new file mode 100644 index 0000000..5f7077b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift @@ -0,0 +1,152 @@ +import Foundation +import Observation + +/// The plan, and changing how the site is run. The plan lives on session +/// state because the box pushes a fresh one unasked after a mode change +/// made from any phone. +@Observable +@MainActor +public final class PlanModel: Activatable { + public enum Command: Equatable, Sendable { + case idle + case sending(String) + case applied(String) + case unconfirmed(String) + case failed(String) + } + + /// How long a settled outcome stays before the screen goes quiet. + static let settleMs: Double = 4_000 + + public private(set) var loading = false + /// A sentence, never a code. + public private(set) var problem: String? + public private(set) var command: Command = .idle + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var want = 0 + @ObservationIgnored private var settle: Cancellable? + + public init(site: SiteModel) { + self.site = site + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0 else { return nil } + return "plan \(self.want)" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + public var plan: Plan? { site.session.plan } + + /// Every mode the box accepts, in its order. Hidden ones are valid over + /// the API but never buttons. + public var modes: [ModeInfo] { site.session.modes.filter { $0.tier != "hidden" } } + public var primaryModes: [ModeInfo] { modes.filter { $0.tier == "primary" } } + public var advancedModes: [ModeInfo] { modes.filter { $0.tier == "advanced" } } + + /// The mode the box reports, which is the only one that counts. + public var actualMode: String? { + guard let i = site.session.fields[Contract.FID.mode].map({ Int($0) }), i >= 0, i < site.session.modes.count else { return nil } + return site.session.modes[i].key + } + + /// The pending choice while one is in flight, else the real one. A + /// refusal snaps back because this never overrides what the box said. + public var shownMode: String? { + switch command { + case .sending(let m), .applied(let m), .unconfirmed(let m): return m + default: return actualMode + } + } + + public var inManual: Bool { + guard let m = shownMode else { return false } + return advancedModes.contains { $0.key == m } + } + + /// The way back from a manual fallback: the box's first primary mode. + public var planHome: ModeInfo? { primaryModes.first } + + /// Both have to hold: the box offers dispatch, and this enrolment carries + /// the scope the box checks. Drawing a control a viewer will be refused + /// is the one thing this app must not do. + public var canControl: Bool { + site.session.caps.contains(Contract.capPlanDispatch) && site.session.scopes.contains(Contract.scopeModeWrite) + } + + public enum NoControl: Sendable { case box, role } + + /// Nil until the box has answered: before its hello, "this box can't do + /// that" would be a sentence about a box that has said nothing. + public var whyNoControl: NoControl? { + guard site.heardFromBox, !canControl else { return nil } + return site.session.scopes.contains(Contract.scopeModeWrite) ? .box : .role + } + + func load() async throws { + loading = true + problem = nil + defer { loading = false } + do { + _ = try await site.plan() + } catch { + problem = "Couldn't get the plan from your box. Still trying." + throw error + } + } + + /// Optimistic on screen, never in the model. + public func setMode(_ mode: String) async { + if case .sending = command { return } + if mode == shownMode { return } + settle?.cancel() + command = .sending(mode) + do { + let result = try await site.command(Contract.opSetMode, args: [("mode", .text(mode))]) + switch result.state { + case .applied: + command = .applied(mode) + followPlan() + case .unconfirmed: + command = .unconfirmed(mode) + default: + command = .failed(CommandText.help(result)) + } + } catch let e as CommandError { + command = .failed(e.help) + } catch { + command = .failed("That didn't go through. Try again.") + } + settle = site.scheduler.after(Self.settleMs) { [weak self] in self?.command = .idle } + } + + /// The box acknowledges a mode before its optimiser has replanned, so the + /// plan is asked for again every three seconds, for thirty at most, + /// until its revision moves. + private func followPlan() { + let before = plan?.rev + func step(_ n: Int) { + guard n < 10, plan?.rev == before else { return } + want += 1 + ask?.evaluate() + site.scheduler.after(3_000) { [weak self] in + guard self != nil else { return } + step(n + 1) + } + } + step(0) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift new file mode 100644 index 0000000..46dc3d1 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift @@ -0,0 +1,64 @@ +import Foundation +import Observation + +/// The spare key on the Box screen: whether Sourceful holds a sealed copy of +/// this home. Written as the web app does it: mark the home, seal the set, +/// and put the mark back when the seal did not land, so the switch never +/// says something the escrow does not hold. +@Observable +@MainActor +public final class SealedCopyModel { + public enum Stage: Sendable { case idle, working, failed } + + public private(set) var kept: Bool + public private(set) var stage: Stage = .idle + /// A sentence, when the last try did not work. + public private(set) var problem: String? + + @ObservationIgnored private unowned let app: AppModel + @ObservationIgnored private let siteId: String + + init(app: AppModel, siteId: String) { + self.app = app + self.siteId = siteId + kept = app.sites.get(siteId)?.escrow == true + } + + /// Read the mark again. Pairing seals its copy in the background, so the + /// answer can land after this model was made. + public func reload() { + guard stage != .working else { return } + kept = app.sites.get(siteId)?.escrow == true + } + + public func set(_ on: Bool) async { + guard stage != .working else { return } + stage = .working + problem = nil + app.escrow.mark(siteId, on) + do { + let wrapping = try await app.vault.unlockWrappingKey(app.passkeys) + let outcome = await app.escrow.save(wrapping) + if outcome == .saved || outcome == .cleared { + kept = on + stage = .idle + return + } + app.escrow.mark(siteId, !on) + stage = .failed + switch outcome { + case .unsupported: problem = "This phone has no passkey that can seal a copy. Everything else works exactly as it does now." + case .unreachable: problem = "That didn't reach Sourceful. Your home works either way. Try again when you're back online." + default: problem = "That didn't work. Your home works either way. Try again." + } + } catch is PasskeyCancelled { + // A dismissed sheet is an answer, not a fault. + app.escrow.mark(siteId, !on) + stage = .idle + } catch { + app.escrow.mark(siteId, !on) + stage = .failed + problem = "That didn't work. Try again." + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift new file mode 100644 index 0000000..9257af5 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -0,0 +1,330 @@ +import Foundation +import Observation + +/// The bridge between the session and the screens. +/// +/// The session owns protocol state; this exposes it, restores the cached +/// snapshot on start, keeps freshness honest and derives what the screens +/// ask for. Nothing above this touches a frame. +@Observable +@MainActor +public final class SiteModel { + /// Fields the Now screen draws. Their sources drive the freshness band. + static let nowFids = [Contract.FID.gridW, Contract.FID.pvW, Contract.FID.batteryW, Contract.FID.batterySoc, Contract.FID.loadW, Contract.FID.evW] + /// A 1 Hz stream is always a moment behind and a dropped tick is normal + /// on a phone; past a couple of beats the silence is real. + static let streamQuietAfterMs: Double = 3_000 + /// How long a foreground stream gets to prove its socket still works. + public static let foregroundFrameDeadlineMs: Double = 2_500 + static let recentWindowMs: Double = 130_000 + static let snapshotIntervalMs: Double = 15_000 + + public private(set) var session = SessionState() + /// Wall clock the cached view was captured. Nil when live. + public private(set) var cachedAtMs: Double? + public private(set) var lastFrameAtMs: Double? + public private(set) var documentVisible = true + /// The one clock screens depend on, so derived ages move on screen. + public private(set) var nowMs: Double + /// Import ceiling the optimiser defends, when known. + public var ceilingW: Double? + + public let siteId: String? + /// True for the demo: nothing is written, and the screens say so. + public let isDemo: Bool + + @ObservationIgnored let core: Session + @ObservationIgnored let scheduler: Scheduler + @ObservationIgnored private let files: SealedFiles? + @ObservationIgnored private var attemptStartedAtMs: Double + @ObservationIgnored private var ticker: Cancellable? + @ObservationIgnored private var resumeTimer: Cancellable? + @ObservationIgnored private var resumeWaiting = false + @ObservationIgnored private var recent: [(t: Double, v: [Int: Double])] = [] + @ObservationIgnored private var lastSnapshotMs: Double = 0 + @ObservationIgnored private var snapshotTimer: Cancellable? + @ObservationIgnored private var destroyed = false + @ObservationIgnored private var listeners: [Int: @MainActor () -> Void] = [:] + @ObservationIgnored private var nextListener = 0 + /// Runs the passkey ceremony a write needs. Injected by the shell. + @ObservationIgnored public var stepUp: (@MainActor () async -> StepUpOutcome)? + + public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, isDemo: Bool = false) { + self.siteId = siteId + self.scheduler = scheduler + self.files = files + self.isDemo = isDemo + nowMs = scheduler.nowMs + attemptStartedAtMs = scheduler.nowMs + core = Session(build: build, ua: ua, scheduler: scheduler) + core.onChange = { [weak self] s in self?.onSession(s) } + } + + // MARK: Lifecycle + + /// Paint from cache, then let the shell connect. The cache is a carrier, + /// not a failure: it is how the first frame has something honest. + public func start() { + if let siteId, let cached = files?.readJSON(CachedSnapshot.self, "snapshot-\(siteId)"), cached.siteId == siteId { + core.restore(cached) + cachedAtMs = cached.savedAtMs + } + startTicker() + } + + private func startTicker() { + ticker?.cancel() + ticker = scheduler.after(1_000) { [weak self] in + guard let self, !self.destroyed else { return } + self.nowMs = self.scheduler.nowMs + self.notify() + self.startTicker() + } + } + + /// Attach a carrier. Refused once the model is gone, so a slow connect + /// can never hand an old home's stream to a new one. + @discardableResult + public func connect(_ carrier: Carrier) -> Bool { + if destroyed { + carrier.close(reason: "superseded connection") + return false + } + core.connect(carrier) + if documentVisible { beginResume(immediate: true) } + return true + } + + public func destroy() { + destroyed = true + ticker?.cancel() + resumeTimer?.cancel() + snapshotTimer?.cancel() + core.onChange = nil + core.close() + listeners = [:] + } + + /// Match the stream's cadence to whether anyone can see it. + public func setVisible(_ visible: Bool) { + let was = documentVisible + documentVisible = visible + core.setTelemetryHz(visible ? 1 : 0.2) + if !visible { + cancelResume() + persistNow() + return + } + if !was { beginResume(immediate: false) } + } + + /// A new network path should replace the old one without waiting. + public func networkOnline() { + guard documentVisible else { return } + beginResume(immediate: true) + } + + /// Pull to refresh: ask for a fresh stream in place. + public func refresh() { + core.wake() + } + + // MARK: Observation for feature models + + /// Called after every session change and every clock tick. + @discardableResult + public func observe(_ f: @escaping @MainActor () -> Void) -> Int { + nextListener += 1 + listeners[nextListener] = f + return nextListener + } + + public func unobserve(_ token: Int) { + listeners[token] = nil + } + + private func notify() { + for f in listeners.values { f() } + } + + // MARK: Session changes + + private func onSession(_ s: SessionState) { + let previous = session + if previous.phase == .streaming, s.phase != .streaming { + attemptStartedAtMs = scheduler.nowMs + } + // Only a streaming session is a reading arriving. Restoring the cache + // and answering hello both move the clock and carry no reading. + if s.phase == .streaming, previous.phase != .streaming || s.uptimeMs != previous.uptimeMs { + let now = scheduler.nowMs + lastFrameAtMs = now + finishResume() + recordRecent(s.fields, now) + } + session = s + if s.phase == .streaming { + cachedAtMs = nil + scheduleSnapshot() + } + notify() + } + + private func scheduleSnapshot() { + guard siteId != nil, files != nil, snapshotTimer == nil else { return } + let due = max(0, lastSnapshotMs + Self.snapshotIntervalMs - scheduler.nowMs) + snapshotTimer = scheduler.after(due) { [weak self] in + guard let self else { return } + self.snapshotTimer = nil + self.persistNow() + } + } + + /// Write the last streaming state now, before the app can be suspended. + public func persistNow() { + guard let siteId, let files, !destroyed, session.phase == .streaming || !session.fields.isEmpty, cachedAtMs == nil else { return } + lastSnapshotMs = scheduler.nowMs + files.writeJSON("snapshot-\(siteId)", CachedSnapshot(siteId: siteId, savedAtMs: scheduler.nowMs, state: session)) + } + + // MARK: Waking after sleep + + private func beginResume(immediate: Bool) { + guard !destroyed, documentVisible else { return } + resumeTimer?.cancel() + resumeWaiting = true + if immediate || session.phase != .streaming || lastFrameAtMs == nil { + core.wake() + } + let frameAtStart = lastFrameAtMs + resumeTimer = scheduler.after(Self.foregroundFrameDeadlineMs) { [weak self] in + guard let self, !self.destroyed, self.documentVisible, self.lastFrameAtMs == frameAtStart else { return } + self.core.wake() + } + } + + private func finishResume() { + guard resumeWaiting else { return } + resumeTimer?.cancel() + resumeWaiting = false + } + + private func cancelResume() { + resumeTimer?.cancel() + resumeWaiting = false + } + + // MARK: What the screens read + + public var role: String { session.role } + /// Whether to draw controls at all. Hiding is presentation; the box is + /// what refuses. + public var canConfigure: Bool { session.role == Contract.roleOwner } + /// Before the box's hello, role and caps are this app's assumptions, and + /// a sentence about the box built on them would be invented. + public var heardFromBox: Bool { session.heardFromBox } + public var paired: Bool { session.box != nil || !session.fields.isEmpty } + public var hasPassthrough: Bool { session.caps.contains(Contract.capAPIPassthrough) } + + /// How the readings on screen reach us. A carrier is claimed only once it + /// has delivered a reading: an open socket is evidence of nothing. + public var carrier: CarrierKind { + if session.phase == .streaming, lastFrameAtMs != nil { return session.carrier } + return cachedAtMs != nil ? .cache : .none + } + + public var connectionWaitMs: Double { max(0, nowMs - attemptStartedAtMs) } + + /// The sources behind what the Now screen draws, named by the box. + var nowSourceIDs: [String] { + var ids = [String]() + for fid in Self.nowFids where session.fields[fid] != nil { + if let src = session.dict[fid]?.srcId, !ids.contains(src) { ids.append(src) } + } + return ids + } + + public var srcState: SourceState { + if session.fields.isEmpty { return .never } + // Every source state was read off the disk: true when written. + if lastFrameAtMs == nil { return .stale } + let ids = nowSourceIDs + let worst: SourceState + if !ids.isEmpty { + worst = core.worstSourceState(ids) + } else if !session.sources.isEmpty { + worst = core.worstSourceState(Array(session.sources.keys)) + } else { + worst = .live + } + // A stream gone quiet is not live however healthy it looked. + if worst == .live, sinceLastFrameMs > 0 { return .lagging } + return worst + } + + public var sinceLastFrameMs: Double { + guard let at = lastFrameAtMs else { return 0 } + let since = nowMs - at + return since < Self.streamQuietAfterMs ? 0 : since + } + + /// Age of the oldest reading on screen. Nil means unknown, which is the + /// honest answer after a box restart. + public var ageMs: Double? { + let ages = nowSourceIDs.compactMap { core.ageOf($0) } + if carrier == .cache, let at = cachedAtMs { + let shelf = nowMs - at + return shelf + (ages.max() ?? 0) + } + guard let oldest = ages.max() else { return nil } + return oldest + sinceLastFrameMs + } + + /// Live is three claims at once: streaming, not the cache, and every + /// source answering. + public var isLive: Bool { + session.phase == .streaming && carrier != .cache && srcState == .live + } + + public var explanation: Explanation.Result { + Explanation.explain(fields: session.fields, dispatchBlockedBy: session.dispatchBlockedBy, ceilingW: ceilingW) + } + + public var socPercent: Double? { + session.fields[Contract.FID.batterySoc].map { ($0 / 10).rounded() } + } + + private func recordRecent(_ fields: [Int: Double], _ now: Double) { + var v = [Int: Double]() + for fid in Self.nowFids { if let x = fields[fid] { v[fid] = x } } + recent.append((now, v)) + let cutoff = now - Self.recentWindowMs + recent.removeAll { $0.t < cutoff } + } + + /// The last two minutes of one field, oldest first, so a live line opens + /// already drawn. + public func recentField(_ fid: Int) -> [(t: Double, v: Double)] { + recent.compactMap { r in r.v[fid].map { (r.t, $0) } } + } + + // MARK: Requests, through this layer + + public func history(_ q: HistQuery, onChunk: @escaping @MainActor (HistChunk) -> Void) async throws -> HistEnd { + try await core.history(q, onChunk: onChunk) + } + + public func plan() async throws -> Plan { try await core.plan() } + + public func prices(fromMs: Double, toMs: Double) async throws -> Prices { + try await core.prices(fromMs: fromMs, toMs: toMs) + } + + public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = []) async throws -> CmdResult { + try await core.command(op, args: args, guards: guards) + } + + public func api(_ req: APIRequest) async throws -> APIResponse { + try await core.api(req) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift new file mode 100644 index 0000000..ce4a707 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Sealed files for what the app caches between launches: the last readings +/// and the history tiles. +/// +/// AES-GCM under a cache key kept in the secure store, and deliberately not +/// behind a passkey: a cold start must paint before any Face ID prompt. The +/// honest claim is that the files resist an offline read of the disk, not +/// someone holding the unlocked phone, who can open the app and look. +/// +/// A file that will not open is treated as absent. Every file here is a +/// cache: the box holds the record. +@MainActor +public final class SealedFiles { + static let keyName = "cache-key" + private let directory: URL + private let store: SecureStore + + public init(directory: URL, store: SecureStore) { + self.directory = directory + self.store = store + } + + /// The app's own cache directory, created on first use. + public static func defaultDirectory() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + return base.appendingPathComponent("FTW", isDirectory: true) + } + + private var key: Bytes { + if let k = store.get(Self.keyName), k.count == 32 { return k } + let k = randomBytes(32) + store.put(Self.keyName, k) + return k + } + + private func url(_ name: String) -> URL { + directory.appendingPathComponent(name.replacingOccurrences(of: "/", with: "_")) + } + + public func write(_ name: String, _ plain: Bytes) { + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let nonce = randomBytes(12) + let sealed = nonce + (try Primitives.aesGCMSeal(key: key, nonce: nonce, ad: Array(name.utf8), plaintext: plain)) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + try Data(sealed).write(to: url(name), options: options) + } catch { + // A cache that could not be written costs a slower next start. + } + } + + public func read(_ name: String) -> Bytes? { + guard let data = try? Data(contentsOf: url(name)), data.count > 28 else { return nil } + let bytes = data.byteArray + return try? Primitives.aesGCMOpen(key: key, nonce: Array(bytes[0..<12]), ad: Array(name.utf8), ciphertext: Array(bytes[12...])) + } + + public func remove(_ name: String) { + try? FileManager.default.removeItem(at: url(name)) + } + + /// Everything, and the key with it: a phone handed on must not paint the + /// previous household's home. + public func clear() { + try? FileManager.default.removeItem(at: directory) + store.remove(Self.keyName) + } + + public func writeJSON(_ name: String, _ value: T) { + guard let data = try? JSONEncoder().encode(value) else { return } + write(name, data.byteArray) + } + + public func readJSON(_ type: T.Type, _ name: String) -> T? { + guard let raw = read(name) else { return nil } + return try? JSONDecoder().decode(T.self, from: Data(raw)) + } +} + +/// Closed history tiles, per home, so opening the chart paints at once and +/// the box sends only what changed. +@MainActor +public final class TileCache { + struct Tile: Codable { + var tileId: String + var etag: String + var res: String + var startMs: Double + var stepMs: Double + var series: [String] + var data: Data + } + + private let files: SealedFiles? + private let siteId: String + private var tiles: [String: Tile] + private var dirty = false + + public init(files: SealedFiles?, siteId: String) { + self.files = files + self.siteId = siteId + tiles = files?.readJSON([String: Tile].self, "tiles-\(siteId)") ?? [:] + } + + public func get(_ ids: [String]) -> [String: HistChunk] { + var out = [String: HistChunk]() + for id in ids { + guard let t = tiles[id] else { continue } + out[id] = HistChunk(tileId: t.tileId, etag: t.etag, res: Resolution(rawValue: t.res) ?? .fiveMinutes, startMs: t.startMs, stepMs: t.stepMs, series: t.series, data: t.data.byteArray, partial: false) + } + return out + } + + /// The trailing tile is still filling and never cached. + public func put(_ chunk: HistChunk) { + guard !chunk.partial else { return } + tiles[chunk.tileId] = Tile(tileId: chunk.tileId, etag: chunk.etag, res: chunk.res.rawValue, startMs: chunk.startMs, stepMs: chunk.stepMs, series: chunk.series, data: Data(chunk.data)) + dirty = true + } + + /// Drop tiles past each resolution's retention, then write. + public func flush(nowMs: Double) { + let before = tiles.count + tiles = tiles.filter { _, t in + let res = Resolution(rawValue: t.res) ?? .fiveMinutes + return t.startMs + HistoryGeometry.spec(res).tileSpanMs >= nowMs - HistoryGeometry.spec(res).retentionMs + } + if dirty || tiles.count != before { + files?.writeJSON("tiles-\(siteId)", tiles) + dirty = false + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift new file mode 100644 index 0000000..c43f86a --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Small secrets and records, by name. +/// +/// On Apple platforms this is the Keychain, readable after first unlock and +/// never synced or migrated to another device. Tests use `MemoryStore`. +/// +/// Everything here is a cache of what the box holds, except the device key, +/// which is this phone's identity. Clearing the store is signing out. +@MainActor +public protocol SecureStore: AnyObject { + func get(_ key: String) -> Bytes? + func put(_ key: String, _ value: Bytes) + func remove(_ key: String) +} + +@MainActor +public final class MemoryStore: SecureStore { + private var map: [String: Bytes] = [:] + + public init() {} + + public func get(_ key: String) -> Bytes? { map[key] } + public func put(_ key: String, _ value: Bytes) { map[key] = value } + public func remove(_ key: String) { map[key] = nil } + + public var keys: [String] { map.keys.sorted() } +} + +extension SecureStore { + func getJSON(_ type: T.Type, _ key: String) -> T? { + guard let raw = get(key) else { return nil } + return try? JSONDecoder().decode(T.self, from: Data(raw)) + } + + func putJSON(_ key: String, _ value: T) { + guard let data = try? JSONEncoder().encode(value) else { return } + put(key, data.byteArray) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift new file mode 100644 index 0000000..c8e63fc --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift @@ -0,0 +1,99 @@ +import Foundation + +/// One paired home, as this phone remembers it. +public struct StoredSite: Codable, Equatable, Sendable, Identifiable { + public var siteId: String + public var label: String + /// Pinned optically. What stops the relay impersonating a box. + public var boxStaticKey: Data + /// Long-lived. The rotating relay handle is derived from it and only it. + /// Nil for a home paired before the v2 payload, which cannot be reached. + public var rendezvousSecret: Data? + /// Single use, spent in the first handshake. Kept until then. + public var pairingCode: Data? + public var lanHint: String? + /// Whether this household asked Sourceful to hold a sealed copy of it. + public var escrow: Bool + public var addedAtMs: Double + public var lastSeenAtMs: Double + + public var id: String { siteId } + + public init(siteId: String, label: String, boxStaticKey: Data, rendezvousSecret: Data?, pairingCode: Data?, lanHint: String?, escrow: Bool, addedAtMs: Double, lastSeenAtMs: Double) { + self.siteId = siteId + self.label = label + self.boxStaticKey = boxStaticKey + self.rendezvousSecret = rendezvousSecret + self.pairingCode = pairingCode + self.lanHint = lanHint + self.escrow = escrow + self.addedAtMs = addedAtMs + self.lastSeenAtMs = lastSeenAtMs + } +} + +/// The homes this phone is paired to, and which one it opens. +/// +/// Storing a home is a fact; making it the one the app shows is a decision, +/// so the two are separate calls. +@MainActor +public final class SiteList { + private let store: SecureStore + static let sitesKey = "sites" + static let currentKey = "current-site" + + public init(store: SecureStore) { + self.store = store + } + + public func all() -> [StoredSite] { + store.getJSON([StoredSite].self, Self.sitesKey) ?? [] + } + + public func get(_ siteId: String) -> StoredSite? { + all().first { $0.siteId == siteId } + } + + public func put(_ site: StoredSite) { + var rows = all().filter { $0.siteId != site.siteId } + rows.append(site) + store.putJSON(Self.sitesKey, rows) + } + + public func update(_ siteId: String, _ change: (inout StoredSite) -> Void) { + guard var row = get(siteId) else { return } + change(&row) + put(row) + } + + /// The home the app opens: the pointer, else the first row. A pointer + /// left behind after its row is gone is ignored rather than trusted. + public func current() -> StoredSite? { + let rows = all() + if let id = store.get(Self.currentKey).map({ String(decoding: $0, as: UTF8.self) }), + let row = rows.first(where: { $0.siteId == id }) { + return row + } + return rows.first + } + + public func setCurrent(_ siteId: String) { + store.put(Self.currentKey, Array(siteId.utf8)) + } + + public func clear() { + store.remove(Self.sitesKey) + store.remove(Self.currentKey) + } + + /// Six hex characters of the box key's digest, so two boxes look + /// different. The same name the web app shows for the same box. + public nonisolated static func fingerprint(_ boxStaticKey: Bytes) -> String { + Array(Primitives.sha256(boxStaticKey).prefix(3)).hex.uppercased() + } + + /// A local id for a box, never sent anywhere. + public nonisolated static func siteID(_ boxStaticKey: Bytes) -> String { + Array(Primitives.sha256(boxStaticKey).prefix(8)).hex + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/Base64url.swift b/appleApp/FTWKit/Sources/FTWKit/Support/Base64url.swift new file mode 100644 index 0000000..33fd967 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/Base64url.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Unpadded base64url, strict on the way in. +/// +/// Strict because every value decoded here came off a QR code or a box, and +/// a lenient decoder turns a misread character into a different key rather +/// than into an error anyone would see. +public enum Base64url { + public struct DecodeError: Error, Equatable { + public let message: String + } + + private static let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".utf8) + + private static let lookup: [Int8] = { + var table = [Int8](repeating: -1, count: 256) + for (i, c) in alphabet.enumerated() { table[Int(c)] = Int8(i) } + return table + }() + + public static func encode(_ bytes: Bytes) -> String { + var out = [UInt8]() + out.reserveCapacity((bytes.count * 4 + 2) / 3) + var i = 0 + while i + 3 <= bytes.count { + let n = UInt32(bytes[i]) << 16 | UInt32(bytes[i + 1]) << 8 | UInt32(bytes[i + 2]) + out.append(alphabet[Int(n >> 18 & 63)]) + out.append(alphabet[Int(n >> 12 & 63)]) + out.append(alphabet[Int(n >> 6 & 63)]) + out.append(alphabet[Int(n & 63)]) + i += 3 + } + let rest = bytes.count - i + if rest == 1 { + let n = UInt32(bytes[i]) << 16 + out.append(alphabet[Int(n >> 18 & 63)]) + out.append(alphabet[Int(n >> 12 & 63)]) + } else if rest == 2 { + let n = UInt32(bytes[i]) << 16 | UInt32(bytes[i + 1]) << 8 + out.append(alphabet[Int(n >> 18 & 63)]) + out.append(alphabet[Int(n >> 12 & 63)]) + out.append(alphabet[Int(n >> 6 & 63)]) + } + return String(decoding: out, as: UTF8.self) + } + + public static func decode(_ text: String) throws -> Bytes { + let chars = Array(text.utf8) + if chars.count % 4 == 1 { + throw DecodeError(message: "length \(chars.count) is not a base64url length") + } + var values = [UInt8]() + values.reserveCapacity(chars.count) + for c in chars { + let v = lookup[Int(c)] + if v < 0 { throw DecodeError(message: "character \(Character(UnicodeScalar(c))) is not base64url") } + values.append(UInt8(v)) + } + var out = Bytes() + out.reserveCapacity(values.count * 3 / 4) + var i = 0 + while i + 4 <= values.count { + let n = UInt32(values[i]) << 18 | UInt32(values[i + 1]) << 12 | UInt32(values[i + 2]) << 6 | UInt32(values[i + 3]) + out.append(UInt8(n >> 16 & 0xff)) + out.append(UInt8(n >> 8 & 0xff)) + out.append(UInt8(n & 0xff)) + i += 4 + } + let rest = values.count - i + if rest == 2 { + let n = UInt32(values[i]) << 18 | UInt32(values[i + 1]) << 12 + // Bits past the last whole byte must be zero, or two strings + // decode to the same bytes and the encoding stops being unique. + if n & 0xffff != 0 { throw DecodeError(message: "trailing bits are not zero") } + out.append(UInt8(n >> 16 & 0xff)) + } else if rest == 3 { + let n = UInt32(values[i]) << 18 | UInt32(values[i + 1]) << 12 | UInt32(values[i + 2]) << 6 + if n & 0xff != 0 { throw DecodeError(message: "trailing bits are not zero") } + out.append(UInt8(n >> 16 & 0xff)) + out.append(UInt8(n >> 8 & 0xff)) + } + return out + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/Bytes.swift b/appleApp/FTWKit/Sources/FTWKit/Support/Bytes.swift new file mode 100644 index 0000000..08bbc08 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/Bytes.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Raw bytes. `[UInt8]` rather than `Data` inside the protocol, because +/// indexing a `Data` slice by absolute offset is the classic way to read the +/// wrong byte, and every layer here indexes. +public typealias Bytes = [UInt8] + +extension Array where Element == UInt8 { + public init(hex: String) { + var out = Bytes() + out.reserveCapacity(hex.count / 2) + var high: UInt8? + for ch in hex.utf8 { + let v: UInt8 + switch ch { + case 0x30...0x39: v = ch - 0x30 + case 0x61...0x66: v = ch - 0x61 + 10 + case 0x41...0x46: v = ch - 0x41 + 10 + default: continue + } + if let h = high { + out.append(h << 4 | v) + high = nil + } else { + high = v + } + } + self = out + } + + public var hex: String { + let digits = Array("0123456789abcdef".utf8) + var out = [UInt8]() + out.reserveCapacity(count * 2) + for b in self { + out.append(digits[Int(b >> 4)]) + out.append(digits[Int(b & 0x0f)]) + } + return String(decoding: out, as: UTF8.self) + } + + public var data: Data { Data(self) } +} + +extension Data { + /// Not `bytes`: newer Foundation already has a `Data.bytes` span. + public var byteArray: Bytes { Bytes(self) } +} + +/// Concatenate byte runs without an intermediate array per step. +public func concat(_ parts: Bytes...) -> Bytes { + var out = Bytes() + out.reserveCapacity(parts.reduce(0) { $0 + $1.count }) + for p in parts { out.append(contentsOf: p) } + return out +} + +/// Cryptographically random bytes from the system generator. +public func randomBytes(_ count: Int) -> Bytes { + var rng = SystemRandomNumberGenerator() + return (0.. Bool { + guard a.count == b.count else { return false } + var diff: UInt8 = 0 + for i in a.indices { diff |= a[i] ^ b[i] } + return diff == 0 +} + +extension UInt64 { + var bigEndianBytes: Bytes { + (0..<8).map { UInt8(truncatingIfNeeded: self >> (8 * (7 - $0))) } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift b/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift new file mode 100644 index 0000000..c7b851d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift @@ -0,0 +1,233 @@ +import Foundation + +/// A JSON value, read tolerantly. The box's HTTP answers are read the way the +/// web app reads them: a missing or mistyped field becomes nil, never a +/// crash, so a newer box's extra fields and an older box's missing ones are +/// both ordinary. +/// +/// Its own small parser rather than JSONSerialization, which reports +/// booleans as numbers differently on each platform. +public enum JSON: Equatable, Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSON]) + /// Keys in document order, so a body re-encodes as it came. + case object([(String, JSON)]) + + public static func == (a: JSON, b: JSON) -> Bool { + switch (a, b) { + case (.null, .null): return true + case let (.bool(x), .bool(y)): return x == y + case let (.number(x), .number(y)): return x == y + case let (.string(x), .string(y)): return x == y + case let (.array(x), .array(y)): return x == y + case let (.object(x), .object(y)): + return x.count == y.count && zip(x, y).allSatisfy { $0.0 == $1.0 && $0.1 == $1.1 } + default: return false + } + } + + public struct ParseError: Error, Equatable { + public let offset: Int + } + + public init(parsing bytes: Bytes) throws { + var p = Parser(b: bytes) + p.skip() + self = try p.value(depth: 0) + p.skip() + guard p.i == bytes.count else { throw ParseError(offset: p.i) } + } + + public subscript(key: String) -> JSON? { + if case .object(let o) = self { return o.first { $0.0 == key }?.1 } + return nil + } + + public subscript(index: Int) -> JSON? { + if case .array(let a) = self, a.indices.contains(index) { return a[index] } + return nil + } + + /// A finite number, or nil. + public var number: Double? { + if case .number(let n) = self, n.isFinite { return n } + return nil + } + + public var string: String? { + if case .string(let s) = self { return s } + return nil + } + + public var bool: Bool? { + if case .bool(let b) = self { return b } + return nil + } + + public var array: [JSON]? { + if case .array(let a) = self { return a } + return nil + } + + public var object: [(String, JSON)]? { + if case .object(let o) = self { return o } + return nil + } + + public var isNull: Bool { self == .null } + + public func encoded() -> Bytes { Array(text.utf8) } + + public var text: String { + switch self { + case .null: return "null" + case .bool(let b): return b ? "true" : "false" + case .number(let n): + guard n.isFinite else { return "null" } + if n.rounded() == n, abs(n) < 9_007_199_254_740_992 { return String(Int64(n)) } + return "\(n)" + case .string(let s): return jsonString(s) + case .array(let a): return "[" + a.map(\.text).joined(separator: ",") + "]" + case .object(let o): return "{" + o.map { jsonString($0.0) + ":" + $0.1.text }.joined(separator: ",") + "}" + } + } + + /// A copy with one key set, keeping the others and their order. + public func setting(_ key: String, _ value: JSON) -> JSON { + guard case .object(var o) = self else { return self } + if let i = o.firstIndex(where: { $0.0 == key }) { + o[i].1 = value + } else { + o.append((key, value)) + } + return .object(o) + } + + private struct Parser { + let b: Bytes + var i = 0 + + mutating func skip() { + while i < b.count, b[i] == 0x20 || b[i] == 0x0a || b[i] == 0x0d || b[i] == 0x09 { i += 1 } + } + + mutating func expect(_ c: UInt8) throws { + guard i < b.count, b[i] == c else { throw ParseError(offset: i) } + i += 1 + } + + mutating func literal(_ word: String) throws { + for c in word.utf8 { try expect(c) } + } + + mutating func value(depth: Int) throws -> JSON { + guard depth < 128, i < b.count else { throw ParseError(offset: i) } + switch b[i] { + case UInt8(ascii: "{"): + i += 1 + var out = [(String, JSON)]() + skip() + if i < b.count, b[i] == UInt8(ascii: "}") { i += 1; return .object(out) } + while true { + skip() + let k = try string() + skip() + try expect(UInt8(ascii: ":")) + skip() + let v = try value(depth: depth + 1) + out.append((k, v)) + skip() + guard i < b.count else { throw ParseError(offset: i) } + if b[i] == UInt8(ascii: ",") { i += 1; continue } + try expect(UInt8(ascii: "}")) + return .object(out) + } + case UInt8(ascii: "["): + i += 1 + var out = [JSON]() + skip() + if i < b.count, b[i] == UInt8(ascii: "]") { i += 1; return .array(out) } + while true { + skip() + out.append(try value(depth: depth + 1)) + skip() + guard i < b.count else { throw ParseError(offset: i) } + if b[i] == UInt8(ascii: ",") { i += 1; continue } + try expect(UInt8(ascii: "]")) + return .array(out) + } + case UInt8(ascii: "\""): + return .string(try string()) + case UInt8(ascii: "t"): + try literal("true") + return .bool(true) + case UInt8(ascii: "f"): + try literal("false") + return .bool(false) + case UInt8(ascii: "n"): + try literal("null") + return .null + default: + return .number(try number()) + } + } + + mutating func number() throws -> Double { + let start = i + while i < b.count, "+-0123456789.eE".utf8.contains(b[i]) { i += 1 } + guard i > start, let n = Double(String(decoding: b[start.. UInt32 { + guard i + 4 <= b.count, let v = UInt32(String(decoding: b[i.. String { + try expect(UInt8(ascii: "\"")) + var out = Bytes() + while true { + guard i < b.count else { throw ParseError(offset: i) } + let c = b[i] + i += 1 + if c == UInt8(ascii: "\"") { break } + if c != UInt8(ascii: "\\") { out.append(c); continue } + guard i < b.count else { throw ParseError(offset: i) } + let e = b[i] + i += 1 + switch e { + case UInt8(ascii: "n"): out.append(0x0a) + case UInt8(ascii: "t"): out.append(0x09) + case UInt8(ascii: "r"): out.append(0x0d) + case UInt8(ascii: "b"): out.append(0x08) + case UInt8(ascii: "f"): out.append(0x0c) + case UInt8(ascii: "u"): + var scalar = try hex4() + if scalar >= 0xd800, scalar < 0xdc00, i + 6 <= b.count, b[i] == UInt8(ascii: "\\"), b[i + 1] == UInt8(ascii: "u") { + i += 2 + let low = try hex4() + scalar = 0x10000 + ((scalar - 0xd800) << 10) + (low - 0xdc00) + } + out += Array(String(Character(UnicodeScalar(scalar) ?? "\u{fffd}")).utf8) + default: out.append(e) + } + } + return String(decoding: out, as: UTF8.self) + } + } +} + +extension JSON: ExpressibleByDictionaryLiteral, ExpressibleByArrayLiteral, ExpressibleByStringLiteral, ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral, ExpressibleByBooleanLiteral, ExpressibleByNilLiteral { + public init(dictionaryLiteral elements: (String, JSON)...) { self = .object(elements) } + public init(arrayLiteral elements: JSON...) { self = .array(elements) } + public init(stringLiteral value: String) { self = .string(value) } + public init(floatLiteral value: Double) { self = .number(value) } + public init(integerLiteral value: Int) { self = .number(Double(value)) } + public init(booleanLiteral value: Bool) { self = .bool(value) } + public init(nilLiteral: ()) { self = .null } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/Scheduler.swift b/appleApp/FTWKit/Sources/FTWKit/Support/Scheduler.swift new file mode 100644 index 0000000..b328842 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/Scheduler.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Time, as everything above the wire sees it: a wall clock and timers. +/// +/// Injected so the carrier backoffs, the session's deadlines and every +/// retry can be tested by moving a clock rather than by waiting. The app +/// uses `LiveScheduler`; tests use `ManualScheduler`. +@MainActor +public protocol Scheduler: AnyObject { + /// Wall clock, in milliseconds since 1970. + var nowMs: Double { get } + /// Run `action` once after `ms`. Cancelling is always safe, even after + /// it has run. + @discardableResult + func after(_ ms: Double, _ action: @escaping @MainActor () -> Void) -> Cancellable + /// A number in [0, 1), for jitter. + func random() -> Double +} + +@MainActor +public final class Cancellable { + private var cancelled = false + private let onCancel: (() -> Void)? + + init(onCancel: (() -> Void)? = nil) { + self.onCancel = onCancel + } + + public var isCancelled: Bool { cancelled } + + public func cancel() { + guard !cancelled else { return } + cancelled = true + onCancel?() + } +} + +@MainActor +public final class LiveScheduler: Scheduler { + public static let shared = LiveScheduler() + + public init() {} + + public var nowMs: Double { Date().timeIntervalSince1970 * 1000 } + + public func after(_ ms: Double, _ action: @escaping @MainActor () -> Void) -> Cancellable { + let task = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(max(0, ms) * 1_000_000)) + if Task.isCancelled { return } + action() + } + let token = Cancellable { task.cancel() } + return token + } + + public func random() -> Double { Double.random(in: 0..<1) } +} + +/// A clock that only moves when a test says so. +@MainActor +public final class ManualScheduler: Scheduler { + private struct Pending { + let at: Double + let seq: Int + let token: Cancellable + let action: @MainActor () -> Void + } + + public private(set) var nowMs: Double + private var pending: [Pending] = [] + private var seq = 0 + public var randomValue: Double = 0.5 + + public init(nowMs: Double = 1_750_000_000_000) { + self.nowMs = nowMs + } + + public func after(_ ms: Double, _ action: @escaping @MainActor () -> Void) -> Cancellable { + let token = Cancellable() + seq += 1 + pending.append(Pending(at: nowMs + max(0, ms), seq: seq, token: token, action: action)) + return token + } + + public func random() -> Double { randomValue } + + /// Move time forward, running every timer that falls due, in order. + public func advance(_ ms: Double) { + let target = nowMs + ms + while true { + pending.removeAll { $0.token.isCancelled } + guard let next = pending.filter({ $0.at <= target }).min(by: { ($0.at, $0.seq) < ($1.at, $1.seq) }) else { break } + pending.removeAll { $0.seq == next.seq } + nowMs = max(nowMs, next.at) + next.action() + } + nowMs = target + } + + public var pendingCount: Int { + pending.filter { !$0.token.isCancelled }.count + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift new file mode 100644 index 0000000..e176f19 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -0,0 +1,331 @@ +import Foundation +import Testing +@testable import FTWKit + +@MainActor +@Suite struct DemoHomeTests { + /// The demo: every screen's model against the simulated box, with no + /// passkey, no relay and nothing written. + func demo() async -> (AppModel, HomeModels, ManualScheduler) { + let scheduler = ManualScheduler() + let app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test") + app.startDemo() + let home = app.home! + await run(scheduler, 1_000) + return (app, home, scheduler) + } + + func run(_ s: ManualScheduler, _ ms: Double, step: Double = 50) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + s.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + + @Test func theDemoStreamsAndSaysItIsLive() async { + let (app, home, s) = await demo() + #expect(app.isDemo) + #expect(home.site.session.phase == .streaming) + await run(s, 2_000) + #expect(home.site.carrier == .relay) + #expect(home.site.isLive) + #expect(home.site.explanation.situation != .noData) + #expect(home.site.recentField(Contract.FID.gridW).count >= 2) + } + + @Test func thePlanLoadsAndAModeChangeFollowsIt() async { + let (_, home, s) = await demo() + home.plan.activate() + await run(s, 500) + #expect(home.plan.plan?.slots.count == 96) + #expect(home.plan.canControl) + #expect(home.plan.primaryModes.map(\.key) == ["planner_passive_arbitrage", "planner_arbitrage"]) + #expect(home.plan.actualMode == "planner_passive_arbitrage") + let task = Task { await home.plan.setMode("self_consumption") } + await run(s, 500) + await task.value + #expect(home.plan.command == .applied("self_consumption")) + await run(s, 1_500) + #expect(home.plan.actualMode == "self_consumption") + #expect(home.plan.inManual) + await run(s, PlanModel.settleMs + 100) + #expect(home.plan.command == .idle) + } + + @Test func pricesEnergyHistoryAndSavingsFill() async { + let (_, home, s) = await demo() + home.prices.activate() + home.energy.activate() + home.history.activate() + home.savings.activate() + home.status.activate() + await run(s, 3_000) + #expect(home.prices.prices?.currency == "SEK") + #expect(home.energy.loaded) + #expect(home.energy.days.count == 7) + #expect(home.energy.totals.loadWh > 0) + home.energy.select(.month) + #expect(!home.energy.loaded) + await run(s, 1_000) + #expect(home.energy.days.count == 30) + #expect(home.history.loaded) + #expect((home.history.frame?.points ?? 0) > 200) + #expect(home.savings.periods?.week.available == true) + #expect(home.status.fresh) + #expect(Flow.fuse(status: home.status.status!) != nil) + } + + @Test func theChargerSheetSendsIntentAndRereads() async throws { + let (_, home, s) = await demo() + home.loadpoints.activate() + await run(s, 1_000) + let lp = try #require(home.loadpoints.points.first) + #expect(lp.pluggedIn) + #expect(home.loadpoints.windows[lp.id] != nil) + + let hold = Task { await home.loadpoints.chargeNow(lp, amps: 10) } + await run(s, 1_000) + await hold.value + #expect(home.loadpoints.points.first?.manualActive == true) + + let release = Task { await home.loadpoints.stopCharging(lp) } + await run(s, 1_000) + await release.value + #expect(home.loadpoints.outcome(for: .hold, lp) == "The plan decides when to charge.") + + let soc = Task { await home.loadpoints.setSoc(lp, pct: 55) } + await run(s, 1_000) + await soc.value + #expect(home.loadpoints.points.first?.socPct == 55) + + let save = Task { try await home.loadpoints.saveSchedule(lp, socPct: 90, hour: 6, minute: 30, recurring: true, days: 0b0011111, surplusUnlockPct: 0) } + await run(s, 1_000) + try await save.value + #expect(home.loadpoints.points.first?.schedule?.socPct == 90) + } + + @Test func theBoxScreenReadsTheRosterAndInvitesAViewer() async { + let (_, home, s) = await demo() + home.access.activate() + home.notify.activate() + await run(s, 1_000) + #expect(home.access.loaded) + #expect(home.access.members.first?.isThisPhone == true) + let invite = Task { await home.access.inviteViewer() } + await run(s, 500) + #expect(await invite.value) + #expect(home.access.invite?.role == Contract.roleViewer) + #expect(home.notify.availableKinds.count == 6) + let save = Task { await home.notify.save(["charging.connected": true]) } + await run(s, 500) + #expect(await save.value) + #expect(home.notify.rules["charging.connected"] == true) + #expect(home.notify.boxEnabled) + } + + @Test func leavingTheDemoTouchesNoSavedHome() async { + let (app, _, _) = await demo() + app.exitDemo() + #expect(app.home == nil) + #expect(!app.isDemo) + } +} + +@MainActor +@Suite struct ShellTests { + @MainActor + struct World { + let scheduler = ManualScheduler() + let store = MemoryStore() + let files: SealedFiles + let box: SimulatedBox + let endpoint: NoiseBoxEndpoint + let relay: FakeRelay + let escrow = FakeEscrowService() + let passkeys = FakePasskeys() + + init() { + files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-test-\(UUID().uuidString)"), store: store) + box = SimulatedBox(scheduler: scheduler) + endpoint = NoiseBoxEndpoint(box: box) + relay = FakeRelay(scheduler: scheduler, endpoint: endpoint) + } + + func app() -> AppModel { + AppModel(store: store, files: files, passkeys: passkeys, scheduler: scheduler, build: "test", ua: "test", relayURL: URL(string: "wss://relay.test")!, escrowTransport: escrow.transport, makeSocket: relay.factory) + } + + var pairingURL: String { + Enrollment(boxStaticPublic: endpoint.staticKey.publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)).url() + } + + func run(_ ms: Double, step: Double = 20) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + } + + @Test func pairStreamCloseAndReopenFromCache() async throws { + let world = World() + let app = world.app() + app.launch() + #expect(app.home == nil) + + let pair = PairModel(app: app) + let task = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await task.value + let site = try #require(app.site) + #expect(site.session.phase == .streaming) + #expect(site.carrier == .relay) + #expect(world.passkeys.registrations == 1) + // Held a sealed copy in the background. + await world.run(200) + #expect(world.escrow.rows.count == 1) + + // Long enough for the snapshot to be written. + await world.run(SiteModel.snapshotIntervalMs + 500, step: 250) + site.persistNow() + + // A cold start paints the cache before any socket opens, and never + // asks for a passkey to read. + let again = world.app() + again.launch() + let cold = try #require(again.site) + #expect(cold.carrier == .cache) + #expect(cold.session.fields[Contract.FID.gridW] != nil) + #expect(cold.srcState == .stale) + #expect(cold.ageMs != nil) + await world.run(500) + #expect(cold.session.phase == .streaming) + #expect(cold.carrier == .relay) + #expect(world.passkeys.assertions.isEmpty) + } + + @Test func aLinkToTheSameBoxIsALeftoverNotAnInvitation() async throws { + let world = World() + let app = world.app() + let url = world.pairingURL + let pair = PairModel(app: app) + let task = Task { await pair.pair(url) } + await world.run(500) + await task.value + app.offer(url) + #expect(app.offeredLink == nil) + let other = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)).url() + app.offer(other) + #expect(app.offeredLink == other) + #expect(PairModel(app: app).offeredFingerprint != nil) + } + + @Test func aConfigureWriteRunsTheCeremonyOnce() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + // The simulated box refuses configure writes without the flag. + let strict = SimulatedBox(scheduler: world.scheduler, requireStepUp: true) + _ = strict + let site = try #require(app.site) + let restart = RestartModel(site: site) + let task = Task { await restart.restart() } + await world.run(300) + await task.value + #expect(restart.error == nil) + } + + @Test func noKeyIsSaidPlainlyAndNothingRetries() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + // The identity is gone while the home's row survives. + app.vault.reset() + let again = world.app() + again.launch() + await world.run(100) + #expect(again.connectHelp == "This device has no key for that home.") + } + + @Test func signingOutClearsTheKeyTheHomeAndTheCache() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + app.site?.persistNow() + app.leave() + #expect(app.home == nil) + #expect(!app.vault.isEnrolled) + #expect(app.sites.all().isEmpty) + #expect(world.store.keys.isEmpty) + // The sealed copy stays: removing it is its own act. + #expect(world.escrow.rows.count == 1) + } +} + +@MainActor +@Suite struct SealedCopyTests { + @Test func theSwitchFollowsWhatTheEscrowHolds() async throws { + let world = ShellTests.World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + await world.run(200) + let copy = try #require(app.sealedCopy) + // Pairing sealed a copy in the background; the screen reads it again. + copy.reload() + #expect(copy.kept) + #expect(world.escrow.rows.values.first?.blob.isEmpty == false) + + let off = Task { await copy.set(false) } + await world.run(200) + await off.value + #expect(!copy.kept) + #expect(copy.stage == .idle) + #expect(app.sites.all().first?.escrow == false) + #expect(world.escrow.rows.values.first?.blob.isEmpty == true) + + let on = Task { await copy.set(true) } + await world.run(200) + await on.value + #expect(copy.kept) + #expect(app.sites.all().first?.escrow == true) + #expect(world.escrow.rows.values.first?.blob.isEmpty == false) + } + + @Test func aDeclinedPasskeyPutsTheMarkBack() async throws { + let world = ShellTests.World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + await world.run(200) + let copy = try #require(app.sealedCopy) + copy.reload() + world.passkeys.cancel = true + let off = Task { await copy.set(false) } + await world.run(200) + await off.value + #expect(copy.kept) + #expect(copy.stage == .idle) + #expect(copy.problem == nil) + #expect(app.sites.all().first?.escrow == true) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift b/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift new file mode 100644 index 0000000..bdbee2d --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift @@ -0,0 +1,83 @@ +import Foundation +@testable import FTWKit + +/// A passkey that always answers with the same PRF output, the way one +/// synced passkey answers on every device. +@MainActor +final class FakePasskeys: PasskeyAuthenticator { + var prf: Bytes? + var credentialID: String + var isAvailable = true + var cancel = false + var fail = false + var prfOnlyOnAssert = false + private(set) var registrations = 0 + private(set) var assertions: [[String]] = [] + + init(prf: Bytes? = Bytes(hex: "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf"), credentialID: String = "Y3JlZC0x") { + self.prf = prf + self.credentialID = credentialID + } + + func register(label: String, userHandle: Bytes, excludeCredentialIDs: [String]) async throws -> PasskeyOutcome { + registrations += 1 + if cancel { throw PasskeyCancelled() } + if fail { throw Primitives.CryptoFailure(message: "platform failed") } + if prfOnlyOnAssert { return PasskeyOutcome(credentialID: credentialID, prfOutput: nil, prfEnabled: prf != nil) } + return PasskeyOutcome(credentialID: credentialID, prfOutput: prf) + } + + func assert(credentialIDs: [String]) async throws -> PasskeyOutcome { + assertions.append(credentialIDs) + if cancel { throw PasskeyCancelled() } + if fail { throw Primitives.CryptoFailure(message: "platform failed") } + return PasskeyOutcome(credentialID: credentialID, prfOutput: prf) + } +} + +/// The escrow service's rules, from escrow/src in the web app repository: +/// one length for every request, a signed write whose key is pinned on first +/// use, and a version that must be the immediate successor. +@MainActor +final class FakeEscrowService { + struct Row { + var version: UInt32 + var blob: Bytes + var writeKey: Bytes + } + + var rows: [String: Row] = [:] + var offline = false + private(set) var requests = 0 + + var transport: Escrow.Transport { + { [unowned self] body in try self.handle(body) } + } + + func handle(_ body: Bytes) throws -> (status: Int, body: Bytes) { + requests += 1 + if offline { throw URLError(.notConnectedToInternet) } + guard body.count == Escrow.requestBytes, + let json = try JSONSerialization.jsonObject(with: Data(body)) as? [String: Any], + let id = json["id"] as? String else { return (400, []) } + switch json["op"] as? String { + case "get": + guard let row = rows[id] else { return (404, []) } + let answer: [String: Any] = ["version": row.version, "blob": Data(row.blob).base64EncodedString()] + return (200, try JSONSerialization.data(withJSONObject: answer).byteArray) + case "put": + guard let version = (json["version"] as? NSNumber)?.uint32Value, + let blob = (json["blob"] as? String).flatMap({ Data(base64Encoded: $0) })?.byteArray, + blob.count == RecoveryBlob.maxBytes || blob.isEmpty, + let pub = (json["pub"] as? String).flatMap({ try? Base64url.decode($0) }), + let sig = (json["sig"] as? String).flatMap({ try? Base64url.decode($0) }) else { return (400, []) } + guard Primitives.ed25519Verify(publicKey: pub, signature: sig, message: Escrow.writeMessage(lookupID: id, version: version, blob: blob)) else { return (403, []) } + if let held = rows[id], held.writeKey != pub { return (403, []) } + guard version == (rows[id]?.version ?? 0) + 1 else { return (409, []) } + rows[id] = Row(version: version, blob: blob, writeKey: pub) + return (200, Array(#"{"version":\#(version)}"#.utf8)) + default: + return (400, []) + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift new file mode 100644 index 0000000..4cd43f3 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift @@ -0,0 +1,200 @@ +import Foundation +import Testing +@testable import FTWKit + +/// The sentences and figures the web app's own tests pin, so both apps say +/// the same thing about the same house. +@Suite struct FormatTests { + typealias F = Contract.FID + + @Test func powerNeverShowsARawMinus() { + for w in [-1_000_000.0, -4200, -60, 0, 60, 4200] { + #expect(!PowerFormat.parts(w).text.hasPrefix("-")) + } + #expect(PowerFormat.parts(4200).direction == .into) + #expect(PowerFormat.parts(-4200).direction == .out) + #expect(PowerFormat.direction(PowerFormat.noiseW - 1) == .idle) + #expect(PowerFormat.direction(-PowerFormat.noiseW) == .out) + #expect(PowerFormat.parts(9900).text == "9.9") + #expect(PowerFormat.parts(11_400).text == "11") + #expect(PowerFormat.scale(5000) == "5 kW") + #expect(PowerFormat.scale(1500) == "1.5 kW") + #expect(PowerFormat.scale(-5000) == PowerFormat.scale(5000)) + #expect(PowerFormat.scale(.nan) == "") + #expect(PowerFormat.soc(875) == "88") + #expect(PowerFormat.soc(.nan) == "—") + #expect(PowerFormat.age(0) == "just now") + #expect(PowerFormat.age(30_000) == "30s ago") + #expect(PowerFormat.age(90_000) == "1 min ago") + #expect(PowerFormat.age(3_600_000) == "1 h ago") + #expect(PowerFormat.age(nil) == "unknown") + } + + @Test func explanationsMatchTheWebApp() { + func explain(_ f: [Int: Double], blocked: [String] = [], ceiling: Double? = nil) -> Explanation.Result { + Explanation.explain(fields: f, dispatchBlockedBy: blocked, ceilingW: ceiling) + } + let shaving = explain([F.gridW: 11_000, F.pvW: 0, F.batteryW: -4200, F.loadW: 15_200], ceiling: 11_000) + #expect(shaving.situation == .batteryShaving) + #expect(shaving.headline == "The battery is supplying 4.2 kW to keep grid import below 11 kW.") + let covering = explain([F.gridW: 0, F.pvW: 0, F.batteryW: -2100, F.loadW: 2100]) + #expect(covering.headline == "The battery is covering the house, so nothing is coming from the grid.") + let importing = explain([F.gridW: 2400, F.pvW: 0, F.batteryW: 0, F.loadW: 2400]) + #expect(importing.headline == "The house is drawing 2.4 kW from the grid.") + #expect(explain([F.gridW: -3000, F.pvW: -5000, F.batteryW: 0, F.loadW: 2000]).situation == .exportingSurplus) + #expect(explain([F.gridW: 0, F.pvW: -5000, F.batteryW: 3000, F.loadW: 2000]).situation == .chargingFromSurplus) + #expect(explain([F.gridW: 10, F.pvW: -2000, F.batteryW: 0, F.loadW: 2000]).situation == .solarCovering) + #expect(explain([F.gridW: 800, F.pvW: -1200, F.batteryW: 0, F.loadW: 2000]).situation == .solarPartial) + #expect(explain([F.gridW: 2400, F.loadW: 2400], blocked: ["meter"]).situation == .dispatchBlocked) + #expect(explain([:]).situation == .noData) + #expect(!explain([F.gridW: -3000, F.pvW: -5000, F.batteryW: -100, F.loadW: 2000]).headline.contains("-")) + } + + @Test func planHeadlineNamesTheNextChange() { + let now = 1_750_000_000_000.0 + let slots = [ + PlanSlot(startMs: now - 60_000, durationMs: 900_000, batteryW: 3000, gridW: 3000, priceMinor: 30, reason: "cheap_import"), + PlanSlot(startMs: now + 840_000, durationMs: 900_000, batteryW: 3000, gridW: 3000, priceMinor: 30, reason: "cheap_import"), + PlanSlot(startMs: now + 1_740_000, durationMs: 900_000, batteryW: -2000, gridW: 0, priceMinor: 150, reason: "expensive_import"), + ] + let h = PlanText.headline(Plan(rev: 1, uptimeMs: 0, slots: slots, stale: false, ceilingW: nil), nowMs: now) + #expect(h.text == "The battery is charging at 3.0 kW — power is cheap. Then it covers the house at 2.0 kW in about half an hour.") + #expect(PlanText.headline(nil, nowMs: now).text == "No plan yet.") + #expect(PlanText.headline(Plan(rev: 1, uptimeMs: 0, slots: [], stale: true, ceilingW: nil), nowMs: now).text.hasPrefix("Your box couldn't plan ahead")) + } + + @Test func pricesInTheChartsUnit() { + #expect(PriceUnits.text(144, "SEK") == "144.0") + #expect(PriceUnits.unit("EUR").perKwh == "cent/kWh") + #expect(PriceUnits.text(400, "CZK") == "4.00") + #expect(PriceUnits.unit("XYZ").perKwh == "XYZ/kWh") + let day: Double = 1_750_000_000_000 + let slots = [PriceSlot(startMs: day, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1), PriceSlot(startMs: day + 7_200_000, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1)] + #expect(PriceUnits.hasHole(slots, fromMs: day)) + #expect(PriceUnits.hasHole(Array(slots.suffix(1)), fromMs: day)) + #expect(!PriceUnits.hasHole(Array(slots.prefix(1)), fromMs: day)) + } + + @Test func savingsAreSignedMajorUnits() { + #expect(Savings.compact(1240) == "+12.4") + #expect(Savings.compact(-310) == "−3.10") + #expect(Savings.compact(12_345) == "+123") + let days = (1...9).map { Savings.Day(day: String(format: "2026-09-%02d", $0), savedOre: 100, resolution: $0 == 9 ? "no_prices" : "slot") } + let p = Savings.periods(days) + #expect(!p.today.available) + #expect(p.week.savedMinor == 600) + #expect(!p.week.complete) + } + + @Test func evSentencesSayOnlyWhatTheBoxSaid() throws { + let unplugged = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":false,"current_soc":0.5}"#.utf8))) + #expect(EVText.status(unplugged) == "Not plugged in") + #expect(unplugged.socPct == nil) + + let charging = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_power_w":7200,"current_soc":0.41,"delivered_wh_session":3040}"#.utf8))) + #expect(EVText.status(charging) == "Charging at 7.2 kW") + #expect(charging.socPct == 41) + #expect(EVText.session(charging) == "3.0 kWh this session") + + let paused = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":0,"manual":{"state":"paused"}}"#.utf8))) + #expect(EVText.isPaused(paused)) + #expect(EVText.status(paused).hasPrefix("Paused by you.")) + + let stale = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"charger":{"known":true,"available":false}}"#.utf8))) + #expect(EVText.status(stale) == "Charger status is out of date. FTW cannot confirm whether the car is charging.") + + #expect(EVText.days(0) == "every day") + #expect(EVText.days(0b0011111) == "weekdays") + #expect(EVText.days(0b1100000) == "weekends") + #expect(EVText.days(0b0000101) == "Mon, Wed") + } + + @Test func chargeCurrentFallsBackLikeTheBoxPage() throws { + let bare = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true}"#.utf8))) + #expect(EVText.current(bare) == EVText.Current(minA: 6, maxA: 16, wattsPerAmp: 690, phases: 3)) + let capped = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"max_charge_w":11000,"min_charge_w":4140}"#.utf8))) + // 16 A is 11 040 W, above the charger's ceiling: the ceiling wins. + #expect(EVText.watts(capped, amps: 16) == 11_000) + #expect(EVText.readout(capped, amps: 16) == "16 A · 11.0 kW") + } + + @Test func scheduleClockConvertsThroughUTC() { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "Europe/Stockholm")! + let summer = Date(timeIntervalSince1970: 1_750_000_000) + let utc = EVText.minuteUTC(hour: 7, minute: 0, at: summer, calendar: cal) + #expect(utc == 5 * 60) + let back = EVText.localTime(minuteUTC: 300, at: summer, calendar: cal) + #expect(back.hour == 7 && back.minute == 0) + #expect(EVText.minuteUTC(hour: 25, minute: 99, calendar: cal) == nil) + } + + @Test func flowUsesMagnitudesAndDirections() { + let r = Flow.readings(fields: [F.gridW: -1500, F.pvW: -4000, F.batteryW: 1200, F.batterySoc: 612, F.loadW: 1300]) + let grid = r.nodes.first { $0.role == .grid }! + #expect(grid.kw == 1.5) + #expect(!grid.toHub) + #expect(grid.sub == "exporting") + let battery = r.nodes.first { $0.role == .battery }! + #expect(battery.sub == "charging") + #expect(battery.socPct == 61) + #expect(!r.nodes.contains { $0.role == .ev }) + let overlaid = Flow.withLoadpointEV([F.loadW: 9000, F.evW: 0], evW: 7400) + #expect(overlaid[F.evW] == 7400) + #expect(overlaid[F.loadW] == 1600) + } + + @Test func theBandMatchesTheWebAppsCases() { + func band(_ carrier: CarrierKind, transport: CarrierKind = .none, _ src: SourceState, age: Double?, _ phase: SessionPhase, wait: Double = 0, boot: Int? = nil, noCarrier: Bool = false) -> Freshness.Band { + Freshness.band(carrier: carrier, transport: transport, srcState: src, ageMs: age, phase: phase, waitMs: wait, bootPct: boot, noCarrier: noCarrier) + } + // A decision about the phone is not a connection fault. + let ended = band(.none, .stale, age: 7_200_000, .terminated) + #expect(ended.message == "Access ended") + // No carrier at all: no promise of a retry. + let lost = band(.none, .stale, age: 60_000, .failed, noCarrier: true) + #expect(lost.message == "Can't reach your box") + #expect(lost.tone == .lost) + #expect(lost.wait == nil) + // A carrier healing itself says so, with its elapsed time. + let healing = band(.none, .stale, age: 60_000, .failed, wait: 7_000) + #expect(healing.message == "Reconnecting to your box") + #expect(healing.wait == "7s") + #expect(healing.age == "1 min ago") + // Live only for live readings. + let quiet = band(.relay, .stale, age: 5_000, .streaming) + #expect(quiet.message == "Encrypted relay connected · readings") + #expect(quiet.tone == .stale) + #expect(!quiet.message.lowercased().contains("live")) + #expect(band(.relay, .live, age: 0, .streaming).message == "Live via encrypted relay") + #expect(band(.relay, .live, age: 0, .streaming).age == nil) + // A starting box shows its own progress. + let booting = band(.none, transport: .relay, .stale, age: 5_000, .booting, boot: 40) + #expect(booting.message == "Your box is starting") + #expect(booting.wait == "40%") + // After a restart the box cannot place the reading: a dash, never a guess. + #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).age == "—") + #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).message == "Securing encrypted relay") + } + + @Test func thePriceCardFindsTheCheapestTwoHoursAhead() { + let hour = 3_600_000.0 + let day = 1_750_000_000_000.0 + let totals: [Double] = [100, 120, 40, 30, 35, 200, -5, 90] + let slots = totals.enumerated().map { PriceSlot(startMs: day + Double($0.offset) * hour, durationMs: hour, spotMinor: $0.element, totalMinor: $0.element) } + let prices = Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false) + let s = PriceStrip.summary(prices, nowMs: day + 1.5 * hour) + #expect(s.current?.totalMinor == 120) + // The slot already over is not ahead. + #expect(s.bars.count == 7) + #expect(s.bars.first?.current == true) + #expect(s.bars.first(where: { $0.minor == -5 })?.tone == .negative) + #expect(s.bars.first(where: { $0.minor == 200 })?.tone == .dear) + #expect(s.bars.first(where: { $0.minor == 30 })?.tone == .cheap) + // 30 then 35 beats 40 then 30: two whole hours in a row, the lowest mean. + #expect(s.cheapest == PriceStrip.Block(meanMinor: 32.5, startMs: day + 3 * hour, endMs: day + 5 * hour)) + #expect(PriceStrip.text(144.4, "SEK") == "144") + #expect(PriceStrip.text(14.44, "SEK") == "14.4") + #expect(PriceStrip.text(nil, "SEK") == "—") + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/FrameTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/FrameTests.swift new file mode 100644 index 0000000..2e9f8cf --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/FrameTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing +@testable import FTWKit + +@Suite struct FrameTests { + @Test func everySharedFrameDecodesAndReencodesByteForByte() throws { + let v = try InteropVectors.load() + #expect(!v.frames.isEmpty) + for want in v.frames { + let wire = Bytes(hex: want.bytes) + #expect(wire.count == want.bucket, "\(want.name)") + let frame = try Frame.decode(wire) + #expect(frame.lane == want.lane, "\(want.name)") + #expect(frame.flags == want.flags, "\(want.name)") + + // The whole payload survives a round trip, unknown keys included, + // because maps keep their order. + let payload = Array(wire[Frame.headerBytes..), .map(["seq": .int(1), "uptimeMs": .int(123_456_789)])] { + let frame = try Frame.encode(lane: Frame.laneControl, envelope: Envelope(t: "tick", b: body), bucket: 512) + #expect(frame.count == 512) + } + } + + @Test func refusesToGrowTheBucket() { + let big = Envelope(t: "x", b: .bytes(Bytes(repeating: 7, count: 600))) + #expect(throws: Frame.FrameError.self) { try Frame.encode(lane: 0, envelope: big, bucket: 512) } + } + + @Test func bulkPicksTheSmallestBucket() throws { + #expect(try Frame.encodeBulk(envelope: Envelope(t: "plan.get", id: 1)).count == 1024) + #expect(try Frame.encodeBulk(envelope: Envelope(t: "x", b: .bytes(Bytes(repeating: 0, count: 2000)))).count == 4096) + } + + @Test func junkIsRefusedWithACode() { + #expect(throws: Frame.FrameError.self) { try Frame.decode([1, 0]) } + #expect(throws: Frame.FrameError.self) { try Frame.decode([2, 0, 0, 0, 0, 0]) } + #expect(throws: Frame.FrameError.self) { try Frame.decode([1, 0, 0, 0, 0, 9, 0xa0]) } + } +} + +@Suite struct CBORTests { + // The web app's own bytes, as the Kotlin suite pinned them. + @Test func encodesTheAppsOwnHello() { + let hello = CBOR.map([ + ("t", .text("hello")), + ("b", .map([ + ("proto", .map([("min", .int(0)), ("max", .int(1))])), + ("app", .map([("build", .text("test")), ("ua", .text("pwa"))])), + ("locales", .array([.text("sv")])), + ])), + ]) + #expect(encodeCBOR(hello).hex == "a261746568656c6c6f6162a36570726f746fa2636d696e00636d61780163617070a2656275696c64647465737462756163707761676c6f63616c657381627376") + } + + @Test func encodesSub() { + let sub = CBOR.map([("t", .text("sub")), ("b", .map([("bucket", .int(512)), ("hz", .number(1))]))]) + #expect(encodeCBOR(sub).hex == "a26174637375626162a2666275636b657419020062687a01") + } + + @Test func floatsTakeTheShortestExactForm() { + // What cbor2, the web app's encoder, writes for the same numbers. + #expect(encodeCBOR(.map(["hz": .number(0.2)])).hex == "a162687afb3fc999999999999a") + #expect(encodeCBOR(.map(["hz": .number(1.5)])).hex == "a162687af93e00") + #expect(encodeCBOR(.map(["v": .number(0.5)])).hex == "a16176f93800") + #expect(encodeCBOR(.map(["v": .number(-3)])).hex == "a1617622") + #expect(encodeCBOR(.map(["v": .number(1e10)])).hex == "a161761b00000002540be400") + #expect(encodeCBOR(.map(["v": .number(4_294_967_296)])).hex == "a161761b0000000100000000") + } + + @Test func roundTripsEveryKind() throws { + let value = CBOR.map([ + ("u", .int(Int64(UInt32.max) + 5)), + ("n", .int(-1_000_000)), + ("b", .bytes([0, 1, 2, 255])), + ("s", .text("åäö")), + ("a", .array([.bool(true), .bool(false), .null])), + ("d", .double(0.1)), + ("f", .double(3.5)), + ]) + let decoded = try decodeCBOR(encodeCBOR(value)) + #expect(decoded == value) + #expect(decoded["n"]?.int64 == -1_000_000) + #expect(decoded["f"]?.double == 3.5) + } + + @Test func refusesDuplicateKeysAndTrailingBytes() { + #expect(throws: CBOR.DecodeError.self) { try decodeCBOR(Bytes(hex: "a2617401617402")) } + #expect(throws: CBOR.DecodeError.self) { try decodeCBOR(Bytes(hex: "0102")) } + } + + @Test func refusesLengthsPastTheInput() { + #expect(throws: CBOR.DecodeError.self) { try decodeCBOR(Bytes(hex: "5bffffffffffffffff")) } + #expect(throws: CBOR.DecodeError.self) { try decodeCBOR(Bytes(hex: "9bffffffffffffffff")) } + } + + @Test func readsIndefiniteLengths() throws { + #expect(try decodeCBOR(Bytes(hex: "9f0102ff")) == .array([.int(1), .int(2)])) + #expect(try decodeCBOR(Bytes(hex: "bf616101ff"))["a"] == .int(1)) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift new file mode 100644 index 0000000..000e8b1 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift @@ -0,0 +1,350 @@ +import Foundation +import Testing +@testable import FTWKit + +/// Vectors produced by running the web app's own identity code +/// (src/lib/identity and src/lib/carrier/rendezvous in srcfl/ftw-webapp). +struct IdentityVectors: Decodable { + struct Handle: Decodable { let epoch: Int64; let handle: String } + struct Rendezvous: Decodable { let secret: String; let handles: [Handle] } + struct PRFVector: Decodable { + let output: String + let credentialId: String + let lookupId: String + let writeKey: String + let signedMessage: String + let signature: String + } + struct Home: Decodable { let siteId: String; let label: String; let boxStaticKey: String; let rendezvousSecret: String } + struct Blob: Decodable { let escrowVersion: UInt32; let sealed: String; let deviceScalar: String; let homes: [Home] } + struct VaultCopy: Decodable { let publicKey: String; let iv: String; let ct: String; let scalar: String } + struct EnrollmentVector: Decodable { + let url: String + let boxStaticPublic: String + let pairingCode: String + let lanHint: String + let rendezvousSecret: String + let siteId: String + let fingerprint: String + let deviceIdOnBox: String + } + + let rendezvous: Rendezvous + let prf: PRFVector + let recoveryBlob: Blob + let vaultCopy: VaultCopy + let enrollment: EnrollmentVector + + static func load() throws -> IdentityVectors { + guard let url = Bundle.module.url(forResource: "identity-vectors", withExtension: "json", subdirectory: "Resources") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(IdentityVectors.self, from: Data(contentsOf: url)) + } +} + +@Suite struct CrossImplementationTests { + @Test func rendezvousHandlesMatchTheWebApp() throws { + let v = try IdentityVectors.load() + for h in v.rendezvous.handles { + #expect(try Rendezvous.handle(secret: Bytes(hex: v.rendezvous.secret), epoch: h.epoch) == h.handle) + } + #expect(Rendezvous.epoch(nowMs: 0) == 0) + #expect(Rendezvous.epoch(nowMs: Rendezvous.epochMs - 1) == 0) + #expect(Rendezvous.epoch(nowMs: Rendezvous.epochMs) == 1) + } + + @Test func passkeyDerivationsMatchTheWebApp() throws { + let v = try IdentityVectors.load() + let wrapping = PRF.wrappingKey(credentialID: v.prf.credentialId, prfOutput: Bytes(hex: v.prf.output)) + let keys = try #require(wrapping.escrow) + #expect(keys.lookupID == v.prf.lookupId) + #expect(keys.writeKey.hex == v.prf.writeKey) + // The web app's signature verifies under the key derived here. + #expect(Primitives.ed25519Verify(publicKey: keys.writeKey, signature: Bytes(hex: v.prf.signature), message: Bytes(hex: v.prf.signedMessage))) + // And the signature made here verifies under the web app's key. + let mine = try keys.sign(Bytes(hex: v.prf.signedMessage)) + #expect(Primitives.ed25519Verify(publicKey: Bytes(hex: v.prf.writeKey), signature: mine, message: Bytes(hex: v.prf.signedMessage))) + } + + @Test func aRecoveryCopyTheWebAppSealedOpensHere() throws { + let v = try IdentityVectors.load() + let keys = try #require(PRF.wrappingKey(credentialID: v.prf.credentialId, prfOutput: Bytes(hex: v.prf.output)).escrow) + let sealed = Bytes(hex: v.recoveryBlob.sealed) + #expect(sealed.count == RecoveryBlob.maxBytes) + let contents = try RecoveryBlob.open(key: keys.sealKey, sealed, escrowVersion: v.recoveryBlob.escrowVersion) + #expect(contents.deviceScalar.hex == v.recoveryBlob.deviceScalar) + #expect(contents.homes.map(\.siteId) == v.recoveryBlob.homes.map(\.siteId)) + #expect(contents.homes.map(\.label) == v.recoveryBlob.homes.map(\.label)) + #expect(contents.homes.map { $0.boxStaticKey.hex } == v.recoveryBlob.homes.map(\.boxStaticKey)) + #expect(contents.homes.map { $0.rendezvousSecret.hex } == v.recoveryBlob.homes.map(\.rendezvousSecret)) + + // The version is bound in: the same bytes under another number fail. + #expect(throws: RecoveryBlob.BlobError.self) { + _ = try RecoveryBlob.open(key: keys.sealKey, sealed, escrowVersion: v.recoveryBlob.escrowVersion + 1) + } + } + + @Test func aVaultCopyTheWebAppSealedOpensUnderTheSamePasskey() throws { + let v = try IdentityVectors.load() + let wrapping = PRF.wrappingKey(credentialID: v.prf.credentialId, prfOutput: Bytes(hex: v.prf.output)) + let plain = try Primitives.aesGCMOpen(key: wrapping.key, nonce: Bytes(hex: v.vaultCopy.iv), ciphertext: Bytes(hex: v.vaultCopy.ct)) + #expect(plain == Vault.pkcs8Prefix + Bytes(hex: v.vaultCopy.scalar)) + } + + @Test func pairingURLAndNamesMatchTheWebApp() throws { + let v = try IdentityVectors.load() + let e = try Enrollment.parse(scanned: v.enrollment.url) + #expect(e.boxStaticPublic.hex == v.enrollment.boxStaticPublic) + #expect(e.pairingCode.hex == v.enrollment.pairingCode) + #expect(e.lanHint == v.enrollment.lanHint) + #expect(e.rendezvousSecret.hex == v.enrollment.rendezvousSecret) + #expect(e.url() == v.enrollment.url) + #expect(SiteList.siteID(e.boxStaticPublic) == v.enrollment.siteId) + #expect(SiteList.fingerprint(e.boxStaticPublic) == v.enrollment.fingerprint) + let pub = Bytes(hex: v.vaultCopy.publicKey) + #expect(String(Base64url.encode(pub).prefix(8)) == v.enrollment.deviceIdOnBox) + } +} + +@Suite struct EnrollmentTests { + let good = Enrollment(boxStaticPublic: Bytes(repeating: 1, count: 32), pairingCode: Bytes(repeating: 2, count: 16), lanHint: "10.0.0.2:8080", rendezvousSecret: Bytes(repeating: 3, count: 32)) + + @Test func acceptsAURLAndABareFragment() throws { + let url = good.url() + #expect(try Enrollment.parse(scanned: url) == good) + #expect(try Enrollment.parse(scanned: " " + url + "\n") == good) + let fragment = String(url[url.firstIndex(of: "#")!...]) + #expect(try Enrollment.parse(scanned: fragment) == good) + } + + @Test func refusesAnotherHostOrPath() { + let url = good.url() + for bad in [url.replacingOccurrences(of: "app.ftw.energy", with: "app.ftw.energy.evil.example"), + url.replacingOccurrences(of: "https://", with: "http://"), + url.replacingOccurrences(of: "/p#", with: "/q#"), + "https://example.com/p" + url[url.firstIndex(of: "#")!...]] { + #expect(throws: EnrollmentError.self) { _ = try Enrollment.parse(scanned: bad) } + } + } + + @Test func versionErrorsNameTheSideThatIsBehind() { + let url = good.url() + do { + _ = try Enrollment.parse(scanned: url.replacingOccurrences(of: "#v2.", with: "#v1.")) + Issue.record("v1 parsed") + } catch let e as EnrollmentError { + #expect(e.code == "E_QR_VERSION") + #expect(e.help.contains("box needs a software update")) + } catch { Issue.record("\(error)") } + do { + _ = try Enrollment.parse(scanned: url.replacingOccurrences(of: "#v2.", with: "#v3.")) + Issue.record("v3 parsed") + } catch let e as EnrollmentError { + #expect(e.help.contains("newer version of the app")) + } catch { Issue.record("\(error)") } + } + + @Test func refusesNonCanonicalBase64AndWrongLengths() { + var short = good + short.rendezvousSecret = Bytes(repeating: 3, count: 16) + #expect(throws: EnrollmentError.self) { _ = try Enrollment.parse(scanned: short.url()) } + // A final character with bits set past the last byte. + #expect(throws: Base64url.DecodeError.self) { _ = try Base64url.decode("AB") } + #expect((try? Base64url.decode("AA")) == [0]) + } +} + +@Suite struct BoxCodeTests { + // Produced by the box's Go encoder, as the web app's suite pins them. + let vectors = [ + ("0000000000", "0000-0000"), + ("ffffffffff", "ZZZZ-ZZZZ"), + ("0000000001", "0000-0001"), + ("0123456789", "04HM-ASW9"), + ("deadbeef42", "VTPV-XVT2"), + ("8f1c00a57b", "HWE0-19BV"), + ("1084210842", "2222-2222"), + ] + + @Test func decodesWhatTheBoxDrew() throws { + for (hex, code) in vectors { + #expect(try BoxCode.decode(code).hex == hex, "\(code)") + } + } + + @Test func foldsWhatListenersMishear() throws { + #expect(try BoxCode.decode("IO1L-0000").hex == "0802100000") + #expect(try BoxCode.decode("l0i1-oooo").hex == "0802100000") + for typed in ["04hm-asw9", "04HMASW9", "04 HM AS W9", " 04hm asw9 ", "04HM\tASW9"] { + #expect(try BoxCode.decode(typed).hex == "0123456789") + } + #expect(BoxCode.fold("04hm-asw9") == "04HMASW9") + #expect(BoxCode.fold("04HMASW9ZZZZ") == "04HMASW9") + #expect(BoxCode.group("04HMASW9") == "04HM-ASW9") + } + + @Test func refusesWhatIsNotACode() { + #expect(throws: BoxCode.BoxCodeError.self) { _ = try BoxCode.decode("UUUU-UUUU") } + #expect(throws: BoxCode.BoxCodeError.self) { _ = try BoxCode.decode("04HM-ASW") } + #expect(throws: BoxCode.BoxCodeError.self) { _ = try BoxCode.decode("04HM!ASW9") } + } +} + +@MainActor +@Suite struct VaultTests { + @Test func enrollingMakesAPasskeyCopyAndALocalOne() async throws { + let store = MemoryStore() + let vault = Vault(store: store) + let passkeys = FakePasskeys() + let wrapping = try await vault.enrollWrappingKey(passkeys) + #expect(wrapping.source == .prf) + let pair = try vault.deviceKey(wrapping) + try vault.ensureLocalCopy(wrapping) + #expect(vault.credentialIDs.sorted() == ["Y3JlZC0x", "local"]) + #expect(vault.passkeyCredentialIDs == ["Y3JlZC0x"]) + + // Reading needs no prompt at all. + let silent = try #require(try vault.silentWrappingKey()) + #expect(try vault.deviceKey(silent).publicKey == pair.publicKey) + #expect(vault.devicePublic == pair.publicKey) + } + + @Test func aDeclineIsRethrownAndAFailureFallsBack() async throws { + let vault = Vault(store: MemoryStore()) + let passkeys = FakePasskeys() + passkeys.cancel = true + await #expect(throws: PasskeyCancelled.self) { _ = try await vault.enrollWrappingKey(passkeys) } + passkeys.cancel = false + passkeys.fail = true + #expect(try await vault.enrollWrappingKey(passkeys).source == .local) + } + + @Test func prfThatArrivesOnlyOnAssertionCostsOneMorePrompt() async throws { + let vault = Vault(store: MemoryStore()) + let passkeys = FakePasskeys() + passkeys.prfOnlyOnAssert = true + let wrapping = try await vault.enrollWrappingKey(passkeys) + #expect(wrapping.source == .prf) + #expect(passkeys.assertions == [["Y3JlZC0x"]]) + } + + @Test func restoreRefusesToOverwriteAnotherIdentity() async throws { + let vault = Vault(store: MemoryStore()) + let local = vault.localWrappingKey() + _ = try vault.deviceKey(local) + #expect(throws: Vault.VaultError.self) { try vault.restoreDeviceKey(local, scalar: randomBytes(32)) } + } + + @Test func theLastCopyCannotBeRemoved() throws { + let vault = Vault(store: MemoryStore()) + _ = try vault.deviceKey(vault.localWrappingKey()) + #expect(throws: Vault.VaultError.self) { try vault.removeCredential(Vault.localCredentialID) } + } + + @Test func stepUpNeverClaimsACeremonyThatDidNotHappen() async throws { + let vault = Vault(store: MemoryStore()) + let passkeys = FakePasskeys() + // Only a local copy: nothing to prompt with. + _ = try vault.deviceKey(vault.localWrappingKey()) + #expect(await vault.stepUp(passkeys) == .unavailable) + + let other = Vault(store: MemoryStore()) + let wrapping = try await other.enrollWrappingKey(passkeys) + _ = try other.deviceKey(wrapping) + #expect(await other.stepUp(passkeys) == .done) + passkeys.cancel = true + #expect(await other.stepUp(passkeys) == .declined) + passkeys.cancel = false + passkeys.fail = true + #expect(await other.stepUp(passkeys) == .unavailable) + } +} + +@MainActor +@Suite struct EscrowTests { + func setUp() -> (Vault, SiteList, FakeEscrowService, Escrow, FakePasskeys) { + let store = MemoryStore() + let vault = Vault(store: store) + let sites = SiteList(store: store) + let service = FakeEscrowService() + return (vault, sites, service, Escrow(vault: vault, sites: sites, transport: service.transport), FakePasskeys()) + } + + @Test func requestsArePaddedToOneLength() throws { + let body = Escrow.padded([("op", .string("get")), ("id", .string("abc"))]) + #expect(body.count == 1024) + let json = try JSONSerialization.jsonObject(with: Data(body)) as? [String: Any] + #expect(json?["op"] as? String == "get") + } + + @Test func pairingHoldsASealedCopyAndANewPhoneGetsTheHomeBack() async throws { + let (vault, sites, service, escrow, passkeys) = setUp() + let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys, now: { 1_000 }) + let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + let paired = try await pairing.pair(scanned: enrollment.url()) + #expect(await paired.sealed.value == .saved) + #expect(sites.get(paired.site.siteId)?.escrow == true) + #expect(service.rows.count == 1) + let devicePublic = try #require(vault.devicePublic) + + // A new phone: empty storage, the same synced passkey. + let store2 = MemoryStore() + let vault2 = Vault(store: store2) + let sites2 = SiteList(store: store2) + let escrow2 = Escrow(vault: vault2, sites: sites2, transport: service.transport) + let found = try await escrow2.recover(passkeys) + #expect(found.map(\.siteId) == [paired.site.siteId]) + #expect(found.first?.fingerprint == SiteList.fingerprint(enrollment.boxStaticPublic)) + try escrow2.adopt(try #require(found.first), nowMs: 2_000) + // The same identity the box already trusts, with no pairing code. + #expect(vault2.devicePublic == devicePublic) + #expect(sites2.get(paired.site.siteId)?.pairingCode == nil) + #expect(sites2.get(paired.site.siteId)?.rendezvousSecret?.byteArray == enrollment.rendezvousSecret) + #expect(try vault2.silentWrappingKey() != nil) + #expect(vault2.passkeyCredentialIDs == [passkeys.credentialID]) + } + + @Test func removingEmptiesTheCopyAndKeepsTheVersionGoing() async throws { + let (vault, sites, service, escrow, passkeys) = setUp() + let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys) + let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + let paired = try await pairing.pair(scanned: enrollment.url()) + _ = await paired.sealed.value + #expect(await escrow.remove(passkeys) == .removed) + let row = try #require(service.rows.values.first) + #expect(row.blob.isEmpty) + #expect(row.version == 2) + // Nothing held now reads as nothing held. + let store2 = MemoryStore() + #expect(try await Escrow(vault: Vault(store: store2), sites: SiteList(store: store2), transport: service.transport).recover(passkeys).isEmpty) + } + + @Test func noOptInCostsNoPromptAndNoRequest() async { + let (_, _, service, escrow, passkeys) = setUp() + #expect(await escrow.remove(passkeys) == .nothingToRemove) + #expect(passkeys.assertions.isEmpty) + #expect(service.requests == 0) + } + + @Test func offlineLeavesThePairingIntactAndUnmarked() async throws { + let (vault, sites, service, escrow, passkeys) = setUp() + service.offline = true + let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys) + let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + let paired = try await pairing.pair(scanned: enrollment.url()) + #expect(await paired.sealed.value == .unreachable) + #expect(sites.get(paired.site.siteId)?.escrow == false) + #expect(sites.get(paired.site.siteId) != nil) + } + + @Test func aBoxCodeArmsAKnownHomeOnly() async throws { + let (vault, sites, _, escrow, passkeys) = setUp() + let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys) + #expect(throws: BoxCode.BoxCodeError.self) { try pairing.redeemBoxCode(siteId: "nope", typed: "04HM-ASW9") } + let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + let paired = try await pairing.pair(scanned: enrollment.url(), holdCopy: false) + try pairing.redeemBoxCode(siteId: paired.site.siteId, typed: "04hm asw9") + #expect(sites.get(paired.site.siteId)?.pairingCode?.byteArray.hex == "0123456789") + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift new file mode 100644 index 0000000..2e4a256 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +@testable import FTWKit + +/// Against a real FTW box, through the production relay. +/// +/// Off unless FTW_LIVE_URL holds a pairing URL the box just minted: +/// +/// curl -s -X POST localhost:8080/api/app-link/pairing -d '{"role":"owner"}' +/// FTW_LIVE_URL='https://app.ftw.energy/p#v2...' swift test --filter LiveBox +/// +/// On Linux, whose Foundation has no WebSocket client, run a bridge that +/// carries frames to the relay unchanged and set FTW_LIVE_RELAY to it. +/// +/// The passkey ceremony cannot run here, so the device key is wrapped under +/// the local key alone. Everything after that is the shipped path: the real +/// socket, the rotating handle, Noise IK with the pairing code in message 1, +/// the session, and the box's own API over it. +@MainActor +@Suite(.enabled(if: ProcessInfo.processInfo.environment["FTW_LIVE_URL"] != nil), .serialized) +struct LiveBoxTests { + @Test func pairsStreamsAndCallsTheBoxOverTheRelay() async throws { + let url = try #require(ProcessInfo.processInfo.environment["FTW_LIVE_URL"]) + let enrollment = try Enrollment.parse(scanned: url) + let vault = Vault(store: MemoryStore()) + let device = try vault.deviceKey(vault.localWrappingKey()) + + let scheduler = LiveScheduler() + // FTW_LIVE_RELAY points at a local bridge where Foundation has no + // WebSocket client of its own (Linux); elsewhere the real socket. + let relay: RelayCarrier + if let bridge = ProcessInfo.processInfo.environment["FTW_LIVE_RELAY"], let bridgeURL = URL(string: bridge) { + #if os(Linux) + relay = RelayCarrier(url: bridgeURL, secret: enrollment.rendezvousSecret, scheduler: scheduler, makeSocket: PlainWebSocket.factory()) + #else + relay = RelayCarrier(url: bridgeURL, secret: enrollment.rendezvousSecret, scheduler: scheduler) + #endif + } else { + relay = RelayCarrier(secret: enrollment.rendezvousSecret, scheduler: scheduler) + } + let noise = NoiseCarrier( + inner: relay, + staticKey: device, + remoteStatic: enrollment.boxStaticPublic, + prologue: NoiseCarrier.prologue(boxStaticKey: enrollment.boxStaticPublic), + handshakePayload: enrollment.pairingCode, + scheduler: scheduler + ) + let session = Session(build: "swift-live-test", ua: "test", scheduler: scheduler) + session.connect(noise) + defer { session.close() } + + let started = Date() + while session.state.phase != .streaming, Date().timeIntervalSince(started) < 40 { + try await Task.sleep(nanoseconds: 200_000_000) + } + print("live: phase \(session.state.phase) after \(String(format: "%.1f", Date().timeIntervalSince(started))) s, role \(session.state.role), caps \(session.state.caps.sorted())") + #expect(session.state.phase == .streaming) + #expect(session.state.heardFromBox) + #expect(session.state.fields[Contract.FID.gridW] != nil) + print("live: fields \(session.state.fields.sorted { $0.key < $1.key })") + + // A second of telemetry, then the box's own API over the session. + let uptime = session.state.uptimeMs + try await Task.sleep(nanoseconds: 2_500_000_000) + #expect(session.state.uptimeMs > uptime) + + let status = try await session.api(APIRequest(method: .get, path: "/api/status")) + #expect(status.status == 200) + let json = try JSON(parsing: status.body) + print("live: /api/status grid_w \(json["grid_w"]?.number ?? .nan), \(status.body.count) bytes") + #expect(json["grid_w"]?.number != nil) + + let plan = try await session.plan() + print("live: plan rev \(plan.rev), \(plan.slots.count) slots, stale \(plan.stale)") + + var tiles = 0 + let end = try await session.history(HistQuery(series: ["grid_w", "pv_w", "battery_w", "load_w"], res: .fiveMinutes, fromMs: Date().timeIntervalSince1970 * 1000 - 86_400_000, toMs: Date().timeIntervalSince1970 * 1000, have: [], maxPoints: 1500)) { _ in tiles += 1 } + print("live: history \(tiles) tiles, served \(end.resActual.rawValue)") + + // Reaching an actuating route through the passthrough is refused and + // points at the command instead. + do { + _ = try await session.api(APIRequest(method: .post, path: "/api/mode", body: Array(#"{"mode":"self_consumption"}"#.utf8))) + Issue.record("an actuating route answered through the passthrough") + } catch let refusal as BoxRefusal { + print("live: POST /api/mode refused with \(refusal.detail.code)") + #expect(refusal.detail.code == "E_USE_CMD") + } + + // The mode, set through the command door the box keeps for it. + if let current = session.state.fields[Contract.FID.mode].flatMap({ Int($0) }), current < session.state.modes.count { + let key = session.state.modes[current].key + let result = try await session.command(Contract.opSetMode, args: [("mode", .text(key))]) + print("live: site.mode.set \(key) -> \(result.state.rawValue)") + #expect(result.state == .applied || result.state == .unconfirmed) + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift b/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift new file mode 100644 index 0000000..53bba50 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift @@ -0,0 +1,179 @@ +import Foundation +@testable import FTWKit + +/// The box's end of Noise, around a `SimulatedBox`: what the Go box does in +/// front of its protocol handler. +@MainActor +final class NoiseBoxEndpoint { + let box: SimulatedBox + let staticKey = Primitives.generateKeyPair() + private var handshake: HandshakeState? + private var transport: NoiseTransport? + var toApp: (@MainActor (Bytes) -> Void)? + private(set) var handshakePayloads: [Bytes] = [] + /// Answer handshakes with silence, as a box does for a phone it refuses. + var refuse = false + + init(box: SimulatedBox) { + self.box = box + box.send = { [weak self] frame in + guard let self, let transport = self.transport, let wire = try? transport.encrypt(frame) else { return } + self.toApp?(wire) + } + } + + var prologue: Bytes { NoiseCarrier.prologue(boxStaticKey: staticKey.publicKey) } + + func receive(_ bytes: Bytes) { + if let transport, let frame = try? transport.decrypt(bytes) { + box.receive(frame) + return + } + // Anything else of the right length is a new handshake. + guard bytes.count >= Noise.message1Overhead, !refuse else { return } + let hs = HandshakeState.responder(staticKey: staticKey, prologue: prologue) + guard let payload = try? hs.readMessage(bytes), let reply = try? hs.writeMessage() else { return } + handshakePayloads.append(payload) + transport = NoiseTransport(try! hs.split()) + toApp?(reply) + } + + func dropSession() { + transport = nil + } +} + +/// A relay room with one box in it, reached through fake sockets. +@MainActor +final class FakeRelay { + let scheduler: ManualScheduler + let endpoint: NoiseBoxEndpoint + var latencyMs: Double = 10 + var boxOnline = true + private(set) var dialled: [URL] = [] + private(set) var sockets: [FakeSocket] = [] + + init(scheduler: ManualScheduler, endpoint: NoiseBoxEndpoint) { + self.scheduler = scheduler + self.endpoint = endpoint + endpoint.toApp = { [weak self] bytes in + guard let self, let socket = self.sockets.last(where: { !$0.closed }) else { return } + _ = self.scheduler.after(self.latencyMs) { socket.deliver(bytes) } + } + } + + var factory: WebSocketFactory { + { [unowned self] url, events in + self.dialled.append(url) + let socket = FakeSocket(relay: self, events: events) + self.sockets.append(socket) + _ = self.scheduler.after(self.latencyMs) { + guard !socket.closed else { return } + events.onOpen() + if self.boxOnline { events.onText("ready") } + } + return socket + } + } + + var current: FakeSocket? { sockets.last(where: { !$0.closed }) } + + /// The relay closes the socket with a code, as it does on a rotation. + func closeCurrent(code: Int, reason: String) { + guard let socket = current else { return } + socket.closed = true + socket.events.onClose(code, reason) + } + + func boxLeaves() { + boxOnline = false + current?.events.onText("gone") + } + + func boxReturns() { + boxOnline = true + endpoint.dropSession() + current?.events.onText("ready") + } +} + +@MainActor +final class FakeSocket: WebSocketConnection { + unowned let relay: FakeRelay + let events: WebSocketEvents + var closed = false + private(set) var sent: [Bytes] = [] + + init(relay: FakeRelay, events: WebSocketEvents) { + self.relay = relay + self.events = events + } + + func send(_ data: Bytes) { + guard !closed else { return } + sent.append(data) + _ = relay.scheduler.after(relay.latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.relay.endpoint.receive(data) + } + } + + func deliver(_ bytes: Bytes) { + guard !closed else { return } + events.onBinary(bytes) + } + + func close() { + closed = true + } +} + +/// The whole stack, as the app builds it, against the simulated box. +@MainActor +struct Rig { + let scheduler: ManualScheduler + let box: SimulatedBox + let endpoint: NoiseBoxEndpoint + let relay: FakeRelay + let relayCarrier: RelayCarrier + let noise: NoiseCarrier + let session: Session + let pairingCode: Bytes + + init(requireStepUp: Bool = false, connect: Bool = true) { + let scheduler = ManualScheduler() + self.scheduler = scheduler + box = SimulatedBox(scheduler: scheduler, requireStepUp: requireStepUp) + endpoint = NoiseBoxEndpoint(box: box) + relay = FakeRelay(scheduler: scheduler, endpoint: endpoint) + pairingCode = randomBytes(16) + relayCarrier = RelayCarrier(url: URL(string: "wss://relay.test")!, secret: Bytes(repeating: 9, count: 32), scheduler: scheduler, makeSocket: relay.factory) + noise = NoiseCarrier( + inner: relayCarrier, + staticKey: Primitives.generateKeyPair(), + remoteStatic: endpoint.staticKey.publicKey, + prologue: endpoint.prologue, + handshakePayload: pairingCode, + scheduler: scheduler + ) + session = Session(build: "test", ua: "test", scheduler: scheduler) + if connect { session.connect(noise) } + } + + /// Let spawned tasks reach their suspension points. + func settle() async { + for _ in 0..<20 { await Task.yield() } + } + + /// Advance in small steps, letting tasks run between them, so a request + /// sent by a task is on the wire before its answer is due. + func run(_ ms: Double, step: Double = 10) async { + var left = ms + while left > 0 { + await settle() + scheduler.advance(min(step, left)) + left -= step + } + await settle() + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift new file mode 100644 index 0000000..7cf2249 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift @@ -0,0 +1,182 @@ +import Foundation +import Testing +@testable import FTWKit + +@Suite struct NoiseTests { + /// Cacophony vector for Noise_IK_25519_ChaChaPoly_SHA256, the same one + /// the web app and the Kotlin client test against. + let vector = [ + "init_prologue": "4a6f686e2047616c74", + "init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62", + "resp_prologue": "4a6f686e2047616c74", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "handshake_hash": "0b0f68fb0c27e03ce9b97565995ed4838cc0581b762ef72b062f6a546419fad7", + ] + + let messages: [(String, String)] = [ + ("4c756477696720766f6e204d69736573", + "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944718da798efbcd91528520204f904b9bd6c7413dccdc214d951e15253e39987f18146e8cd0873654207148333479d4d16c289f0294b29960a72f48e0b7bba2e89083169825e59642148d492020664ccf7"), + ("4d757272617920526f746862617264", + "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088435361e70b2ed446e6c9ec387d1d6b3b840f194e373979d241b203c4acafccf5"), + ("462e20412e20486179656b", "050e9f3c8fac16b68dbce8f8c4bfbf6617c897f9ada4aa29aa19c8"), + ("4361726c204d656e676572", "344233a6cabb7141d80f3da2fedc311d9646bbb0f505afe403a667"), + ("4a65616e2d426170746973746520536179", "62cdeeb172ad7ade7aa7d9e069da5790f12331bfa00177787a1d0810c67dc3b2b4"), + ("457567656e2042f6686d20766f6e2042617765726b", + "029bead1b40992327044d409d9a1f3ad8f36c3c452775d557e18bbeb2e8dfcead32d514024"), + ] + + func key(_ name: String) throws -> Primitives.KeyPair { + try Primitives.keyPair(fromSecret: Bytes(hex: vector[name]!)) + } + + @Test func namesTheProtocol() { + #expect(Noise.protocolName == "Noise_IK_25519_ChaChaPoly_SHA256") + #expect(Array(Noise.protocolName.utf8).count == 32) + } + + @Test func cacophonyHandshakeAndTransport() throws { + let initiator = try HandshakeState.initiator( + staticKey: key("init_static"), + remoteStatic: Bytes(hex: vector["init_remote_static"]!), + prologue: Bytes(hex: vector["init_prologue"]!), + ephemeral: key("init_ephemeral") + ) + let responder = HandshakeState.responder( + staticKey: try key("resp_static"), + prologue: Bytes(hex: vector["resp_prologue"]!), + ephemeral: try key("resp_ephemeral") + ) + + let m1 = try initiator.writeMessage(Bytes(hex: messages[0].0)) + #expect(m1.hex == messages[0].1) + #expect(try responder.readMessage(m1).hex == messages[0].0) + + let m2 = try responder.writeMessage(Bytes(hex: messages[1].0)) + #expect(m2.hex == messages[1].1) + #expect(try initiator.readMessage(m2).hex == messages[1].0) + + let a = try initiator.split() + let b = try responder.split() + #expect(a.handshakeHash.hex == vector["handshake_hash"]) + #expect(b.handshakeHash.hex == vector["handshake_hash"]) + #expect(a.remoteStatic.hex == vector["init_remote_static"]) + #expect(b.remoteStatic == (try key("init_static")).publicKey) + + for (i, msg) in messages.dropFirst(2).enumerated() { + let fromInitiator = i % 2 == 0 + let sender = fromInitiator ? a.send : b.send + let receiver = fromInitiator ? b.recv : a.recv + let ct = try sender.encrypt(ad: [], Bytes(hex: msg.0)) + #expect(ct.hex == msg.1) + #expect(try receiver.decrypt(ad: [], ct).hex == msg.0) + } + } + + @Test func splitTwiceIsRefused() throws { + let box = Primitives.generateKeyPair() + let app = Primitives.generateKeyPair() + let i = try HandshakeState.initiator(staticKey: app, remoteStatic: box.publicKey) + let r = HandshakeState.responder(staticKey: box) + _ = try r.readMessage(try i.writeMessage()) + _ = try i.readMessage(try r.writeMessage()) + _ = try i.split() + #expect(throws: Noise.NoiseError.self) { try i.split() } + } + + @Test func aStrayMessageTwoLeavesTheHandshakeWaiting() throws { + // Two phones in one relay room read each other's 48-byte replies. A + // reply that does not authenticate must not poison the state. + let box = Primitives.generateKeyPair() + let app = Primitives.generateKeyPair() + let i = try HandshakeState.initiator(staticKey: app, remoteStatic: box.publicKey) + let r = HandshakeState.responder(staticKey: box) + _ = try r.readMessage(try i.writeMessage()) + let genuine = try r.writeMessage() + #expect(throws: Noise.NoiseError.self) { _ = try i.readMessage(randomBytes(48)) } + _ = try i.readMessage(genuine) + #expect(i.isComplete) + } + + @Test func aPinnedKeyThatIsNotTheBoxFailsAtTheFirstDecryption() throws { + let box = Primitives.generateKeyPair() + let impostor = Primitives.generateKeyPair() + let app = Primitives.generateKeyPair() + let i = try HandshakeState.initiator(staticKey: app, remoteStatic: box.publicKey) + let r = HandshakeState.responder(staticKey: impostor) + let m1 = try i.writeMessage() + #expect(throws: Noise.NoiseError.self) { _ = try r.readMessage(m1) } + } + + @Test func sharedInteropVectors() throws { + let v = try InteropVectors.load() + #expect(v.protocolName == Noise.protocolName) + let hs = v.handshake + let appStatic = try Primitives.keyPair(fromSecret: Bytes(hex: hs.initiatorStatic)) + let boxStatic = try Primitives.keyPair(fromSecret: Bytes(hex: hs.responderStatic)) + #expect(appStatic.publicKey.hex == hs.initiatorStaticPublic) + #expect(boxStatic.publicKey.hex == hs.responderStaticPublic) + + let app = try HandshakeState.initiator( + staticKey: appStatic, + remoteStatic: boxStatic.publicKey, + prologue: Bytes(hex: hs.prologue), + ephemeral: try Primitives.keyPair(fromSecret: Bytes(hex: hs.initiatorEphemeral)) + ) + let box = HandshakeState.responder( + staticKey: boxStatic, + prologue: Bytes(hex: hs.prologue), + ephemeral: try Primitives.keyPair(fromSecret: Bytes(hex: hs.responderEphemeral)) + ) + let m1 = try app.writeMessage(Bytes(hex: hs.message1Payload)) + #expect(m1.hex == hs.message1) + #expect(try box.readMessage(m1).hex == hs.message1Payload) + let m2 = try box.writeMessage(Bytes(hex: hs.message2Payload)) + #expect(m2.hex == hs.message2) + #expect(try app.readMessage(m2).hex == hs.message2Payload) + + let appSide = NoiseTransport(try app.split()) + let boxSide = NoiseTransport(try box.split()) + #expect(appSide.handshakeHash.hex == hs.handshakeHash) + + let frames = Dictionary(uniqueKeysWithValues: v.frames.map { ($0.name, Bytes(hex: $0.bytes)) }) + for step in v.transport { + let frame = try #require(frames[step.frame]) + if step.from == "app" { + #expect(appSide.nextSeq == step.seq) + let wire = try appSide.encrypt(frame) + #expect(wire.hex == step.wire, "app frame \(step.frame) at \(step.seq)") + #expect(try boxSide.decrypt(wire) == frame) + } else { + let wire = Bytes(hex: step.wire) + #expect(try appSide.decrypt(wire) == frame, "box frame \(step.frame) at \(step.seq)") + } + } + } + + @Test func replayWindowRefusesARepeatAndAcceptsLateFrames() throws { + let box = Primitives.generateKeyPair() + let appKey = Primitives.generateKeyPair() + let i = try HandshakeState.initiator(staticKey: appKey, remoteStatic: box.publicKey) + let r = HandshakeState.responder(staticKey: box) + _ = try r.readMessage(try i.writeMessage()) + _ = try i.readMessage(try r.writeMessage()) + let app = NoiseTransport(try i.split()) + let boxSide = NoiseTransport(try r.split()) + + let w0 = try boxSide.encrypt([0]) + let w1 = try boxSide.encrypt([1]) + let w2 = try boxSide.encrypt([2]) + #expect(try app.decrypt(w2) == [2]) + #expect(try app.decrypt(w0) == [0]) + #expect(throws: Noise.NoiseError.self) { _ = try app.decrypt(w0) } + #expect(try app.decrypt(w1) == [1]) + + // A forged frame does not move the window. + var forged = try boxSide.encrypt([3]) + forged[forged.count - 1] ^= 1 + #expect(throws: Noise.NoiseError.self) { _ = try app.decrypt(forged) } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift b/appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift new file mode 100644 index 0000000..7553a87 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift @@ -0,0 +1,181 @@ +#if os(Linux) +import Foundation +import Glibc +@testable import FTWKit + +/// A minimal RFC 6455 client over a plain TCP socket, for the live test on +/// Linux only: Foundation there is built on a libcurl without WebSocket +/// support, where Apple platforms have URLSessionWebSocketTask. It talks to +/// a local bridge that carries the frames to the relay unchanged. +@MainActor +final class PlainWebSocket: WebSocketConnection { + private enum Event: Sendable { + case open + case text(String) + case binary([UInt8]) + case close(Int, String) + } + + private let fd: Int32 + private var closed = false + + static func factory() -> WebSocketFactory { + { url, events in PlainWebSocket(url: url, events: events) } + } + + init(url: URL, events: WebSocketEvents) { + fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) + let host = url.host ?? "127.0.0.1" + let port = UInt16(url.port ?? 80) + let path = url.path.isEmpty ? "/" : url.path + let (stream, continuation) = AsyncStream.makeStream() + let fd = self.fd + + // One consumer on the main actor, so events arrive in order. + Task { @MainActor [weak self] in + for await event in stream { + guard let self, !self.closed || { if case .close = event { return true }; return false }() else { continue } + switch event { + case .open: events.onOpen() + case .text(let t): events.onText(t) + case .binary(let b): events.onBinary(b) + case .close(let code, let reason): + self.closed = true + events.onClose(code, reason) + } + } + } + + Thread.detachNewThread { + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr(host == "localhost" ? "127.0.0.1" : host) + let ok = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Glibc.connect(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard ok == 0 else { + continuation.yield(.close(1006, "")) + continuation.finish() + return + } + let key = Data((0..<16).map { _ in UInt8.random(in: 0...255) }).base64EncodedString() + let request = "GET \(path) HTTP/1.1\r\nHost: \(host):\(port)\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: \(key)\r\nSec-WebSocket-Version: 13\r\n\r\n" + PlainWebSocket.write(fd, Array(request.utf8)) + + var buffer = [UInt8]() + func fill(_ n: Int) -> Bool { + var chunk = [UInt8](repeating: 0, count: 65_536) + while buffer.count < n { + let got = recv(fd, &chunk, chunk.count, 0) + if got <= 0 { return false } + buffer += chunk[0..= 2 ? Int(payload[0]) << 8 | Int(payload[1]) : 1005 + let reason = payload.count > 2 ? String(decoding: payload[2...], as: UTF8.self) : "" + continuation.yield(.close(code, reason)) + continuation.finish() + Glibc.close(fd) + return + case 0x9: + PlainWebSocket.write(fd, PlainWebSocket.frame(opcode: 0xA, payload)) + case 0x0, 0x1, 0x2: + if opcode != 0 { messageOpcode = opcode; message = [] } + message += payload + if fin { + continuation.yield(messageOpcode == 1 ? .text(String(decoding: message, as: UTF8.self)) : .binary(message)) + message = [] + } + default: + break + } + } + continuation.yield(.close(1006, "")) + continuation.finish() + } + } + + nonisolated static func frame(opcode: UInt8, _ payload: [UInt8]) -> [UInt8] { + var out: [UInt8] = [0x80 | opcode] + if payload.count < 126 { + out.append(0x80 | UInt8(payload.count)) + } else if payload.count < 65_536 { + out.append(0x80 | 126) + out.append(UInt8(payload.count >> 8)) + out.append(UInt8(payload.count & 0xff)) + } else { + out.append(0x80 | 127) + for shift in stride(from: 56, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: payload.count >> shift)) } + } + let mask = (0..<4).map { _ in UInt8.random(in: 0...255) } + out += mask + for (i, b) in payload.enumerated() { out.append(b ^ mask[i % 4]) } + return out + } + + nonisolated static func write(_ fd: Int32, _ bytes: [UInt8]) { + var sent = 0 + while sent < bytes.count { + let n = bytes[sent...].withUnsafeBytes { Glibc.send(fd, $0.baseAddress, $0.count, Int32(MSG_NOSIGNAL)) } + if n <= 0 { return } + sent += n + } + } + + func send(_ data: Bytes) { + guard !closed else { return } + PlainWebSocket.write(fd, Self.frame(opcode: 0x2, data)) + } + + func close() { + guard !closed else { return } + closed = true + PlainWebSocket.write(fd, Self.frame(opcode: 0x8, [0x03, 0xe8])) + shutdown(fd, Int32(SHUT_RDWR)) + } +} +#endif diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/identity-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/identity-vectors.json new file mode 100644 index 0000000..a8d7b02 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/identity-vectors.json @@ -0,0 +1,64 @@ +{ + "note": "Generated from srcfl/ftw-webapp by the FTWKit identity vector script. Do not hand-edit.", + "rendezvous": { + "secret": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + "handles": [ + { + "epoch": 0, + "handle": "beed4f23fe34a00e59330db079679919" + }, + { + "epoch": 42, + "handle": "446ac61f697e3fc056fee30aa17af119" + }, + { + "epoch": 493827, + "handle": "678f644aee950c0bffd06f85819862db" + } + ] + }, + "prf": { + "output": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "credentialId": "Y3JlZC0x", + "lookupId": "yZ3HcAQb54jgUNmy5i-ncFpsg9g9fSTIGxy-k_TiszU", + "writeKey": "df14a14ef739adeac0cea8e5224a50476e27571b91b0e47584eefc494ce5bfc0", + "signedMessage": "6674772d657363726f773a76313a74657374", + "signature": "6ee8f87feb18e696bf053c9051ba8ba7336ffdb51cc67f2751e63542e9c2ae6f0c272be1e74611d6758ffa9a72611476009e45a54b4c47dfbff40c808520810d" + }, + "recoveryBlob": { + "escrowVersion": 7, + "sealed": "022af5ddaf04748e7c57aefafd2cb96bf088426225297f2444547814a5c07dee323e74d9ddea720057c52f0ea1f1533287ff32577a05d8a4be010c6423ba946b1ef37be68f55f7e894bc1c1b76c1bdfd15bdaf91600d83b4afcf1236af305ba9861c9dab95ac3c31ef1a0f3eac34781b331e1dfed76274ad8e7dcb7c4e2aacb8ff139c893b8c3b8cfd10a0ee607256765c660610b3bb80d2c4f5c3aa7412b1dad13bd6f315f727a40089d1f047444c934c4d4c08b457e6609a26b3047529336eb0c1bfbad01f3b968d8b4c3da975eef85ab56485c64af335f98c1a9ae5f91f8432cf71a479cd39adb30757708f5d0b223f10e437606558835745c636f8f7cc1e05c4aa828be564744aef33de858d5c98a39a21744e041ca9f913e91594718cbc2293fcd1d47303f6b2b80e391d45ceeb7e7cd8553838033286079cef8036f5d10edd75b49d8bba89c36e083e78ac56a37727aa572a26d53bf0a864443f9b92a3cd1b0696421c54aaacfb2c447e03ad0233e745c9063a2bd56f3f622367b30cf0123e30786ac6dda668310e0e5d3d8f5832d6a9c5db8772bd143bb3b75de5fbe3efd2a1334f2d02e77e50e16d7749bb8480ab1de7da42f7790e801e2562a0660410f47133c87ff17ebdcf452ffb914766733e8a1daea322387d6d1af0d17cd59b35d0e352738f761feb8ef8baf78ef0f193c417f00990fbe45039fe52561e87be", + "deviceScalar": "5f5f5f5f5f5f5f5f6060606060606060717171717171717182828282828282f2", + "homes": [ + { + "siteId": "deb7e3fa44c17f16", + "label": "Home", + "boxStaticKey": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "rendezvousSecret": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "siteId": "cabin-0001", + "label": "Stugan 🌲", + "boxStaticKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "rendezvousSecret": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + }, + "vaultCopy": { + "publicKey": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "devicePublicFromVault": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "iv": "f5d0d835d4ad37910c35399d", + "ct": "24a5d928204136e3808b3e2c3ac862ea55e71b9a91ff373fb2224bfcad6e06d245aafa30e18cd8d8aacad79d9f43976083c4c85381c6f176a8fc6adb51ac6fe5", + "scalar": "5f5f5f5f5f5f5f5f6060606060606060717171717171717182828282828282f2" + }, + "enrollment": { + "url": "https://app.ftw.energy/p#v2.l_dPuPIqti5g-dclg1qSiuERd18csBM3VgMSDBWcuAc.ASNFZ4mrze8BI0VniavN7w.MTkyLjE2OC4xLjIwOjgwODA.ERERERERERERERERERERERERERERERERERERERERERE", + "boxStaticPublic": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "pairingCode": "0123456789abcdef0123456789abcdef", + "lanHint": "192.168.1.20:8080", + "rendezvousSecret": "1111111111111111111111111111111111111111111111111111111111111111", + "siteId": "deb7e3fa44c17f16", + "fingerprint": "DEB7E3", + "deviceIdOnBox": "l_dPuPIq" + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/webapp-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/webapp-vectors.json new file mode 100644 index 0000000..646e5e6 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/webapp-vectors.json @@ -0,0 +1,163 @@ +{ + "note": "Generated by generate-vectors.ts from srcfl/ftw-webapp. Do not hand-edit.", + "protocolName": "Noise_IK_25519_ChaChaPoly_SHA256", + "handshake": { + "prologue": "66747731", + "initiatorStatic": "a1a1a1a1a1a1a1a1b2b2b2b2b2b2b2b2c3c3c3c3c3c3c3c3d4d4d4d4d4d4d4d4", + "initiatorStaticPublic": "9cec68f5ec3551bc8d392406bba24d4a6e3d93ddb5c5045dbf5603d56ef0692b", + "initiatorEphemeral": "1111111111111111222222222222222233333333333333334444444444444444", + "responderStatic": "5f5f5f5f5f5f5f5f6060606060606060717171717171717182828282828282f2", + "responderStaticPublic": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "responderEphemeral": "aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbccccccccccccccccdddddddddddddddd", + "message1Payload": "706169723d51522d38383432", + "message1": "331d3762839c4e57d1e81afc5cabd016cf07fc9c1855455f4b9fb4b6ee88c46f7842572896c354fe4b5c01f3532b5bd0f419fba5a5dead3243a00f2752a4209352461793d03356835ebabb86bb505b47ed99a7103cb61680c06ba42f1a9837b45bb0be95d9c583d52b2a9adc", + "message2Payload": "626f6f743d6f6b", + "message2": "b27d950049dbc37de5c6386f591f76561a5898dec21b8f2569e7a071ee74362cac1663e77c54a86924a0038be6368ebeb67b827b370822", + "handshakeHash": "5eaf9676153d0091f74275f236e9d7ee36f7e86cc448c9b17d4d8558b36cd639" + }, + "frames": [ + { + "name": "tick", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 17, + "envelopeKeys": [ + "t", + "b" + ], + "bytes": "010000000011a26174647469636b6162a1637365711829000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "tick_no_body", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 8, + "envelopeKeys": [ + "t" + ], + "bytes": "010000000008a16174647469636b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "delta_export", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 51, + "envelopeKeys": [ + "t", + "b" + ], + "bytes": "010000000033a261746564656c74616162a263736571182a666669656c6473a56132391067613319183861343905db613519036b61361934580000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "delta_truncated", + "lane": 0, + "flags": 2, + "bucket": 512, + "payloadLen": 31, + "envelopeKeys": [ + "t", + "b" + ], + "bytes": "01000200001fa261746564656c74616162a263736571182b666669656c6473a16132192c8800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "cmd_with_id", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 132, + "envelopeKeys": [ + "t", + "id", + "b" + ], + "bytes": "010000000084a3617463636d64626964076162a565636d644964782430313866336132622d303030302d373030302d383030302d303030303030303030303031626f70687365745f6d6f64656461726773a1646d6f64656469646c656f6e6f7456616c696441667465724d731b0000018eafc1843566657870656374a2637265760c66677561726473800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "unknown_type_with_unknown_keys", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 72, + "envelopeKeys": [ + "t", + "b", + "futureField", + "anotherOne" + ], + "bytes": "010000000048a4617467706c616e2e76326162a169686f72697a6f6e4d731a0036ee806b6675747572654669656c647166726f6d2061206e6577657220706565726a616e6f746865724f6e65182a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "small_control_bucket", + "lane": 0, + "flags": 0, + "bucket": 256, + "payloadLen": 17, + "envelopeKeys": [ + "t", + "b" + ], + "bytes": "010000000011a26174647469636b6162a163736571182c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "snap_bulk", + "lane": 1, + "flags": 0, + "bucket": 1024, + "payloadLen": 150, + "envelopeKeys": [ + "t", + "id", + "b" + ], + "bytes": "010100000096a3617464736e6170626964036162a4637265760c6464696374a16132a3646e616d6566677269645f7764756e69746157657372634964686d657465722e7031666669656c6473a26132391067613519036b67736f7572636573a1686d657465722e7031a4646b696e64656d65746572686c6173744f6b4d731903846c7374616c6541667465724d73191388657374617465646c69766500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "transport": [ + { + "from": "app", + "frame": "cmd_with_id", + "seq": 0, + "wire": "0000000000000000ecce70a797fdc476b2fb258a1cb01112256de53e61c0efee55970aa183d4641ae5f183679cc24a3c9786e2b75a6a4bf00a734527a2407e0c673476ab444872ea0a3b47403302f746e86af81d30992299c4169d096b7718e2fd370661606c56209e6c080fb1c503ba7eb5ed0ddca6c84899c80a0dc880756336ed544edf839bb48794d1ae51ac7be08ea15eb72835d25f60b4d805720a4aad29803fccd4adc7d6296e742ccee959b97941969daef39f983b3ffbf606809c7c8e61a3ead7fc5c00611a284490a2e01c75dd154c5a98c01932dc1b7d423dbec215110d617598980a832a6db06f11c1f49591337c274614d9d81a7ec20877ac06f5a88d5ab50c531f1adce441d7956b1da86d2fde8b2acc1993afe8ad5f1fa381aec4135651e4648668b7cdf859f9d37f81a0dc2d8025694817cc5532c3bfeed82b9c3288a82c501d1bf7d7a8663e10cbba5c7fd171b3fa9220a3b5e3bf33b78421cac780ed6e99945636e2a9702dd384f64d9dd4322a7ec60577d4dac9da48a0eeaa477793c63b2639ca72d9114fdd0145d17fc1278171d9b0d16a8a8829b17fcc2a6999e16d623f0fdd0fd1e92e56ef3c7885c490520e45a10bc5b716532d3e131dcb694fd49fc47a5291542d8aaba2972bd6a8b4072b11e116c2f98483333231c48783e9ffa5f27dbc3cc2adb1b573ffdfb5375bcb3af3e722e95cf5e1c4ea0e861ae2ba81ab80e8b941f78a982f0f9eae469efa0352c7" + }, + { + "from": "box", + "frame": "tick", + "seq": 0, + "wire": "0000000000000000516e9a6c206bd055dfc435339b7a1e0aadfe361f60c6165d77b5218e9611c197034bd0ad3d5b650ad372bfe47caa9106347c476047a313d07d96134f7d42c4e07d9760467d768cfcfecc7cf398db76365cbbc2fe4d393ba1bf3fea6ea6d54c3f9eb425ed898bbc5426fec1bffefaafac0dae5262212c5595f24baee48aa5e783b6af06c59046f8178c72b8b5ee285c5b18a36f4c9fc9f37a5c1ea8f5e602dbba6787fd094457f9c8954ed2caf8c6b6a2f507f774f4131c32cda6a50d0b1006afb8aec043baf4fbbb6f00ecef1d0794ba92129dd5654633d324f5d7d75027f32ba6856b7dee94bf93df6d16210fa06da0422a09f78d92aac35ee9dca6b3dfd71748e9f245364a945bcca8fa73fe15583b64a8ff0ac01302c93a95e31770b00f1f3f2b68d17554649141dbd3a982fa911083a67126e9206c1947d5389f7273d9152ceab3e9dea5ab809b69eaf5a671fce5425639b2ae2c148d6452fd23a875e5d527c44efd4782ffd57017498c56026b55d2654ca7527b7bc13c30bf82aefcc41318766dd57c4168e158e8ced3b22ca311729ea84444cadac0706e25c9283a393cf825cdadfb5580f77885eb18fce413da50b41ce2714b73fa9e5252070b5e357ac1234fee4af339e764e44664883709c0fdf78ba943f47b72990ac255fb0e3c082889971de295956ef0bdb1ab64e21ba93a9966e0fe1809718b33d36b0d0033e420b237a22bae143e5825baa82ba719c0" + }, + { + "from": "box", + "frame": "delta_export", + "seq": 1, + "wire": "0000000000000001c473f3e7d40222d7d80bcabf50979a162b7fe59c5c98d24ffc5d00fffd4e1d9268c256d2e46db025e56d03b71b79115c8a6070496025d4dfe32d7774bb1d6eafec69e6d5667467e2c4ffb9cb0ddd6f525dd7820f74884c14959abccf166cae9d498e963f690b68b3e8c1ba6c6e4e94ce63aecb816814b35fe4398092e4c15948bfaa866cc6e240d9439e81c87bcd4715b17fc0a705cd15545b461532814483991dd10a535faf52692fbbf1b30957184ab41d913034463d703073a05e115b53c954c6574117ba40576d0b290c54353f8d509b027f5466403428b0a3dbe0ad9a05823dc5167509bce811dae677f91bd61bc814c09ec8696bc71a0f3a568837fb87d23a7accf46cba8967ffdcee5e1f26550e4c212d4919f2a1fab90e2321ed49a4bd2e73ad27ec2f4aad7b7e070279d40bf146584aeb1f0ed3f048f6e613176619541fdd60203ae544ae560cd5b9437f23a10be36392429db47cba2ecca971106cfa0d3186e27d22c85052018b03ac14a0fb5bee0875c6eda5e9bc642d6b79fc4dfca1459c6d3acf101e1eba91871c8b8adc6d6571b28eab0e28d68cfddbd04d1082ee39e8bc465dcc80493423f8b2cfc447659ed11615065524ee8ff8bba783712c75c12e0d5858418ee253321a50d925dcdb2eb7c03863418fdb3312c7eff9094b68f32f74a99d5c2c1acb2413fba2970317f8d18fd67f12e4a85713d193216bacda90c80650cacaf8b46936ca3e9be8" + }, + { + "from": "box", + "frame": "delta_truncated", + "seq": 2, + "wire": "000000000000000254e09b9c87a03a9f6335a3a3ce3af98267f074cfa510300177b81a74643c66abbbbc5f2c4f722de2b6e2599e99ee36c34462c4f89b329ff46a9c2c3494340a175c98ea4f64104413348988ab4175fdad2fcdd07ff018232677323df57cff99e95cd8acc976c8b8360f0530da673fe29eab347ef382569c6a682d53fe0b45d524f9c3ad33fd904788d2b8199beb99fcc6c45c6365f54f507135a1acacb2568e59ef077ca4c9dcf44631b3125ae51f30c072bfafd44de2c765c5c3c3d4ffe02e8aa667dc5cdfbb134089874931ef173ccd4c755627a380bb76e8d72a27d461d4aff617c7765756d9e23323ad8a5168b3496961a35994b0653a5458f9542f1be8363cc4029c76331583bf698b46f8a53c70464c1d360a6411b2663b864ccbc4e8477d01f9ecdb5970e8a0ef822ec6612c7f64a923b7338080a09b92555a96697066c2acd610b1bc06296ccd87e9074c72b6290c37e877e767df7f51ae16648e5f7ce445bb9ae0a9c63c40694bbe18a7b1e661661b0dda5194f5b228af66c799aa1a49018849d32f5db5f218111e35a747c28afe0a11be1648e67a26760d0db19ebd5505fec484186ce55216b87fbcd64d1ba7b0c3035fd5d552e9d5c9ce77c2b6bdec57d49e901643db9da24c1baa4adf0fddc158aaad77610664c8fb8953f5a0882776919d9b1766657259d3530cd251bcb1824c6ac86c778f07daefb39cbb6a5a0e6402dee12aa21f96a6de33de690701" + }, + { + "from": "app", + "frame": "tick_no_body", + "seq": 1, + "wire": "0000000000000001c8591c8434b5950b20b903560ce80df2ba339734d70d6c444ab447dcc9fe10769a9345e7571c4beb39768b7b3eb9b4ff8f72e59aae18d652aa95086065f40c69303d20f7ce0921311a7313cf98fed03f9eb53259855030413b3e6a2a07be66108c6eef709f0ef5e1dd2ee3afc6372859849334e3c95d496d076ad2d06140a40e07cfde154536134a2ad04c4327fabc37fe82ff2e90e435eebf4890a9c53024313d9114c2e2a8fa1e645c697c9616e94aa68fb1a69725687bfbe401b085311e8f833276624ceaea939fe55d5b7b58e7e590befa7f652ccef32cb974a8c289022444a112c7436020235853e642846eeed54edc5046380a3d36c0de8c0cb25e7f16a34c8f9d43df541674de3609cab98c06b644e330b5fa5250a94bc575e5d7b4519dbc7f5ef2cd1da8d8f0a24220886c2bcfbe5d746d4d0290a4682eddf78671f8522bd9cda65bc67d927301c1d0af95797109e2ef54e198d4ab0635a7c85a09cd73d6e43c098032196dfbd471cffb9ac240ab4e76951be14b66811ed4a1a82b46e29c7713d1cf9d02772cf0ddace7b0367c8cc51239f86a0bb968f67c1120dec653505741fcfda16505d9011ea3e63a16ad3ad923bf8eef02e646adf0858d903fc5ad0823becb8280d19ca746dedea43450a03759324a633106529d00de7426e414448b33a1f91f174a39328ad2ac6043b2e633d7f2b87b9e0c5b86cdbbcc5152015c21f8427ace8cd641c1568893724f" + }, + { + "from": "box", + "frame": "snap_bulk", + "seq": 3, + "wire": "0000000000000003b9d298685a72f1ac18509da2a460138293a53aba3017b787583a7a4467d5b87ac9224e8cf9d0a701f75030aa12783defa4523d5cad0b42e489cbd07636077be1bf3ca30704f4a3464822c51ccadc01652d2a064330affc2038e486e1d913a36c2af9366d5214bbdb626340a7c941dd3438b503e60f7e6bbf1ba9079c27df1a08fb93e3d040598129edbd8f52149bdf1a48f3cf3bcdb90655f736f0a83a419662736e8be426fe220edaeeb030369fd0cb7f9c49e586d5b30e31d505434f7de3a960958749a8022002375503a718a5cdc98ca66b582108a99b490a19434bf7157d4c76e1e89d496711ef21780d98922f0ce82ad65db51637734ddbb4375cb63be6d7f9127562b6977edce3fb1cb65556c209ad1a8c2f1fc92a6ab88787ca4f0a45b7cea4da4178ca637c9c76262fba7e64e1b1bab2ef84503e47d03c50f965648f3184fd8c92a4726497fa654f7b44f189176ac51f0fcd33ee3cccaa1f9d57d670041a6941f90154141269430e73f55d7a2b9110a7a0675ceb7386e652ef1a246a8ae34f18583616eb5f330d6ca3fef5bf0e20e4ab3d80c032ef6a382d3b2d8a549893675f6473681daf0ec77367ca6c15c0a971c92b1f5c5567494579c3bead028715dac89dad9dc452516cdacd8d52f5149e8900be9d0693749cb1bdba20bba4cc5c784538f18706c7905171cfa3a72c86f6ac13579fc81f6efac3ee865236eb9993817e518194b410aae58df235bfe991504569556e74894fb1480968247403f34691426a973db3a080124bae7eef9ee8c27d0ed07bcda69875fd49e524606f9e2890133de2d1522421ef96ee2f8a888d8b5927018860d36a91765508b25bf8d537a3b6a31086c3de2c4417221c015815330367732317acc63d8e34acb6965cbfd8957745197ac70779b9f7acd5e3824f81961af2f499f06ac4522f1427419174bc9f36810fe6ad15dcb68d9560e01e269408405c104633009b060a188bbb92efb86fb7f0f45d43b191e571d603581d267df57b85fb5c0f08ee87f8087b5e9ebd2dd342d9b4662f6665bea8290c867d161f95969a49a721e8a1846ab3cf381468ba1eee407482770644d2d3e04264531e5189666250ce72171a7372badb03336001da905f8ad453363470a7fbe553cb63a8c4783b51fc3fab2181c9f125829bcc27886bb9696aab59ca17ed3690ce19b9f5db052736798ebd54eac089de1a3e95512df4515b78914ab48c300e0233d5aaeae2eb1ecb91bb4abfda79fa610d1eaa28f9e9e6a65c2b648f9f0738782adf7354a647088908d5a285b7dc9368a2af4203390a6e70cbbb4a7fb7d6c4dd141121f716a0fd86e17ff082553c6e0fb8a15dd5e9f768c73f453e45c76949dbb9adbc241e50da70f9274516d92613379bb038cda8cb61e9db8ebbbf905107279caf60b9583df2de6b0164912c2733f0a1c7e1e39a049ad61a04b24d41faffcc6038" + }, + { + "from": "box", + "frame": "tick", + "seq": 4, + "wire": "000000000000000424ecfa4e44db2a323fef3473988ed660820a5596a96cb2801740c3d06e366fd1953597421d3f4ea000b74f699639f0b2fc16c94def7899b25b057dccce7ab1e0ff2ddafd9b83139f2e4c6fc7077ac961a413bbcdc4831cbb3c4670ee8ca6f9d3cb3b9f48a77fe0037ceb883a670c732f11e5e5ca9b4ffb99c18233578b71192e9103bea3e358e118d8624665b23dbd021b93d361985cf0ae3af9224450741627a1175b450f15ce2b5282e36fcfd2e132584356217b744b1312f13088fbe2f38177a72d9d5179a77e4f930787aab842a87d1bc277d34334446e0dfb6961d5a252d9d0888290a6a2d0d73020b6c425309a8f87eac9d0bb42346e9d963eedf267e05e88658b24cb43404dfed2454d22be9ffdb0ec371c27a1949407273d713bde52d8d946be18531ef3af68d27c08e2731d50f98020c02e80c082c4fa2bd6a5dd5593bf005f521b6cc4772c2a59a0580a82f5dd915248a641cb4662af4ccadf8c728ea60a08e4383a91adfefc425fa4430ba27a51cc87a2628a4ce4e25d54ee2ff37abdd7fb408155987332de4271e06e24a33b3a1e24c4e84e424c5bb6f56aa4c7629c764f3df9e0966a2c31634fce4ff5bfec91744cd90159a390d935cc9d0f1943dfe177d7489173b98893f8716a55a03cfc10494129513f4273633446a2a570755156e62361ff89fbe0f7c6f6ba92ab59382ea42d774979c24e7caa8f68cb95716078fa5e7ee43963f501fe8edf896d" + } + ] +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift new file mode 100644 index 0000000..f0b3f5b --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift @@ -0,0 +1,369 @@ +import Foundation +import Testing +@testable import FTWKit + +@MainActor +@Suite struct SessionTests { + @Test func theWholeStackReachesAStream() async throws { + let rig = Rig() + await rig.run(200) + #expect(rig.session.state.phase == .streaming) + #expect(rig.session.state.carrier == .relay) + #expect(rig.session.state.heardFromBox) + #expect(rig.session.state.caps.contains(Contract.capAPIPassthrough)) + #expect(rig.session.state.fields[Contract.FID.gridW] != nil) + #expect(rig.session.state.modes.first?.key == "planner_passive_arbitrage") + // The pairing code travelled, encrypted, in message 1. + #expect(rig.endpoint.handshakePayloads.first == rig.pairingCode) + + // The relay only ever saw the rotating handle, never a key. + let url = try #require(rig.relay.dialled.first) + let parts = url.path.split(separator: "/") + #expect(parts.count == 4) + #expect(parts[0] == "r") + #expect(parts[3] == "app") + #expect(String(parts[2]) == (try Rendezvous.handle(secret: Bytes(repeating: 9, count: 32), epoch: Int64(parts[1])!))) + } + + @Test func laneZeroFramesAreOneSizeOnTheWire() async throws { + let rig = Rig() + await rig.run(5_000, step: 100) + // Everything the app sent after the handshake on lane 0: hello, sub. + let sent = try #require(rig.relay.current?.sent) + let transport = sent.dropFirst() + #expect(!transport.isEmpty) + #expect(Set(transport.map(\.count)) == [512 + NoiseTransport.overhead]) + } + + @Test func telemetryKeepsArriving() async { + let rig = Rig() + await rig.run(200) + let before = rig.session.state.uptimeMs + await rig.run(3_000, step: 100) + #expect(rig.session.state.uptimeMs > before) + } + + @Test func theBoxsOwnAPIAnswersInJSON() async throws { + let rig = Rig() + await rig.run(200) + let task = Task { try await rig.session.api(APIRequest(method: .get, path: "/api/status")) } + await rig.run(200) + let response = try await task.value + #expect(response.status == 200) + let json = try JSON(parsing: response.body) + #expect(json["fuse"]?["max_amps"]?.number == 25) + } + + @Test func aLargeAnswerArrivesInOrderedChunks() async throws { + let rig = Rig() + await rig.run(200) + // Ninety days of ledger is several chunks. + let task = Task { try await rig.session.api(APIRequest(method: .get, path: "/api/energy/daily", query: ["days": "90"])) } + await rig.run(300) + let response = try await task.value + #expect(response.body.count > 12_288) + #expect(try JSON(parsing: response.body)["days"]?.array?.count == 90) + } + + @Test func refusalsArriveAsCodesAndNoHandlerRan() async throws { + let rig = Rig(requireStepUp: true) + await rig.run(200) + for (req, code) in [ + (APIRequest(method: .post, path: "/api/restart"), "E_NEEDS_STEP_UP"), + (APIRequest(method: .post, path: "/api/mode"), "E_USE_CMD"), + (APIRequest(method: .get, path: "/api/config"), "E_LOCAL_ONLY"), + (APIRequest(method: .get, path: "/api/nothing/here"), "E_UNKNOWN_OP"), + ] { + let task = Task { try await rig.session.api(req) } + await rig.run(100) + do { + _ = try await task.value + Issue.record("\(req.path) answered") + } catch let refusal as BoxRefusal { + #expect(refusal.detail.code == code) + } + } + // The same request with the flag goes through. + let task = Task { try await rig.session.api(APIRequest(method: .post, path: "/api/restart", stepUp: true)) } + await rig.run(100) + #expect(try await task.value.status == 200) + } + + @Test func oneAPICallIsOnTheWireAtATime() async throws { + let rig = Rig() + await rig.run(200) + let a = Task { try await rig.session.api(APIRequest(method: .get, path: "/api/status")) } + let b = Task { try await rig.session.api(APIRequest(method: .get, path: "/api/loadpoints")) } + await rig.run(400) + #expect(try await a.value.status == 200) + #expect(try await b.value.status == 200) + } + + @Test func aModeCommandIsAckedAppliedAndReplans() async throws { + let rig = Rig() + await rig.run(200) + let task = Task { try await rig.session.command(Contract.opSetMode, args: [("mode", .text("self_consumption"))]) } + await rig.run(200) + let result = try await task.value + #expect(result.state == .applied) + #expect(result.observedValue == 3) + // The plan pushed unasked lands on state. + #expect(rig.session.state.plan != nil) + await rig.run(1_500, step: 100) + #expect(rig.session.state.fields[Contract.FID.mode] == 3) + } + + @Test func noAckMeansItNeverReachedTheBox() async throws { + let rig = Rig() + await rig.run(200) + rig.box.mute = true + let task = Task { try await rig.session.command(Contract.opSetMode, args: [("mode", .text("idle"))]) } + await rig.run(Session.cmdAckTimeoutMs + 100, step: 100) + do { + _ = try await task.value + Issue.record("a silent box confirmed") + } catch let e as CommandError { + #expect(e.code == "E_NO_ACK") + } + } + + @Test func ackedButNeverConfirmedIsItsOwnAnswer() async throws { + let rig = Rig() + await rig.run(200) + rig.box.neverConfirm = true + let task = Task { try await rig.session.command(Contract.opSetMode, args: [("mode", .text("idle"))]) } + await rig.run(Session.cmdConfirmTimeoutMs + 200, step: 100) + #expect(try await task.value.state == .unconfirmed) + } + + @Test func aDropSettlesEveryRequestInFlight() async throws { + let rig = Rig() + await rig.run(200) + rig.box.mute = true + let task = Task { try await rig.session.plan() } + await rig.run(50) + rig.relay.closeCurrent(code: 1006, reason: "") + await rig.run(20) + await #expect(throws: SessionError.carrierClosed) { _ = try await task.value } + #expect(rig.session.state.phase == .failed) + // Readings stay, older. + #expect(!rig.session.state.fields.isEmpty) + } + + @Test func theSessionComesBackAfterADrop() async throws { + let rig = Rig() + await rig.run(200) + rig.relay.closeCurrent(code: 1006, reason: "") + // Full-jitter backoff at the scheduler's 0.5: 250 ms, then a new + // socket, a new handshake and a new stream. + rig.endpoint.dropSession() + await rig.run(1_000, step: 20) + #expect(rig.session.state.phase == .streaming) + #expect(rig.relay.dialled.count == 2) + } + + @Test func theBoxLeavingAndReturningKeepsOneSocket() async throws { + let rig = Rig() + await rig.run(200) + rig.relay.boxLeaves() + await rig.run(20) + #expect(rig.session.state.phase == .failed) + rig.relay.boxReturns() + await rig.run(300) + #expect(rig.session.state.phase == .streaming) + #expect(rig.relay.dialled.count == 1) + } + + @Test func aRelayEpochCorrectionIsTakenOnlyWhenSmall() async throws { + let rig = Rig() + await rig.run(200) + let ours = Rendezvous.epoch(nowMs: rig.scheduler.nowMs) + rig.relay.closeCurrent(code: RelayCarrier.closeEpoch, reason: String(ours + 1)) + await rig.run(100) + let corrected = try #require(rig.relay.dialled.last) + #expect(corrected.path.split(separator: "/")[1] == Substring(String(ours + 1))) + + // A relay naming an epoch a year away is not a clock correction. + rig.relay.closeCurrent(code: RelayCarrier.closeEpoch, reason: String(ours + 9_000)) + await rig.run(100) + let refused = try #require(rig.relay.dialled.last) + #expect(refused.path.split(separator: "/")[1] == Substring(String(ours + 1))) + } + + @Test func aSilentBoxEndsTheHandshakeAndItIsRetried() async throws { + let rig = Rig(connect: false) + rig.endpoint.refuse = true + rig.session.connect(rig.noise) + await rig.run(NoiseCarrier.handshakeDeadlineMs + 100, step: 200) + if case .closed(let reason, let retryable) = rig.noise.status { + #expect(reason == "the box did not answer") + #expect(retryable) + } else { + Issue.record("still \(rig.noise.status)") + } + rig.endpoint.refuse = false + await rig.run(20_000, step: 200) + #expect(rig.session.state.phase == .streaming) + } + + @Test func aForeignFrameOnTheRoomIsIgnored() async throws { + let rig = Rig() + await rig.run(200) + rig.relay.current?.deliver(randomBytes(528)) + rig.relay.current?.deliver(randomBytes(48)) + await rig.run(1_500, step: 100) + #expect(rig.session.state.phase == .streaming) + } + + @Test func aStartingBoxIsAskedAgainOnItsOwnTimer() async throws { + let rig = Rig(connect: false) + rig.box.booting = true + rig.session.connect(rig.noise) + await rig.run(200) + #expect(rig.session.state.phase == .booting) + #expect(rig.session.state.boot?.phase == "vacuum") + rig.box.booting = false + await rig.run(Session.bootRetryMs + 200, step: 100) + #expect(rig.session.state.phase == .streaming) + } + + @Test func historyArrivesAsTilesAndAssembles() async throws { + let rig = Rig() + await rig.run(200) + let to = rig.scheduler.nowMs + let from = to - 86_400_000 + var chunks = [String: HistChunk]() + let task = Task { + try await rig.session.history(HistQuery(series: ["grid_w", "pv_w", "battery_w", "load_w"], res: .fiveMinutes, fromMs: from, toMs: to, have: [], maxPoints: 1500)) { chunk in + chunks[chunk.tileId] = chunk + } + } + await rig.run(300) + let end = try await task.value + #expect(end.resActual == .fiveMinutes) + let plan = HistoryGeometry.plan(.fiveMinutes, fromMs: from, toMs: to, maxPoints: 1500) + #expect(Set(chunks.keys) == Set(plan.tiles.map(\.tileId))) + let frame = HistoryGeometry.clip(HistoryGeometry.assemble(plan, names: ["grid_w", "pv_w", "battery_w", "load_w"], tiles: chunks), fromMs: from, toMs: to) + #expect(frame.points >= 287 && frame.points <= 289) + #expect(frame.value("load_w", at: 0) != nil) + } + + @Test func timesGoOutAsWholeMilliseconds() async throws { + // A real box drops a query whose times carry a fraction, and a + // phone's clock has one. Found against a live box. + let rig = Rig() + await rig.run(200) + let at = rig.scheduler.nowMs + 0.25 + let task = Task { try await rig.session.prices(fromMs: at, toMs: at + 3_600_000.5) } + await rig.run(200) + #expect(try await task.value.slots.isEmpty == false) + } + + @Test func pricesAndThePlanAreAsked() async throws { + let rig = Rig() + await rig.run(200) + let plan = Task { try await rig.session.plan() } + let prices = Task { try await rig.session.prices(fromMs: rig.scheduler.nowMs, toMs: rig.scheduler.nowMs + 86_400_000) } + await rig.run(200) + #expect(try await plan.value.slots.count == 96) + #expect(try await prices.value.currency == "SEK") + } + + @Test func revocationTerminatesAndSaysWhy() async throws { + let rig = Rig() + await rig.run(200) + let frame = try Frame.encodeBulk(envelope: Envelope(t: "session.terminate", b: .map([("reason", .text("revoked"))]))) + rig.box.send?(frame) + await rig.run(50) + #expect(rig.session.state.phase == .terminated) + #expect(rig.session.state.terminated == .revoked) + } + + @Test func cachedReadingsNeverOverwriteALiveCarrier() async throws { + let rig = Rig() + // Until the handshake to the box completes, the cache is honestly + // what is on screen. + var old = SessionState() + old.fields = [Contract.FID.gridW: 1_234] + for _ in 0..<20 where rig.session.state.phase != .handshaking { + await rig.run(5, step: 5) + } + #expect(rig.session.state.phase == .handshaking) + // Mid-handshake: the cache paints data, never the phase or carrier. + rig.session.restore(CachedSnapshot(siteId: "s", savedAtMs: 0, state: old)) + #expect(rig.session.state.phase == .handshaking) + #expect(rig.session.state.carrier == .relay) + #expect(rig.session.state.fields[Contract.FID.gridW] == 1_234) + await rig.run(200) + #expect(rig.session.state.phase == .streaming) + rig.session.restore(CachedSnapshot(siteId: "s", savedAtMs: 0, state: old)) + #expect(rig.session.state.fields[Contract.FID.gridW] != 1_234) + } +} + +@Suite struct HistoryGeometryTests { + @Test func wideWindowsClampToTheHourlyStore() { + let now = 1_750_000_000_000.0 + let year = HistoryGeometry.plan(.fiveMinutes, fromMs: now - 365 * 86_400_000, toMs: now, maxPoints: 1500) + #expect(year.res == .hour) + #expect(year.tiles.count * year.tiles[0].points <= 1500 * 2) + let day = HistoryGeometry.plan(.fiveMinutes, fromMs: now - 86_400_000, toMs: now, maxPoints: 1500) + #expect(day.res == .fiveMinutes) + #expect(day.stride == 1) + } + + @Test func packingRoundTripsAndKeepsTheMissingMarker() { + let columns: [[Int32]] = [[1, -2, missingSample], [Int32.max, 0, 7]] + #expect(HistoryGeometry.unpack(HistoryGeometry.pack(columns), seriesCount: 2) == columns) + } + + @Test func etagIsFNV1a() { + #expect(HistoryGeometry.etag([]) == "811c9dc5") + #expect(HistoryGeometry.etag(Array("a".utf8)) == "e40c292c") + } +} + +@Suite struct ContractTests { + /// The registry is one file shared with the box and the web app. Every + /// name this app spells has to be the registry's. + @Test func namesMatchTheSharedRegistry() throws { + let yaml = try RepoFiles.read("protocol/registry.yaml") + func names(section: String, key: String) -> [String] { + var out = [String]() + var inside = false + for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("\(section):") { inside = true; continue } + if inside, let first = line.first, !first.isWhitespace, first != "#" { break } + guard inside, let range = line.range(of: "\(key): ") else { continue } + let rest = line[range.upperBound...] + out.append(String(rest.prefix { $0 != "," && $0 != "}" && $0 != " " })) + } + return out + } + func list(section: String) -> [String] { + var out = [String]() + var inside = false + for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("\(section):") { inside = true; continue } + if inside, let first = line.first, !first.isWhitespace, first != "#" { break } + let t = line.trimmingCharacters(in: .whitespaces) + if inside, t.hasPrefix("- ") { out.append(String(t.dropFirst(2))) } + } + return out + } + + #expect(names(section: "scopes", key: "name") == Contract.scopes) + #expect(names(section: "ops", key: "name") == Contract.ops) + #expect(list(section: "capabilities") == Contract.capabilities) + let errors = names(section: "errors", key: "code") + names(section: "client_errors", key: "code") + #expect(Set(errors) == Set(Contract.retryable.keys)) + for code in errors { + let line = yaml.split(separator: "\n").first { $0.contains("code: \(code),") }! + #expect(line.contains("retryable: \(Contract.isRetryable(code))"), "\(code)") + } + #expect(yaml.contains("source_states: [\(Contract.sourceStates.joined(separator: ", "))]")) + #expect(yaml.contains("carrier_states: [\(Contract.carrierStates.joined(separator: ", "))]")) + #expect(yaml.contains("1: { name: mode,")) + #expect(yaml.contains("10: { name: ev_w,")) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Support.swift b/appleApp/FTWKit/Tests/FTWKitTests/Support.swift new file mode 100644 index 0000000..3596d26 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Support.swift @@ -0,0 +1,69 @@ +import Foundation +@testable import FTWKit + +/// The shared interop vectors, generated by srcfl/ftw from the web app's own +/// code (go/internal/appwire/testdata/generate-vectors.ts). The Go box replays +/// the same file; replaying it here puts three implementations on one wire. +struct InteropVectors: Decodable { + struct Handshake: Decodable { + let prologue: String + let initiatorStatic: String + let initiatorStaticPublic: String + let initiatorEphemeral: String + let responderStatic: String + let responderStaticPublic: String + let responderEphemeral: String + let message1Payload: String + let message1: String + let message2Payload: String + let message2: String + let handshakeHash: String + } + + struct FrameVector: Decodable { + let name: String + let lane: UInt8 + let flags: UInt8 + let bucket: Int + let payloadLen: Int + let envelopeKeys: [String] + let bytes: String + } + + struct TransportStep: Decodable { + let from: String + let frame: String + let seq: UInt64 + let wire: String + } + + let protocolName: String + let handshake: Handshake + let frames: [FrameVector] + let transport: [TransportStep] + + static func load() throws -> InteropVectors { + guard let url = Bundle.module.url(forResource: "webapp-vectors", withExtension: "json", subdirectory: "Resources") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(InteropVectors.self, from: Data(contentsOf: url)) + } +} + +/// Where the repository's shared files live, found from this file's path so +/// the tests read the real registry rather than a copy of it. +enum RepoFiles { + static var root: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // FTWKitTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // FTWKit + .deletingLastPathComponent() // appleApp + .deletingLastPathComponent() // repository + } + + static func read(_ path: String) throws -> String { + try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) + } +} + diff --git a/appleApp/scripts/demo-screenshots.sh b/appleApp/scripts/demo-screenshots.sh new file mode 100755 index 0000000..3ebc1b3 --- /dev/null +++ b/appleApp/scripts/demo-screenshots.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Photographs every screen of the demo in an iOS Simulator, for review. +# Needs a Debug build in build/ios, as the Apple workflow makes: +# xcodebuild build -project FTW.xcodeproj -scheme FTW \ +# -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios +set -euo pipefail +cd "$(dirname "$0")/.." +app=build/ios/Build/Products/Debug-iphonesimulator/FTW.app +out=${1:-build/screenshots} +mkdir -p "$out" + +udid=$(xcrun simctl list devices available --json | python3 -c ' +import json, sys +devices = json.load(sys.stdin)["devices"] +phones = [d for runtime, ds in sorted(devices.items()) if "iOS" in runtime for d in ds if d["name"].startswith("iPhone")] +print(phones[-1]["udid"])') + +xcrun simctl boot "$udid" 2>/dev/null || true +xcrun simctl bootstatus "$udid" -b +xcrun simctl install "$udid" "$app" + +# name, appearance, then the app's launch arguments +shoot() { + local name=$1 look=$2 + shift 2 + xcrun simctl ui "$udid" appearance "$look" + xcrun simctl launch --terminate-running-process "$udid" energy.ftw.app "$@" >/dev/null + sleep 8 + xcrun simctl io "$udid" screenshot "$out/$name.png" >/dev/null + echo "$out/$name.png" +} + +shoot pair dark +for tab in now plan history box; do + shoot "$tab" dark -FTWDemo YES -FTWTab "$tab" +done +shoot now-light light -FTWDemo YES -FTWTab now diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj deleted file mode 100644 index 152027e..0000000 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ /dev/null @@ -1,296 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 56; - objects = { - -/* Begin PBXBuildFile section */ - A10000000000000000000020 /* FTWApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000010 /* FTWApp.swift */; }; - A10000000000000000000021 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000011 /* AppModel.swift */; }; - A10000000000000000000022 /* PairView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000012 /* PairView.swift */; }; - A10000000000000000000023 /* NowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000013 /* NowView.swift */; }; - A10000000000000000000024 /* QRScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000014 /* QRScanner.swift */; }; - A10000000000000000000025 /* PasskeyHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000015 /* PasskeyHost.swift */; }; - A10000000000000000000026 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000018 /* KeychainStore.swift */; }; - A10000000000000000000093 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000090 /* LICENSE */; }; - A10000000000000000000094 /* NOTICE in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000091 /* NOTICE */; }; - A10000000000000000000095 /* LICENSING.md in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000092 /* LICENSING.md */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - A10000000000000000000010 /* FTWApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FTWApp.swift; sourceTree = ""; }; - A10000000000000000000011 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; - A10000000000000000000012 /* PairView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairView.swift; sourceTree = ""; }; - A10000000000000000000013 /* NowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowView.swift; sourceTree = ""; }; - A10000000000000000000014 /* QRScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScanner.swift; sourceTree = ""; }; - A10000000000000000000015 /* PasskeyHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeyHost.swift; sourceTree = ""; }; - A10000000000000000000018 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; - A10000000000000000000016 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A10000000000000000000017 /* iosApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iosApp.entitlements; sourceTree = ""; }; - A10000000000000000000081 /* FTW.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FTW.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A10000000000000000000090 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; name = "LICENSE"; path = "../LICENSE"; sourceTree = SOURCE_ROOT; }; - A10000000000000000000091 /* NOTICE */ = {isa = PBXFileReference; lastKnownFileType = text; name = "NOTICE"; path = "../NOTICE"; sourceTree = SOURCE_ROOT; }; - A10000000000000000000092 /* LICENSING.md */ = {isa = PBXFileReference; lastKnownFileType = text; name = "LICENSING.md"; path = "../LICENSING.md"; sourceTree = SOURCE_ROOT; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - A10000000000000000000031 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - A10000000000000000000002 = { - isa = PBXGroup; - children = ( - A10000000000000000000003 /* iosApp */, - A10000000000000000000080 /* Products */, - ); - sourceTree = ""; - }; - A10000000000000000000003 /* iosApp */ = { - isa = PBXGroup; - children = ( - A10000000000000000000010 /* FTWApp.swift */, - A10000000000000000000011 /* AppModel.swift */, - A10000000000000000000012 /* PairView.swift */, - A10000000000000000000013 /* NowView.swift */, - A10000000000000000000014 /* QRScanner.swift */, - A10000000000000000000015 /* PasskeyHost.swift */, - A10000000000000000000018 /* KeychainStore.swift */, - A10000000000000000000016 /* Info.plist */, - A10000000000000000000017 /* iosApp.entitlements */, - ); - path = iosApp; - sourceTree = ""; - }; - A10000000000000000000080 /* Products */ = { - isa = PBXGroup; - children = ( - A10000000000000000000081 /* FTW.app */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - A10000000000000000000040 /* iosApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = A10000000000000000000071 /* Build configuration list for PBXNativeTarget "iosApp" */; - buildPhases = ( - A10000000000000000000033 /* Compile Kotlin Framework */, - A10000000000000000000030 /* Sources */, - A10000000000000000000031 /* Frameworks */, - A10000000000000000000032 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = iosApp; - productName = FTW; - productReference = A10000000000000000000081 /* FTW.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - A10000000000000000000001 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1600; - LastUpgradeCheck = 1600; - TargetAttributes = { - A10000000000000000000040 = { - CreatedOnToolsVersion = 16.0; - }; - }; - }; - buildConfigurationList = A10000000000000000000070 /* Build configuration list for PBXProject "iosApp" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = A10000000000000000000002; - productRefGroup = A10000000000000000000080 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - A10000000000000000000040 /* iosApp */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - A10000000000000000000032 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A10000000000000000000093 /* LICENSE in Resources */, - A10000000000000000000094 /* NOTICE in Resources */, - A10000000000000000000095 /* LICENSING.md in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - A10000000000000000000033 /* Compile Kotlin Framework */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Compile Kotlin Framework"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build from IDE\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\nexport JAVA_HOME=\"${JAVA_HOME:-$(/usr/libexec/java_home 2>/dev/null)}\"\nif [ -z \"$JAVA_HOME\" ] || [ ! -x \"$JAVA_HOME/bin/java\" ]; then\n export JAVA_HOME=\"/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home\"\nfi\nexport ANDROID_HOME=\"${ANDROID_HOME:-$HOME/Android/sdk}\"\nexport ANDROID_SDK_ROOT=\"$ANDROID_HOME\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - A10000000000000000000030 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A10000000000000000000020 /* FTWApp.swift in Sources */, - A10000000000000000000021 /* AppModel.swift in Sources */, - A10000000000000000000022 /* PairView.swift in Sources */, - A10000000000000000000023 /* NowView.swift in Sources */, - A10000000000000000000024 /* QRScanner.swift in Sources */, - A10000000000000000000025 /* PasskeyHost.swift in Sources */, - A10000000000000000000026 /* KeychainStore.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - A10000000000000000000050 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_MODULES = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - SDKROOT = iphoneos; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - A10000000000000000000051 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_MODULES = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - SDKROOT = iphoneos; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - A10000000000000000000060 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = ""; - CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_PREVIEWS = YES; - FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.1.0; - OTHER_LDFLAGS = ( - "$(inherited)", - "-framework", - Shared, - ); - PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; - PRODUCT_NAME = FTW; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - A10000000000000000000061 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = ""; - CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_PREVIEWS = YES; - FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.1.0; - OTHER_LDFLAGS = ( - "$(inherited)", - "-framework", - Shared, - ); - PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; - PRODUCT_NAME = FTW; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - A10000000000000000000070 /* Build configuration list for PBXProject "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - A10000000000000000000050 /* Debug */, - A10000000000000000000051 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - A10000000000000000000071 /* Build configuration list for PBXNativeTarget "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - A10000000000000000000060 /* Debug */, - A10000000000000000000061 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = A10000000000000000000001 /* Project object */; -} diff --git a/iosApp/iosApp/AppModel.swift b/iosApp/iosApp/AppModel.swift deleted file mode 100644 index 0abdf2c..0000000 --- a/iosApp/iosApp/AppModel.swift +++ /dev/null @@ -1,136 +0,0 @@ -import SwiftUI -import Shared - -@MainActor -final class AppModel: ObservableObject { - @Published var site: PairedSite? - @Published var headline: String = "Waiting for the first reading." - @Published var carrier: String = "none" - @Published var srcState: String = "never" - @Published var grid: String = "—" - @Published var pv: String = "—" - @Published var battery: String = "—" - @Published var load: String = "—" - @Published var help: String? - @Published var paste: String = "" - - private let vault: Vault - private let sites: SiteStore - private let readings: ReadingsCache - private lazy var client: FtwClient = FtwClient( - vault: vault, - sites: sites, - sockets: IosSockets(), - passkeys: IosPasskey(), - relayUrl: OriginKt.RELAY_URL, - build: "ios" - ) - private var session: Session? - private var epoch = 0 - - init() { - let kv = KeychainStore() - vault = Vault(store: kv) - sites = SiteStore(kv: kv) - readings = ReadingsCache(kv: kv) - if let paired = sites.all().first { - site = paired - if let cached = readings.get() { - let nums = NowNumbersKt.nowNumbers(fields: cached.fields) - headline = SessionKt.headlineOf(readings: cached) - carrier = "cache" - grid = nums.grid - pv = nums.pv - battery = nums.battery - load = nums.load - } - connect() - } - } - - func pair() { - help = nil - let scanned = paste - Task { - do { - let wrapping = try await IosPasskey.enroll() - let paired = try client.pair(scanned: scanned, wrapping: wrapping) - await MainActor.run { - self.site = paired - self.connect() - } - } catch let err as EnrollmentError { - await MainActor.run { self.help = err.help } - } catch let err as VaultError { - await MainActor.run { self.help = err.help } - } catch { - await MainActor.run { - self.help = "That code did not read cleanly. Hold the phone steady and scan it again." - } - } - } - } - - func applyScanned(_ raw: String) { - paste = raw - pair() - } - - func connect() { - guard let site else { return } - session?.close() - epoch += 1 - let mine = epoch - do { - let session = try client.connect(site: site) - self.session = session - if let cached = readings.get() { - session.restore(readings: cached) - } - session.subscribe { [weak self] snap in - let hasFields = snap.readings.fields.count > 0 - let nums = NowNumbersKt.nowNumbers(fields: snap.readings.fields) - DispatchQueue.main.async { - guard let self, self.epoch == mine else { return } - self.headline = snap.headline - self.carrier = String(describing: snap.carrier) - self.srcState = String(describing: snap.srcState) - if hasFields { - self.grid = nums.grid - self.pv = nums.pv - self.battery = nums.battery - self.load = nums.load - self.readings.put(readings: snap.readings) - } - } - } - } catch let err as ConnectError { - help = err.help - } catch { - help = "Could not reach your box." - } - } - - func wake() { - session?.wake() - } - - func forget() { - epoch += 1 - session?.close() - session = nil - vault.clear() - sites.clear() - readings.clear() - site = nil - headline = "Waiting for the first reading." - carrier = "none" - srcState = "never" - grid = "—" - pv = "—" - battery = "—" - load = "—" - help = nil - paste = "" - } -} diff --git a/iosApp/iosApp/FTWApp.swift b/iosApp/iosApp/FTWApp.swift deleted file mode 100644 index ed7d635..0000000 --- a/iosApp/iosApp/FTWApp.swift +++ /dev/null @@ -1,43 +0,0 @@ -import SwiftUI -import Shared - -@main -struct FTWApp: App { - @StateObject private var model = AppModel() - - var body: some Scene { - WindowGroup { - RootView() - .environmentObject(model) - } - } -} - -struct RootView: View { - @EnvironmentObject var model: AppModel - @Environment(\.scenePhase) private var scenePhase - - var body: some View { - Group { - if model.site != nil { - NowView() - } else { - PairView() - } - } - .safeAreaInset(edge: .bottom) { - DisclosureGroup("Source & licenses") { - Text("© 2026 Sourceful Labs AB and contributors. You may copy, modify and share under the license. No warranty to the extent permitted by law.") - HStack { - Link("Source", destination: URL(string: SourceLicense.shared.sourceUrl)!) - Link("AGPLv3 + Energyplan permission", destination: URL(string: SourceLicense.shared.licenseUrl)!) - } - } - .font(.caption) - .padding(8) - } - .onChange(of: scenePhase) { _, phase in - if phase == .active { model.wake() } - } - } -} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist deleted file mode 100644 index 539b00a..0000000 --- a/iosApp/iosApp/Info.plist +++ /dev/null @@ -1,36 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - FTW - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - FTW - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - NSCameraUsageDescription - FTW uses the camera to scan the pairing code on your box. - NSFaceIDUsageDescription - FTW uses Face ID to unlock the key that talks to your box. - UILaunchScreen - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - - diff --git a/iosApp/iosApp/KeychainStore.swift b/iosApp/iosApp/KeychainStore.swift deleted file mode 100644 index bff0d3e..0000000 --- a/iosApp/iosApp/KeychainStore.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation -import Security -import Shared - -/// Generic-password KeyValueStore. After first unlock, this device only — readable at cold start without Face ID. -final class KeychainStore: NSObject, KeyValueStore { - private let service = "energy.ftw.app" - - func get(key: String) -> KotlinByteArray? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return data.kotlinByteArray - } - - func put(key: String, value: KotlinByteArray) { - let data = value.data - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - SecItemDelete(query as CFDictionary) - var item = query - item[kSecValueData as String] = data - item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - SecItemAdd(item as CFDictionary, nil) - } - - func remove(key: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - SecItemDelete(query as CFDictionary) - } -} - -private extension Data { - var kotlinByteArray: KotlinByteArray { - let arr = KotlinByteArray(size: Int32(count)) - enumerated().forEach { i, b in arr.set(index: Int32(i), value: Int8(bitPattern: b)) } - return arr - } -} - -private extension KotlinByteArray { - var data: Data { - Data((0.. some View { - HStack { - Text(name).foregroundStyle(Color(red: 0.63, green: 0.63, blue: 0.63)) - Spacer() - Text(value).foregroundStyle(Color(red: 0.91, green: 0.91, blue: 0.91)) - } - } -} diff --git a/iosApp/iosApp/PairView.swift b/iosApp/iosApp/PairView.swift deleted file mode 100644 index 20f41b3..0000000 --- a/iosApp/iosApp/PairView.swift +++ /dev/null @@ -1,66 +0,0 @@ -import SwiftUI - -struct PairView: View { - @EnvironmentObject var model: AppModel - @State private var scanning = false - - var body: some View { - VStack(spacing: 24) { - Spacer() - Text("FTW") - .font(.largeTitle.weight(.semibold)) - .foregroundStyle(Color(red: 0.91, green: 0.91, blue: 0.91)) - Text("Scan the pairing code on your box.") - .font(.body) - .foregroundStyle(Color(red: 0.63, green: 0.63, blue: 0.63)) - .multilineTextAlignment(.center) - if scanning { - QRScanner { code in - scanning = false - model.applyScanned(code) - } - .frame(height: 280) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } else { - Button { - scanning = true - } label: { - ZStack { - RoundedRectangle(cornerRadius: 16) - .strokeBorder(Color(red: 0.16, green: 0.16, blue: 0.16), lineWidth: 1) - .frame(height: 240) - VStack(spacing: 8) { - Image(systemName: "qrcode.viewfinder") - .font(.system(size: 48)) - .foregroundStyle(Color(red: 0.85, green: 0.82, blue: 0.25)) - Text("Scan") - .font(.footnote) - .foregroundStyle(Color(red: 0.52, green: 0.52, blue: 0.52)) - } - } - } - } - TextField("Or paste a pairing link", text: $model.paste) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .padding(12) - .background(Color(red: 0.09, green: 0.09, blue: 0.09)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .foregroundStyle(Color(red: 0.91, green: 0.91, blue: 0.91)) - Button("Continue", action: model.pair) - .buttonStyle(.borderedProminent) - .tint(Color(red: 0.85, green: 0.82, blue: 0.25)) - .foregroundStyle(Color(red: 0.04, green: 0.04, blue: 0.04)) - if let help = model.help { - Text(help) - .font(.footnote) - .foregroundStyle(Color(red: 0.72, green: 0.18, blue: 0.12)) - .multilineTextAlignment(.center) - } - Spacer() - } - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(red: 0.05, green: 0.05, blue: 0.05)) - } -} diff --git a/iosApp/iosApp/PasskeyHost.swift b/iosApp/iosApp/PasskeyHost.swift deleted file mode 100644 index e8dc5b9..0000000 --- a/iosApp/iosApp/PasskeyHost.swift +++ /dev/null @@ -1,150 +0,0 @@ -import AuthenticationServices -import CryptoKit -import Foundation -import Shared -import UIKit - -/// PasskeyHost for FTW. RP ID app.ftw.energy, PRF salt ftw.prf.v1.vault. -final class IosPasskey: NSObject, PasskeyHost { - var rpId: String { "app.ftw.energy" } - var rpName: String { "FTW" } - var prfSalt: KotlinByteArray { Data("ftw.prf.v1.vault".utf8).kotlinByteArray } - - func enroll(label: String) -> WrappingKey { - bridge { try await IosPasskey.enroll(label: label) } - } - - func unlock(label: String) -> WrappingKey { - bridge { try await IosPasskey.unlock(label: label) } - } - - @MainActor - static func enroll(label: String = "FTW") async throws -> WrappingKey { - try await ceremony(register: true, label: label) - } - - @MainActor - static func unlock(label: String = "FTW") async throws -> WrappingKey { - try await ceremony(register: false, label: label) - } - - @MainActor - private static func ceremony(register: Bool, label: String) async throws -> WrappingKey { - let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "app.ftw.energy") - let challenge = Data((0..<32).map { _ in UInt8.random(in: 0...255) }) - let salt = Data("ftw.prf.v1.vault".utf8) - let request: ASAuthorizationRequest - if register { - let userId = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) - let create = provider.createCredentialRegistrationRequest( - challenge: challenge, - name: label, - userID: userId - ) - create.userVerificationPreference = .required - if #available(iOS 18.0, *) { - create.prf = .inputValues(.init(saltInput1: salt)) - } - request = create - } else { - let get = provider.createCredentialAssertionRequest(challenge: challenge) - get.userVerificationPreference = .required - if #available(iOS 18.0, *) { - get.prf = .inputValues(.init(saltInput1: salt)) - } - request = get - } - let controller = ASAuthorizationController(authorizationRequests: [request]) - let delegate = Delegate() - controller.delegate = delegate - controller.presentationContextProvider = delegate - return try await withCheckedThrowingContinuation { cont in - delegate.cont = cont - controller.performRequests() - } - } - - private func bridge(_ work: @escaping () async throws -> WrappingKey) -> WrappingKey { - let slot = Slot() - Task { @MainActor in - do { - slot.value = try await work() - } catch { - slot.error = error - } - slot.done = true - } - let deadline = Date().addingTimeInterval(120) - while Date() < deadline && !slot.done { - RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05)) - } - if let value = slot.value { return value } - let reason = slot.error?.localizedDescription ?? "timed out" - preconditionFailure("passkey ceremony failed: \(reason)") - } -} - -private final class Slot { - var value: WrappingKey? - var error: Error? - var done = false -} - -private final class Delegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { - var cont: CheckedContinuation? - - func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - .first { $0.isKeyWindow } ?? ASPresentationAnchor() - } - - func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { - var prf: Data? - var credId = Data() - if #available(iOS 18.0, *) { - if let cred = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { - credId = cred.credentialID - prf = cred.prf?.first?.rawData - } - if let cred = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { - credId = cred.credentialID - if let out = cred.prf { prf = out.first.rawData } - } - } else if let cred = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { - credId = cred.credentialID - } else if let cred = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { - credId = cred.credentialID - } - let key = prf ?? Data((0..<32).map { _ in UInt8.random(in: 0...255) }) - let source: WrappingSource = prf == nil ? .local : .prf - cont?.resume( - returning: WrappingKey( - credentialId: credId.base64EncodedString(), - source: source, - key: key.kotlinByteArray - ) - ) - cont = nil - } - - func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - cont?.resume(throwing: error) - cont = nil - } -} - -private extension SymmetricKey { - var rawData: Data { - withUnsafeBytes { Data($0) } - } -} - -private extension Data { - var kotlinByteArray: KotlinByteArray { - let arr = KotlinByteArray(size: Int32(count)) - enumerated().forEach { i, b in arr.set(index: Int32(i), value: Int8(bitPattern: b)) } - return arr - } -} diff --git a/iosApp/iosApp/QRScanner.swift b/iosApp/iosApp/QRScanner.swift deleted file mode 100644 index 5dc5940..0000000 --- a/iosApp/iosApp/QRScanner.swift +++ /dev/null @@ -1,55 +0,0 @@ -import AVFoundation -import SwiftUI -import Vision - -/// Native camera QR scanner. Hands the first FTW pairing URL to the shared parser. -struct QRScanner: UIViewControllerRepresentable { - var onCode: (String) -> Void - - func makeUIViewController(context: Context) -> ScannerController { - let c = ScannerController() - c.onCode = onCode - return c - } - - func updateUIViewController(_ uiViewController: ScannerController, context: Context) {} -} - -final class ScannerController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate { - var onCode: ((String) -> Void)? - private let session = AVCaptureSession() - private var locked = false - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .black - guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), - let input = try? AVCaptureDeviceInput(device: device) else { return } - session.addInput(input) - let output = AVCaptureVideoDataOutput() - output.setSampleBufferDelegate(self, queue: DispatchQueue(label: "ftw.qr")) - session.addOutput(output) - let preview = AVCaptureVideoPreviewLayer(session: session) - preview.videoGravity = .resizeAspectFill - preview.frame = view.bounds - view.layer.addSublayer(preview) - DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() } - } - - func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { - if locked { return } - guard let pixel = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } - let request = VNDetectBarcodesRequest { [weak self] req, _ in - guard let code = (req.results as? [VNBarcodeObservation])?.first?.payloadStringValue else { return } - guard code.contains("ftw.energy") || code.contains("/p#") else { return } - DispatchQueue.main.async { - guard let self, !self.locked else { return } - self.locked = true - self.session.stopRunning() - self.onCode?(code) - } - } - request.symbologies = [.qr] - try? VNImageRequestHandler(cvPixelBuffer: pixel, options: [:]).perform([request]) - } -} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index fc1dacb..f007153 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -47,17 +47,8 @@ kotlin { } } jvm() - - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { target -> - target.binaries.framework { - baseName = "Shared" - isStatic = true - } - } + // iOS and macOS are pure Swift now: appleApp/ carries its own copy of + // this logic in FTWKit, checked against the same vectors. sourceSets { commonMain { diff --git a/shared/src/iosMain/kotlin/energy/ftw/carrier/IosSockets.kt b/shared/src/iosMain/kotlin/energy/ftw/carrier/IosSockets.kt deleted file mode 100644 index fdceb20..0000000 --- a/shared/src/iosMain/kotlin/energy/ftw/carrier/IosSockets.kt +++ /dev/null @@ -1,75 +0,0 @@ -package energy.ftw.carrier - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.convert -import kotlinx.cinterop.usePinned -import platform.Foundation.NSData -import platform.Foundation.NSMutableURLRequest -import platform.Foundation.NSURL -import platform.Foundation.NSURLSession -import platform.Foundation.NSURLSessionWebSocketMessage -import platform.Foundation.NSURLSessionWebSocketTask -import platform.Foundation.dataWithBytes -import platform.darwin.dispatch_async -import platform.darwin.dispatch_get_main_queue -import platform.posix.memcpy - -@OptIn(ExperimentalForeignApi::class) -class IosSockets : SocketFactory { - override fun open(url: String, listener: SocketListener): RawSocket { - val nsUrl = NSURL.URLWithString(url) ?: error("bad url") - val request = NSMutableURLRequest.requestWithURL(nsUrl) - val task = NSURLSession.sharedSession.webSocketTaskWithRequest(request) - val handle = object : RawSocket { - override fun sendBinary(bytes: ByteArray) { - bytes.usePinned { pinned -> - val data = NSData.dataWithBytes(pinned.addressOf(0), bytes.size.convert()) - task.sendMessage(NSURLSessionWebSocketMessage(data)) { _ -> } - } - } - - override fun close() { - task.cancel() - } - } - receive(task, listener) - task.resume() - dispatch_async(dispatch_get_main_queue()) { listener.onOpen() } - return handle - } - - private fun receive(task: NSURLSessionWebSocketTask, listener: SocketListener) { - task.receiveMessageWithCompletionHandler { message, error -> - if (error != null) { - val code = task.closeCode.toInt() - val reasonBytes = task.closeReason - val reason = if (reasonBytes != null && reasonBytes.length.toInt() > 0) { - nsDataToBytes(reasonBytes).decodeToString() - } else { - error.localizedDescription - } - listener.onClose(if (code == 0) 1006 else code, reason) - return@receiveMessageWithCompletionHandler - } - val text = message?.string - val data = message?.data - if (text != null) listener.onText(text) - if (data != null) { - listener.onBinary(nsDataToBytes(data)) - } - receive(task, listener) - } - } -} - -@OptIn(ExperimentalForeignApi::class) -private fun nsDataToBytes(data: NSData): ByteArray { - val n = data.length.toInt() - if (n == 0) return ByteArray(0) - val out = ByteArray(n) - out.usePinned { pinned -> - memcpy(pinned.addressOf(0), data.bytes, n.convert()) - } - return out -} diff --git a/shared/src/iosMain/kotlin/energy/ftw/carrier/Retry.ios.kt b/shared/src/iosMain/kotlin/energy/ftw/carrier/Retry.ios.kt deleted file mode 100644 index 860ad9c..0000000 --- a/shared/src/iosMain/kotlin/energy/ftw/carrier/Retry.ios.kt +++ /dev/null @@ -1,11 +0,0 @@ -package energy.ftw.carrier - -import platform.darwin.DISPATCH_TIME_NOW -import platform.darwin.dispatch_after -import platform.darwin.dispatch_get_main_queue -import platform.darwin.dispatch_time - -internal actual fun scheduleRetry(delayMs: Long, block: () -> Unit) { - val ns = delayMs * 1_000_000L - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, ns), dispatch_get_main_queue(), block) -} diff --git a/shared/src/iosMain/kotlin/energy/ftw/carrier/Time.ios.kt b/shared/src/iosMain/kotlin/energy/ftw/carrier/Time.ios.kt deleted file mode 100644 index c3715dc..0000000 --- a/shared/src/iosMain/kotlin/energy/ftw/carrier/Time.ios.kt +++ /dev/null @@ -1,6 +0,0 @@ -package energy.ftw.carrier - -import platform.Foundation.NSDate -import platform.Foundation.timeIntervalSince1970 - -actual fun nowMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/shared/src/iosMain/kotlin/energy/ftw/crypto/Random.ios.kt b/shared/src/iosMain/kotlin/energy/ftw/crypto/Random.ios.kt deleted file mode 100644 index f3ce84c..0000000 --- a/shared/src/iosMain/kotlin/energy/ftw/crypto/Random.ios.kt +++ /dev/null @@ -1,16 +0,0 @@ -package energy.ftw.crypto - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.usePinned -import platform.Security.SecRandomCopyBytes -import platform.Security.errSecSuccess -import platform.Security.kSecRandomDefault - -@OptIn(ExperimentalForeignApi::class) -actual fun fillRandom(bytes: ByteArray) { - val rc = bytes.usePinned { pinned -> - SecRandomCopyBytes(kSecRandomDefault, bytes.size.toULong(), pinned.addressOf(0)) - } - require(rc == errSecSuccess) { "SecRandomCopyBytes failed: $rc" } -} From 56210fc59c9dc3fc86df41ca8a3c5eb902ea00ef Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 17:06:55 +0200 Subject: [PATCH 02/25] Cut the Swift app down to the iPhone M1 foundation iPhone only, iOS 26. Pairing, Now with the price card, and the demo stay. History, Plan, Box admin, escrow, spoken box codes, the macOS target and menu bar, swift-crypto, the Linux job and PlainWebSocket go. The charger sheet goes too; the car view replaces it next. - One Swift package, three modules: FTWKit (the app's logic), FTWShared (Sendable values for widgets and later a notification extension) and FTWDemo (the simulated box, out of the product module). - FTWShared holds the App Group cache: prices- and car-, each with carrier, srcState and when it was read, written with completeUntilFirstUserAuthentication. A reader takes any number of sources, so a cloud copy can be one more source later. The price and freshness formatters moved here, in English and Swedish. - The car record keeps soc_source and when the level was read. - Contract.swift follows main's registry: planner.prefs.set and the modes without priority and weighted. ContractTests now also checks modes, frozen field ids, the shared enums and the demo box's names. - The session closes when the app goes to the background and reopens on return without a passkey. A relay 4429 shows "FTW is busy". - A WidgetKit extension with one price widget that reads the cache. The App Group is written once, in FTW.xcconfig; both entitlements and Info.plists read it. No DEVELOPMENT_TEAM; simulator builds sign ad hoc so the entitlements travel with the app. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/apple.yml | 37 +- .gitignore | 3 +- CLAUDE.md | 87 ++- README.md | 143 ++++- appleApp/FTW-Info.plist | 13 + appleApp/FTW-macOS.entitlements | 19 - ...{FTW-iOS.entitlements => FTW.entitlements} | 4 + appleApp/FTW.xcconfig | 19 + appleApp/FTW.xcodeproj/project.pbxproj | 223 +++++-- appleApp/FTW/FTWApp.swift | 57 +- appleApp/FTW/Platform/KeychainStore.swift | 40 +- appleApp/FTW/Platform/Passkeys.swift | 4 - appleApp/FTW/Platform/QRScanner.swift | 107 +--- appleApp/FTW/UI/BoxView.swift | 353 ----------- appleApp/FTW/UI/EVSheet.swift | 584 ------------------ appleApp/FTW/UI/FlowDiagram.swift | 4 +- appleApp/FTW/UI/GlanceView.swift | 61 -- appleApp/FTW/UI/HistoryView.swift | 311 ---------- appleApp/FTW/UI/LiveSheet.swift | 101 --- appleApp/FTW/UI/NowView.swift | 53 +- appleApp/FTW/UI/PairView.swift | 56 +- appleApp/FTW/UI/PlanView.swift | 204 ------ appleApp/FTW/UI/PriceChart.swift | 2 +- appleApp/FTW/UI/RootView.swift | 51 +- appleApp/FTW/UI/Theme.swift | 14 - appleApp/FTWKit/Package.resolved | 24 - appleApp/FTWKit/Package.swift | 42 +- .../Demo => FTWDemo}/SimulatedBox.swift | 176 +----- .../Demo => FTWDemo}/SimulatedHouse.swift | 0 .../Sources/FTWKit/Carrier/Carrier.swift | 16 +- .../Sources/FTWKit/Carrier/NoiseCarrier.swift | 1 + .../Sources/FTWKit/Carrier/RelayCarrier.swift | 11 +- appleApp/FTWKit/Sources/FTWKit/Exports.swift | 2 + .../Sources/FTWKit/Format/CommandText.swift | 38 ++ .../FTWKit/Sources/FTWKit/Format/EV.swift | 17 + .../Sources/FTWKit/Format/Explanation.swift | 1 + .../FTWKit/Sources/FTWKit/Format/Flow.swift | 1 + .../Sources/FTWKit/Format/PlanText.swift | 296 --------- .../Sources/FTWKit/Format/Savings.swift | 53 ++ .../Sources/FTWKit/Identity/BoxCode.swift | 70 --- .../Sources/FTWKit/Identity/Escrow.swift | 296 --------- .../Sources/FTWKit/Identity/Origin.swift | 3 - .../FTWKit/Sources/FTWKit/Identity/PRF.swift | 39 +- .../Sources/FTWKit/Identity/Pairing.swift | 46 +- .../FTWKit/Identity/RecoveryBlob.swift | 143 ----- .../Sources/FTWKit/Identity/Vault.swift | 4 +- .../Sources/FTWKit/Protocol/Contract.swift | 17 +- .../FTWKit/Protocol/HistoryGeometry.swift | 184 ------ .../Sources/FTWKit/Protocol/Messages.swift | 229 +------ .../Sources/FTWKit/Protocol/Session.swift | 98 +-- .../Sources/FTWKit/State/AppModel.swift | 88 +-- .../Sources/FTWKit/State/BoxModels.swift | 335 ---------- .../Sources/FTWKit/State/EnergyHistory.swift | 288 --------- .../FTWKit/State/LoadpointsModel.swift | 2 + .../Sources/FTWKit/State/NowModels.swift | 3 + .../Sources/FTWKit/State/PairModel.swift | 51 +- .../Sources/FTWKit/State/PlanModel.swift | 152 ----- .../FTWKit/State/SealedCopyModel.swift | 64 -- .../Sources/FTWKit/State/SiteModel.swift | 99 ++- .../Sources/FTWKit/Store/SealedFiles.swift | 55 -- .../FTWKit/Sources/FTWKit/Store/Sites.swift | 5 +- .../FTWKit/Sources/FTWKit/Support/JSON.swift | 22 + appleApp/FTWKit/Sources/FTWShared/Cache.swift | 228 +++++++ .../Format => FTWShared}/Freshness.swift | 57 +- .../{FTWKit/Format => FTWShared}/Power.swift | 12 +- .../Format => FTWShared}/PriceStrip.swift | 0 .../FTWKit/Sources/FTWShared/PriceUnits.swift | 50 ++ .../FTWKit/Sources/FTWShared/Prices.swift | 34 + .../FTWKit/Sources/FTWShared/Readings.swift | 36 ++ .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 28 + appleApp/FTWKit/Sources/FTWShared/Words.swift | 17 + .../Tests/FTWKitTests/AppModelTests.swift | 198 +++--- .../Tests/FTWKitTests/ContractTests.swift | 89 +++ appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift | 47 -- .../Tests/FTWKitTests/FormatTests.swift | 112 +--- .../Tests/FTWKitTests/IdentityTests.swift | 164 +---- .../Tests/FTWKitTests/LiveBoxTests.swift | 25 +- .../FTWKit/Tests/FTWKitTests/Loopback.swift | 1 + .../Tests/FTWKitTests/PlainWebSocket.swift | 181 ------ .../Tests/FTWKitTests/SessionTests.swift | 120 +--- .../Tests/FTWSharedTests/CacheTests.swift | 92 +++ .../FTWSharedTests/SharedFormatTests.swift | 109 ++++ appleApp/FTWWidgets-Info.plist | 18 + appleApp/FTWWidgets.entitlements | 10 + appleApp/FTWWidgets/PriceWidget.swift | 113 ++++ appleApp/scripts/demo-screenshots.sh | 7 +- 87 files changed, 1863 insertions(+), 5408 deletions(-) create mode 100644 appleApp/FTW-Info.plist delete mode 100644 appleApp/FTW-macOS.entitlements rename appleApp/{FTW-iOS.entitlements => FTW.entitlements} (76%) create mode 100644 appleApp/FTW.xcconfig delete mode 100644 appleApp/FTW/UI/BoxView.swift delete mode 100644 appleApp/FTW/UI/EVSheet.swift delete mode 100644 appleApp/FTW/UI/GlanceView.swift delete mode 100644 appleApp/FTW/UI/HistoryView.swift delete mode 100644 appleApp/FTW/UI/LiveSheet.swift delete mode 100644 appleApp/FTW/UI/PlanView.swift delete mode 100644 appleApp/FTWKit/Package.resolved rename appleApp/FTWKit/Sources/{FTWKit/Demo => FTWDemo}/SimulatedBox.swift (75%) rename appleApp/FTWKit/Sources/{FTWKit/Demo => FTWDemo}/SimulatedHouse.swift (100%) create mode 100644 appleApp/FTWKit/Sources/FTWKit/Exports.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/CommandText.swift delete mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/Savings.swift delete mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift delete mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift delete mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift delete mode 100644 appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift delete mode 100644 appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift delete mode 100644 appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift delete mode 100644 appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift delete mode 100644 appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Cache.swift rename appleApp/FTWKit/Sources/{FTWKit/Format => FTWShared}/Freshness.swift (61%) rename appleApp/FTWKit/Sources/{FTWKit/Format => FTWShared}/Power.swift (92%) rename appleApp/FTWKit/Sources/{FTWKit/Format => FTWShared}/PriceStrip.swift (100%) create mode 100644 appleApp/FTWKit/Sources/FTWShared/PriceUnits.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Prices.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Readings.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Localizable.strings create mode 100644 appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings create mode 100644 appleApp/FTWKit/Sources/FTWShared/Words.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift delete mode 100644 appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/SharedFormatTests.swift create mode 100644 appleApp/FTWWidgets-Info.plist create mode 100644 appleApp/FTWWidgets.entitlements create mode 100644 appleApp/FTWWidgets/PriceWidget.swift diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index 1de09e0..a5ec938 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -12,19 +12,9 @@ concurrency: cancel-in-progress: true jobs: - kit-linux: - name: FTWKit on Linux - runs-on: ubuntu-latest - container: swift:6.1 - steps: - - uses: actions/checkout@v4 - - name: Test - working-directory: appleApp/FTWKit - run: swift test - apple: - name: FTWKit and the app on macOS - runs-on: macos-15 + name: FTWKit and the iPhone app + runs-on: macos-26 steps: - uses: actions/checkout@v4 - name: Use the newest Xcode on the runner @@ -32,26 +22,21 @@ jobs: latest=$(ls -d /Applications/Xcode_*.app | sort -V | tail -1) sudo xcode-select -s "$latest" xcodebuild -version - - name: Test FTWKit with CryptoKit + - name: Test FTWKit, FTWShared and FTWDemo working-directory: appleApp/FTWKit run: swift test - # Unsigned builds: they prove the app compiles for both platforms. - # Signing, passkeys and the camera need a team and a device. - - name: Build the app for the iOS Simulator + # Signed ad hoc, as FTW.xcconfig sets for the simulator, so the App + # Group entitlement travels with the app and the widget. Device + # builds need a team, which the project does not have yet. + - name: Build the app and its widgets for the iOS Simulator working-directory: appleApp run: >- xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW -destination 'generic/platform=iOS Simulator' - -derivedDataPath build/ios CODE_SIGNING_ALLOWED=NO - - name: Build the app for macOS - working-directory: appleApp - run: >- - xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW - -destination 'generic/platform=macOS' - -derivedDataPath build/macos CODE_SIGNING_ALLOWED=NO - # For whoever reviews the screens: the demo, photographed per tab in - # the simulator. Evidence, not a gate, so a flaky simulator does not - # turn the build red. + -derivedDataPath build/ios + # For whoever reviews the screens: the demo, photographed in the + # simulator. Evidence, not a gate, so a flaky simulator does not turn + # the build red. - name: Photograph the demo in the iOS Simulator continue-on-error: true working-directory: appleApp diff --git a/.gitignore b/.gitignore index 5373ebb..00894b1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,7 @@ DerivedData/ *.aab captures/ -# iOS -iosApp/Pods/ +# Xcode *.xcworkspace/xcuserdata/ # Secrets diff --git a/CLAUDE.md b/CLAUDE.md index a941e9e..6e4f206 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,8 @@ # FTW native app — project guide -Kotlin Multiplatform shared logic. SwiftUI on iOS. Jetpack Compose on Android. +Two native apps. iPhone: pure Swift in `appleApp/` (FTWKit, FTWShared, +FTWDemo, SwiftUI, a WidgetKit extension). Android: Jetpack Compose on Kotlin +Multiplatform shared logic in `shared/`. Talks to an FTW box over an encrypted session; the box is the authority and this app is a cached projection of it. @@ -17,15 +19,32 @@ and useful notifications. Those goals do not remove the current native release gates below or claim features are present on either phone. Reuse Core's contracts and authority as native scope expands. -## Current v1 +## Current scope -Pair + Now only. Shipped on `main` as of 2026-08-22. Persist vault, site and -last readings on the phone. Cold start paints the cache, then reconnects -without a passkey. README Status lists what was proven and what is still open. +**iPhone (`appleApp/`).** On 2026-10-12 Fredrik approved M1 for an +iPhone-only app on iOS 26: pairing, a lean Now, a car view and price +widgets. The car view sets the level with 20/40/60/80 and ±5 through +`loadpoint.soc.set`, the goal through the schedule PUT, and has Charge now, +Pause and Back to the plan. M1 needs no server and no change in Core. +His decisions: -Do not add Energy / History / Plan / EV, commands, escrow, LAN, push, or store -listing until Pair + Now is solid on both phones, including wrap-key parity -with the web app. +- iOS 26 is the minimum. iPhone only, no macOS. +- Notification text is written on the phone, in Swedish and English. +- Commands from the lock screen, Siri and notifications require unlock. +- Charge now runs with no time limit. + +Do not add Plan, History, Box admin, escrow, LAN, push or the store listing +in M1. A cloud service that caches the box's state encrypted is designed +separately; nothing in M1 may depend on it. + +**Android (`androidApp/`, `shared/`).** Pair + Now, shipped on `main` as of +2026-08-22. Do not add Energy / History / Plan / EV, commands, escrow, LAN, +push, or store listing on Android until Pair + Now is solid there, including +wrap-key parity with the web app. + +Both: persist vault, site and last readings on the phone. Cold start paints +the cache, then reconnects without a passkey. README Status lists what was +proven and what is still open. The protocol, the QR, the relay and the identity model are specified in [ftw-webapp](https://github.com/srcfl/ftw-webapp) `docs/architecture.md` and @@ -39,7 +58,8 @@ The protocol, the QR, the relay and the identity model are specified in - Few required choices. Hide transport setup; expose useful household goals and expert controls when their flows are implemented. - Failures heal themselves. A dropped connection reconnects on its own. - There is no reconnect button. + There is no reconnect button. A relay that is full (4429) says FTW is + busy, not that something broke. - Errors say what happens now, not what broke inside. - Least code that does the whole job. @@ -56,27 +76,47 @@ The protocol, the QR, the relay and the identity model are specified in - **The cache is a cache, never the original.** - **The cache key is not PRF-wrapped.** Cold start paints before Face ID. PRF gates enrollment and privileged commands, not reading. +- **Widgets read the App Group cache and nothing else.** No key from the + Keychain, no relay stream. Every record carries `carrier`, `srcState` and + the time it was read, and reads back as `cache`. +- **The session closes in the background.** iOS freezes the app anyway, + and an open socket holds one of the relay's four streams per home. - **Never hand-write a name shared with the box.** Scopes, capabilities, field ids come from `protocol/registry.yaml`, the same file as in srcfl/ftw and srcfl/ftw-webapp. ## Shared vs UI -`shared/` owns enrollment parse, rendezvous handles, Noise IK, frames, -session, vault wrap/unwrap, freshness and explanations. - -Platform UI owns the camera, the passkey ceremony, Keychain / Keystore, -and every pixel. - -Inject `PasskeyHost`, `KeyValueStore` and `SocketFactory`. Do not call -AuthenticationServices or Credential Manager from commonMain. iOS uses -Keychain. Android uses EncryptedSharedPreferences + a Keystore master key. +On iPhone, one Swift package in `appleApp/FTWKit` holds three modules: + +- `FTWKit` owns everything that is not a pixel: enrollment parse, + rendezvous handles, Noise IK, frames, the relay and Noise carriers, the + session, vault wrap/unwrap and the state each screen reads. The app alone + links it. It writes the App Group cache. +- `FTWShared` holds plain `Sendable` values for the app's extensions: the + App Group cache (`prices-`, `car-`), the price and freshness + formatters, and their Swedish and English words. Nothing on the main + actor, no keys. A cloud copy of the box's state would be one more + `CacheSource` here, not a new model. +- `FTWDemo` is the simulated box. The app links it for the demo and App + Review; FTWKit never does. + +`appleApp/FTW` owns the camera, the passkey ceremony, the Keychain and every +pixel. `appleApp/FTWWidgets` owns the widgets. Keep logic out of views: if a +sentence or a rule can be tested, it belongs in a package module with a +test. The App Group name is written once, in `appleApp/FTW.xcconfig`; the +entitlements and Info.plists read it from there. + +On Android, `shared/` owns the same logic in Kotlin. Inject `PasskeyHost`, +`KeyValueStore` and `SocketFactory`. Do not call Credential Manager from +commonMain. Android uses EncryptedSharedPreferences + a Keystore master key. ## Crypto Noise_IK_25519_ChaChaPoly_SHA256, Cacophony-tested, must stay byte-identical to the TypeScript client and the Go box. Do not swap the primitives for a -library that has not passed `NoiseTest`. +library that has not passed `NoiseTest` (Kotlin) or `NoiseTests` (Swift). +FTWKit uses CryptoKit. ## RP ID @@ -86,8 +126,15 @@ strands every passkey. ## Tests ```bash -./gradlew :shared:jvmTest +./gradlew :shared:jvmTest # Android shared logic +cd appleApp/FTWKit && swift test # iPhone logic, on a Mac with Xcode 27 ``` +`ContractTests` reads `protocol/registry.yaml` and fails when `Contract.swift` +drifts from it. Update `Contract.swift` by hand, never with a name the +registry does not have. The Apple workflow also builds the app and widget +for the iOS Simulator. Review UI changes in the simulator or on a device; +reading the source is not enough. + Green before every handoff. New protocol code needs a vector, not only a round-trip against itself. diff --git a/README.md b/README.md index 5ae3564..6e8f443 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ Your home's energy, on the phone. -Native iOS (SwiftUI) and Android (Jetpack Compose). Shared logic is Kotlin -Multiplatform: pairing, passkeys, Noise, the relay, the session. The box at -home is the record. This app is a cached projection of it. The cloud is blind. +Two native apps. The iPhone app is pure Swift in `appleApp/`. The Android app +is Jetpack Compose on Kotlin Multiplatform (`shared/`, `androidApp/`). Both +carry pairing, passkeys, Noise, the relay and the session. The box at home is +the record. This app is a cached projection of it. The cloud is blind. Not a wrap of the [web app](https://github.com/srcfl/ftw-webapp). Same protocol, same QR, same relay, same RP ID (`app.ftw.energy`). @@ -25,33 +26,88 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Shape ``` -SwiftUI / Compose - │ - ▼ - shared (KMP) — enrollment, vault, Noise IK, frames, session, relay - │ - ▼ - wss://relay.ftw.energy (encrypted) - │ - ▼ - FTW box + iPhone (appleApp/) Android + SwiftUI app ── widgets Compose + │ │ │ + ▼ ▼ ▼ + FTWKit ──► FTWShared ◄── App Group shared (KMP) + session, cache records, enrollment, vault, + Noise, vault formatters Noise IK, frames, + │ session, relay + └──────────────────┬──────────────────────┘ + ▼ + wss://relay.ftw.energy (encrypted) + │ + ▼ + FTW box ``` Two taps: scan the QR on the box, Face ID / biometrics, the house. -## Status (2026-08-22) +## Status: iPhone (2026-10-12) + +M1 is pairing, a lean Now, a car view and price widgets. This is its +foundation, taken from the Swift draft in #5 and cut down to that scope. +iOS 26 is the minimum. iPhone only. + +**In the app** + +- Pair: camera QR, a link from outside shown before it is trusted, and the + live demo against a simulated box. +- Now: one sentence, the energy flow, the price card with the cheapest two + hours, today's totals and the fuse. The freshness band above it keeps + carrier and source state apart. +- A price widget that reads the App Group cache and nothing else. +- The app writes `prices-` and `car-` into the App Group after + each read, with where each reading came from and when. +- The session closes when the app goes to the background and opens again + on return, without a passkey. +- A relay that turns the phone away with 4429 shows "FTW is busy" ("FTW är + upptaget") instead of an error. + +**Proven** + +| Check | Result | +|---|---| +| `swift test` in `appleApp/FTWKit` (Xcode 27, Swift 6.4) | 92 tests green, 1 live test skipped | +| Cross-implementation vectors from the web app and the box | Noise, frames, rendezvous handles and the vault copy match | +| `ContractTests` against `protocol/registry.yaml` | Scopes, ops, capabilities, modes, errors, states and frozen field ids match | +| App and widget for the iOS Simulator, signed ad hoc | Builds; both carry the App Group entitlement | +| Demo in the iOS Simulator (iPhone 17, iOS 26.5) | Launches and streams | + +**Next in M1** + +The car view (level presets 20/40/60/80 and ±5 through `loadpoint.soc.set`, +the goal through the schedule PUT, Charge now, Pause and Back to the plan), +the four price widget families, then tests and screenshots. + +**Needs an owner decision or a device** + +- An Apple team. `DEVELOPMENT_TEAM` is unset, so nothing runs on a phone yet. + Set it once in `appleApp/FTW.xcconfig`. +- An `apple-app-site-association` file on `app.ftw.energy` that names + `.energy.ftw.app` under `webcredentials` and `applinks`. Passkeys + need it. + +**Not in M1 (do not start these next)** + +Plan, History, Box admin, escrow and recovery, spoken box codes, macOS, +LAN, WebRTC, push and the store listing. + +## Status: Android (2026-08-22) V1 is Pair + Now. That is on `main` as of 2026-08-22. Not a wrap of the web -app. Not Flutter, not React Native. +app. Not Flutter, not React Native. Android's KMP core stays as it is until +Android is back in scope. -**In the apps today** +**In the app today** - Scan or paste a v2 pairing QR (`https://app.ftw.energy/p#v2.…`). - One passkey prompt at enroll. RP ID `app.ftw.energy`. PRF salt `ftw.prf.v1.vault`. - Noise_IK_25519_ChaChaPoly_SHA256 to the box through `wss://relay.ftw.energy`. - Now shows headline plus grid / solar / battery / house from frozen field ids. -- Vault, site and last readings live in iOS Keychain / - Android EncryptedSharedPreferences. Cold start paints from cache, then +- Vault, site and last readings live in Android + EncryptedSharedPreferences. Cold start paints from cache, then reconnects without Face ID. Forget wipes the store. **Proven here** @@ -60,24 +116,24 @@ app. Not Flutter, not React Native. |---|---| | `./gradlew :shared:jvmTest` | Green | | Live box e2e (`127.0.0.1:18080` + production relay) | `hello_ok` + snapshot, phase `streaming` | -| iOS Simulator (iPhone 17, iOS 26.5) | Built and launched | | Android emulator `FTW_Phone` (API 35 ARM64) | APK installed, Pair shown twice | Passkey PRF cannot run on the JVM. Live e2e uses a local wrapping key for the ceremony and the real Noise / relay / box path. The Android emulator has no camera feed — paste the pairing link. -**Not v1 (do not start these next)** +**Not v1 on Android (do not start these next)** Energy, History, Plan, EV, commands, escrow restore, spoken codes, LAN, -WebRTC, push, App Store / Play listing. +WebRTC, push, Play listing. **Known holes** - `srcState` should follow the Now fields' `srcId` in the dict, not every driver on the site. -- Wrap key is raw PRF bytes, not the web app's HKDF. A native vault will not - open in the PWA, and the other way around. +- Wrap key is raw PRF bytes, not the web app's HKDF. An Android vault will + not open in the PWA, and the other way around. The iPhone app uses the + web app's HKDF. - `PasskeyHost.enroll` from Kotlin still blocks. The UIs call the async ceremony and skip that path. - Field ids in `Explanation.kt` are still hand-written; they should come from @@ -85,14 +141,20 @@ WebRTC, push, App Store / Play listing. ## Tests -JDK 21. +iPhone, on a Mac with Xcode 27: + +```bash +cd appleApp/FTWKit && swift test +``` + +Android, JDK 21: ```bash export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home" ./gradlew :shared:jvmTest ``` -That suite covers enrollment URLs, Cacophony Noise IK, hello/sub CBOR matching +The Android suite covers enrollment URLs, Cacophony Noise IK, hello/sub CBOR matching the box's interop hex, 512-byte lane 0 frames, the vault, and a session that turns hello_ok + snap into Now state. @@ -116,10 +178,18 @@ snapshot that includes the frozen field ids. ## Native apps -iOS: open `iosApp/iosApp.xcodeproj`. SwiftUI Pair (camera QR + paste) and Now. -Xcode 16+, iOS 18 for passkey PRF. A Run Script build phase compiles the -Shared framework with -`./gradlew :shared:embedAndSignAppleFrameworkForXcode`. +iPhone: open `appleApp/FTW.xcodeproj` in Xcode 27 and run the FTW scheme on +a simulator. The demo on the pairing screen runs without a box, a passkey or +a network; `-FTWDemo YES` opens it at launch in a debug build. From the +command line: + +```bash +cd appleApp +xcodebuild build -project FTW.xcodeproj -scheme FTW -destination 'generic/platform=iOS Simulator' +``` + +Simulator builds sign ad hoc (`FTW.xcconfig`), so the App Group entitlement +travels with the app and its widget. Android: `./gradlew :androidApp:assembleDebug` (minSdk 28). Pair uses CameraX + ML Kit for the QR. Passkeys go through Credential Manager. @@ -140,11 +210,20 @@ wrapping copy so Now paints without a passkey prompt. | Path | What | |---|---| -| `shared/` | KMP: identity, crypto, protocol, relay, session | +| `appleApp/FTWKit/Sources/FTWKit` | Swift: identity, crypto, protocol, relay, session, screen state. The app alone links it. | +| `appleApp/FTWKit/Sources/FTWShared` | `Sendable` values for the app and its extensions: the App Group cache and the price and freshness formatters | +| `appleApp/FTWKit/Sources/FTWDemo` | The simulated box, for the demo, App Review and previews | +| `appleApp/FTW/` | SwiftUI for iPhone, Keychain, passkeys, camera | +| `appleApp/FTWWidgets/` | WidgetKit extension. Reads the cache; no key, no relay. | +| `appleApp/FTW.xcconfig` | Settings app and widget share: the App Group, versions, signing | +| `shared/` | KMP for Android: identity, crypto, protocol, relay, session | | `androidApp/` | Compose UI | -| `iosApp/` | SwiftUI UI | | `protocol/registry.yaml` | Names shared with the box | -| `scripts/e2e-ftw.sh` | Live box e2e | +| `scripts/e2e-ftw.sh` | Live box e2e (Android shared logic) | + +The Xcode project uses folders that Xcode keeps in step with the disk. A new +file under `FTW/`, `FTWWidgets/` or a package target needs no change to +`project.pbxproj`; only a new target does. ## Licence diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist new file mode 100644 index 0000000..03bab63 --- /dev/null +++ b/appleApp/FTW-Info.plist @@ -0,0 +1,13 @@ + + + + + FTWAppGroup + $(FTW_APP_GROUP) + CFBundleLocalizations + + en + sv + + + diff --git a/appleApp/FTW-macOS.entitlements b/appleApp/FTW-macOS.entitlements deleted file mode 100644 index fb1a0a4..0000000 --- a/appleApp/FTW-macOS.entitlements +++ /dev/null @@ -1,19 +0,0 @@ - - - - - com.apple.developer.associated-domains - - webcredentials:app.ftw.energy - applinks:app.ftw.energy - - com.apple.security.app-sandbox - - com.apple.security.device.camera - - com.apple.security.files.user-selected.read-only - - com.apple.security.network.client - - - diff --git a/appleApp/FTW-iOS.entitlements b/appleApp/FTW.entitlements similarity index 76% rename from appleApp/FTW-iOS.entitlements rename to appleApp/FTW.entitlements index fbe5ddb..1411e58 100644 --- a/appleApp/FTW-iOS.entitlements +++ b/appleApp/FTW.entitlements @@ -7,5 +7,9 @@ webcredentials:app.ftw.energy applinks:app.ftw.energy + com.apple.security.application-groups + + $(FTW_APP_GROUP) + diff --git a/appleApp/FTW.xcconfig b/appleApp/FTW.xcconfig new file mode 100644 index 0000000..5099e34 --- /dev/null +++ b/appleApp/FTW.xcconfig @@ -0,0 +1,19 @@ +// Settings the app and its widget extension must agree on, in one place. +// Both targets build on this file through the project. + +// The App Group the app and widgets share. Written here once: both +// entitlements files and both Info.plists read it as $(FTW_APP_GROUP), and +// Swift reads it back from Info.plist (AppGroupFiles.infoKey). +FTW_APP_GROUP = group.energy.ftw.app + +// An extension's version must match its app's. +MARKETING_VERSION = 0.1.0 +CURRENT_PROJECT_VERSION = 1 + +IPHONEOS_DEPLOYMENT_TARGET = 26.0 + +// No Apple team yet. Simulator builds sign ad hoc so the entitlements, and +// with them the App Group, travel with the app. Set DEVELOPMENT_TEAM here +// once the team exists; device builds need it. +CODE_SIGN_STYLE = Automatic +CODE_SIGN_IDENTITY[sdk=iphonesimulator*] = - diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj index 31c513a..9a2ab24 100644 --- a/appleApp/FTW.xcodeproj/project.pbxproj +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -8,12 +8,43 @@ /* Begin PBXBuildFile section */ F7A000000000000000000014 /* FTWKit in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000016 /* FTWKit */; }; + F7A000000000000000000030 /* FTWDemo in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000031 /* FTWDemo */; }; + F7A000000000000000000032 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000033 /* FTWShared */; }; + F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000040 /* FTWWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + F7A000000000000000000035 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F7A000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F7A000000000000000000050; + remoteInfo = FTWWidgets; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + F7A000000000000000000036 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ F7A000000000000000000005 /* FTW.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FTW.app; sourceTree = BUILT_PRODUCTS_DIR; }; - F7A000000000000000000006 /* FTW-iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "FTW-iOS.entitlements"; sourceTree = ""; }; - F7A000000000000000000007 /* FTW-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "FTW-macOS.entitlements"; sourceTree = ""; }; + F7A000000000000000000006 /* FTW.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTW.entitlements; sourceTree = ""; }; + F7A000000000000000000007 /* FTWWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTWWidgets.entitlements; sourceTree = ""; }; + F7A000000000000000000008 /* FTW.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FTW.xcconfig; sourceTree = ""; }; + F7A000000000000000000009 /* FTW-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTW-Info.plist"; sourceTree = ""; }; + F7A00000000000000000000A /* FTWWidgets-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWWidgets-Info.plist"; sourceTree = ""; }; + F7A000000000000000000040 /* FTWWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -22,6 +53,11 @@ path = FTW; sourceTree = ""; }; + F7A000000000000000000041 /* FTWWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTWWidgets; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -30,6 +66,15 @@ buildActionMask = 2147483647; files = ( F7A000000000000000000014 /* FTWKit in Frameworks */, + F7A000000000000000000030 /* FTWDemo in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000052 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000032 /* FTWShared in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -40,8 +85,12 @@ isa = PBXGroup; children = ( F7A000000000000000000003 /* FTW */, - F7A000000000000000000006 /* FTW-iOS.entitlements */, - F7A000000000000000000007 /* FTW-macOS.entitlements */, + F7A000000000000000000041 /* FTWWidgets */, + F7A000000000000000000008 /* FTW.xcconfig */, + F7A000000000000000000006 /* FTW.entitlements */, + F7A000000000000000000009 /* FTW-Info.plist */, + F7A000000000000000000007 /* FTWWidgets.entitlements */, + F7A00000000000000000000A /* FTWWidgets-Info.plist */, F7A000000000000000000004 /* Products */, ); sourceTree = ""; @@ -50,6 +99,7 @@ isa = PBXGroup; children = ( F7A000000000000000000005 /* FTW.app */, + F7A000000000000000000040 /* FTWWidgets.appex */, ); name = Products; sourceTree = ""; @@ -64,10 +114,12 @@ F7A000000000000000000011 /* Sources */, F7A000000000000000000012 /* Frameworks */, F7A000000000000000000013 /* Resources */, + F7A000000000000000000036 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + F7A000000000000000000037 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( F7A000000000000000000003 /* FTW */, @@ -75,11 +127,35 @@ name = FTW; packageProductDependencies = ( F7A000000000000000000016 /* FTWKit */, + F7A000000000000000000031 /* FTWDemo */, ); productName = FTW; productReference = F7A000000000000000000005 /* FTW.app */; productType = "com.apple.product-type.application"; }; + F7A000000000000000000050 /* FTWWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A000000000000000000056 /* Build configuration list for PBXNativeTarget "FTWWidgets" */; + buildPhases = ( + F7A000000000000000000051 /* Sources */, + F7A000000000000000000052 /* Frameworks */, + F7A000000000000000000053 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000041 /* FTWWidgets */, + ); + name = FTWWidgets; + packageProductDependencies = ( + F7A000000000000000000033 /* FTWShared */, + ); + productName = FTWWidgets; + productReference = F7A000000000000000000040 /* FTWWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -93,6 +169,9 @@ F7A000000000000000000010 = { CreatedOnToolsVersion = 16.0; }; + F7A000000000000000000050 = { + CreatedOnToolsVersion = 27.0; + }; }; }; buildConfigurationList = F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */; @@ -100,6 +179,7 @@ hasScannedForEncodings = 0; knownRegions = ( en, + sv, Base, ); mainGroup = F7A000000000000000000002; @@ -113,6 +193,7 @@ projectRoot = ""; targets = ( F7A000000000000000000010 /* FTW */, + F7A000000000000000000050 /* FTWWidgets */, ); }; /* End PBXProject section */ @@ -125,6 +206,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000053 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -135,11 +223,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000051 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + F7A000000000000000000037 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F7A000000000000000000050 /* FTWWidgets */; + targetProxy = F7A000000000000000000035 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ F7A000000000000000000021 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F7A000000000000000000008 /* FTW.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -160,12 +264,11 @@ "DEBUG=1", "$(inherited)", ); - IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -173,6 +276,7 @@ }; F7A000000000000000000022 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F7A000000000000000000008 /* FTW.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -187,12 +291,12 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; }; name = Release; }; @@ -201,40 +305,29 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = "FTW-iOS.entitlements"; - "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "FTW-macOS.entitlements"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_HARDENED_RUNTIME = YES; + CODE_SIGN_ENTITLEMENTS = FTW.entitlements; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTW-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = FTW; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; PRODUCT_NAME = FTW; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -243,40 +336,75 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = "FTW-iOS.entitlements"; - "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "FTW-macOS.entitlements"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_HARDENED_RUNTIME = YES; + CODE_SIGN_ENTITLEMENTS = FTW.entitlements; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTW-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = FTW; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; PRODUCT_NAME = FTW; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + F7A000000000000000000054 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWWidgets.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.widgets; + PRODUCT_NAME = FTWWidgets; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + F7A000000000000000000055 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWWidgets.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.widgets; + PRODUCT_NAME = FTWWidgets; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -301,6 +429,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F7A000000000000000000056 /* Build configuration list for PBXNativeTarget "FTWWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000054 /* Debug */, + F7A000000000000000000055 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -316,6 +453,16 @@ package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; productName = FTWKit; }; + F7A000000000000000000031 /* FTWDemo */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWDemo; + }; + F7A000000000000000000033 /* FTWShared */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWShared; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F7A000000000000000000001 /* Project object */; diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index b03be0a..612fcd4 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -1,21 +1,34 @@ +import FTWDemo import FTWKit import SwiftUI +import WidgetKit @main struct FTWApp: App { @State private var app: AppModel @Environment(\.scenePhase) private var scenePhase private let network: NetworkWatch - static let mainWindow = "main" init() { let store = KeychainStore() let files = SealedFiles(directory: SealedFiles.defaultDirectory(), store: store) - let model = AppModel(store: store, files: files, passkeys: Passkeys(), build: AppInfo.build, ua: AppInfo.userAgent) + let model = AppModel( + store: store, + files: files, + // What the widgets read. Nil only in a build without the App + // Group entitlement, and then the app works without widgets. + shared: AppGroupFiles.shared(), + sharedWritten: { WidgetCenter.shared.reloadAllTimelines() }, + passkeys: Passkeys(), + build: AppInfo.build, + ua: AppInfo.userAgent, + // App Review and anyone without a box can try a simulated home. + demo: SimulatedBox.loopback + ) // A paired phone paints its cached home before anything else runs. model.launch() #if DEBUG - // For screenshots in CI: `-FTWDemo YES` opens the simulated home. + // For screenshots: `-FTWDemo YES` opens the simulated home. if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() } #endif _app = State(initialValue: model) @@ -23,25 +36,17 @@ struct FTWApp: App { } var body: some Scene { - WindowGroup(id: Self.mainWindow) { + WindowGroup { RootView(app: app) .onOpenURL { app.offer($0.absoluteString) } .onChange(of: scenePhase, initial: true) { _, phase in - app.setVisible(AppInfo.isVisible(phase)) + // Inactive is a glance at Control Center or the app + // switcher: slow the stream, keep the session. Only the + // background closes it. + app.setVisible(phase == .active) + if phase == .background { app.suspend() } } } - #if os(macOS) - .defaultSize(width: 520, height: 820) - #endif - - #if os(macOS) - MenuBarExtra { - GlanceView(app: app) - } label: { - Image(systemName: "bolt.fill") - } - .menuBarExtraStyle(.window) - #endif } } @@ -57,21 +62,5 @@ enum AppInfo { /// What the box records about this app in its hello. static var build: String { "\(version)+\(buildNumber)" } - static var userAgent: String { - #if os(iOS) - return "FTW iOS \(version)" - #else - return "FTW macOS \(version)" - #endif - } - - /// A phone in the background is not looked at. A Mac window behind - /// another app still is. - static func isVisible(_ phase: ScenePhase) -> Bool { - #if os(iOS) - return phase == .active - #else - return phase != .background - #endif - } + static var userAgent: String { "FTW iOS \(version)" } } diff --git a/appleApp/FTW/Platform/KeychainStore.swift b/appleApp/FTW/Platform/KeychainStore.swift index a4b98a4..16f5611 100644 --- a/appleApp/FTW/Platform/KeychainStore.swift +++ b/appleApp/FTW/Platform/KeychainStore.swift @@ -2,23 +2,23 @@ import Foundation import FTWKit import Security -/// The secure store on Apple platforms: generic passwords, readable after -/// first unlock, never synced and never migrated to another device. Cold -/// start reads it without Face ID, which is what lets the cache paint first. +/// The secure store: generic passwords, readable after first unlock, never +/// synced and never migrated to another device. Cold start reads it without +/// Face ID, which is what lets the cache paint first. +/// +/// No access group is named, so every item lives in the app's own default +/// group. The widget extension has a different one and can read none of it: +/// widgets get the App Group cache and no key. @MainActor final class KeychainStore: SecureStore { private let service = "energy.ftw.app" - /// The data protection keychain on the Mac, the one iOS always uses. A - /// build without a signing team cannot reach it, and falls back to the - /// login keychain rather than failing to remember anything. - private var dataProtection = true func get(_ key: String) -> Bytes? { var query = base(key) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: AnyObject? - let status = run { SecItemCopyMatching(query.merging(self.flavour) { $1 } as CFDictionary, &result) } + let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { return nil } return data.byteArray } @@ -26,18 +26,17 @@ final class KeychainStore: SecureStore { func put(_ key: String, _ value: Bytes) { let query = base(key) let update: [String: Any] = [kSecValueData as String: Data(value)] - let status = run { SecItemUpdate(query.merging(self.flavour) { $1 } as CFDictionary, update as CFDictionary) } + let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) if status == errSecItemNotFound { var item = query item[kSecValueData as String] = Data(value) item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - _ = run { SecItemAdd(item.merging(self.flavour) { $1 } as CFDictionary, nil) } + SecItemAdd(item as CFDictionary, nil) } } func remove(_ key: String) { - let query = base(key) - _ = run { SecItemDelete(query.merging(self.flavour) { $1 } as CFDictionary) } + SecItemDelete(base(key) as CFDictionary) } private func base(_ key: String) -> [String: Any] { @@ -47,21 +46,4 @@ final class KeychainStore: SecureStore { kSecAttrAccount as String: key, ] } - - private var flavour: [String: Any] { - #if os(macOS) - return dataProtection ? [kSecUseDataProtectionKeychain as String: true] : [:] - #else - return [:] - #endif - } - - private func run(_ call: () -> OSStatus) -> OSStatus { - let status = call() - if status == errSecMissingEntitlement, dataProtection { - dataProtection = false - return call() - } - return status - } } diff --git a/appleApp/FTW/Platform/Passkeys.swift b/appleApp/FTW/Platform/Passkeys.swift index 6bfc505..202d830 100644 --- a/appleApp/FTW/Platform/Passkeys.swift +++ b/appleApp/FTW/Platform/Passkeys.swift @@ -124,11 +124,7 @@ extension Ceremony: ASAuthorizationControllerDelegate { extension Ceremony: ASAuthorizationControllerPresentationContextProviding { func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { - #if os(iOS) let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows) return windows.first(where: \.isKeyWindow) ?? windows.first ?? ASPresentationAnchor() - #else - return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor() - #endif } } diff --git a/appleApp/FTW/Platform/QRScanner.swift b/appleApp/FTW/Platform/QRScanner.swift index 5b09354..8074d26 100644 --- a/appleApp/FTW/Platform/QRScanner.swift +++ b/appleApp/FTW/Platform/QRScanner.swift @@ -1,5 +1,4 @@ import AVFoundation -import CoreImage import SwiftUI /// Reads a pairing QR through the camera. The first code that looks like an @@ -8,6 +7,10 @@ struct QRScanner: View { var onCode: (String) -> Void @State private var allowed: Bool? + static func looksLikePairing(_ text: String) -> Bool { + text.contains("ftw.energy") || text.contains("/p#") + } + var body: some View { Group { switch allowed { @@ -32,23 +35,6 @@ struct QRScanner: View { } } -/// QR codes in a still picture, for a Mac reading a screenshot of the code. -enum QRImage { - static func looksLikePairing(_ text: String) -> Bool { - text.contains("ftw.energy") || text.contains("/p#") - } - - static func codes(in image: CIImage) -> [String] { - let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) - return (detector?.features(in: image) ?? []).compactMap { ($0 as? CIQRCodeFeature)?.messageString } - } - - static func pairingCode(at url: URL) -> String? { - guard let image = CIImage(contentsOf: url) else { return nil } - return codes(in: image).first(where: QRImage.looksLikePairing) - } -} - /// The capture session, started and stopped off the main thread as /// AVFoundation asks. Held in a box so the closures that move it are honest /// about crossing threads. @@ -60,7 +46,6 @@ private final class CaptureBox: @unchecked Sendable { func stop() { queue.async { self.session.stopRunning() } } } -#if os(iOS) private struct CameraView: UIViewRepresentable { var onCode: (String) -> Void @@ -112,7 +97,7 @@ private struct CameraView: UIViewRepresentable { } func found(_ text: String) { - guard !done, QRImage.looksLikePairing(text) else { return } + guard !done, QRScanner.looksLikePairing(text) else { return } done = true capture.stop() onCode(text) @@ -127,85 +112,3 @@ extension CameraView.Coordinator: @preconcurrency AVCaptureMetadataOutputObjects } } } -#else -private struct CameraView: NSViewRepresentable { - var onCode: (String) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - view.wantsLayer = true - context.coordinator.attach(view) - return view - } - - func updateNSView(_ view: NSView, context: Context) { - context.coordinator.onCode = onCode - } - - static func dismantleNSView(_ view: NSView, coordinator: Coordinator) { - coordinator.capture.stop() - } - - func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } - - /// The Mac has no metadata output, so frames go through Core Image. - @MainActor - final class Coordinator: NSObject { - let capture = CaptureBox() - var onCode: (String) -> Void - private var done = false - private let frames = DispatchQueue(label: "energy.ftw.frames") - private var reader: FrameReader? - - init(onCode: @escaping (String) -> Void) { - self.onCode = onCode - } - - func attach(_ view: NSView) { - let session = capture.session - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { return } - session.addInput(input) - let output = AVCaptureVideoDataOutput() - output.alwaysDiscardsLateVideoFrames = true - guard session.canAddOutput(output) else { return } - session.addOutput(output) - let reader = FrameReader(owner: self) - self.reader = reader - output.setSampleBufferDelegate(reader, queue: frames) - let preview = AVCaptureVideoPreviewLayer(session: session) - preview.videoGravity = .resizeAspectFill - preview.frame = view.bounds - preview.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] - view.layer?.addSublayer(preview) - capture.start() - } - - func found(_ text: String) { - guard !done, QRImage.looksLikePairing(text) else { return } - done = true - capture.stop() - onCode(text) - } - } - - /// Decodes on the frame queue and hands text to the main actor. - final class FrameReader: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, @unchecked Sendable { - private weak var owner: Coordinator? - - init(owner: Coordinator) { - self.owner = owner - } - - func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { - guard let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } - guard let text = QRImage.codes(in: CIImage(cvPixelBuffer: pixels)).first(where: QRImage.looksLikePairing) else { return } - let target = owner - DispatchQueue.main.async { - MainActor.assumeIsolated { target?.found(text) } - } - } - } -} -#endif diff --git a/appleApp/FTW/UI/BoxView.swift b/appleApp/FTW/UI/BoxView.swift deleted file mode 100644 index 9bfaa04..0000000 --- a/appleApp/FTW/UI/BoxView.swift +++ /dev/null @@ -1,353 +0,0 @@ -import CoreImage -import CoreImage.CIFilterBuiltins -import FTWKit -import SwiftUI - -/// The box this phone is paired to: what it is, who else it trusts, what it -/// tells phones, the spare key, and leaving. -struct BoxView: View { - let app: AppModel - let home: HomeModels - @State private var leaving = false - - private var site: SiteModel { home.site } - private var stored: StoredSite? { site.siteId.flatMap { app.sites.get($0) } } - - var body: some View { - Group { - Title("Your FTW Box") - if let stored { - Text(SiteList.fingerprint(stored.boxStaticKey.byteArray)) - .font(Theme.number(17)) - .foregroundStyle(Theme.fgDim) - } - Card { - Row("App version", "\(AppInfo.version) (\(AppInfo.buildNumber))", number: true) - if let build = site.session.box?.build { Row("Software", build, number: true) } - if let zone = Self.timeZone(site.session.box?.tz) { Row("Time zone", zone) } - if let stored { Row("Paired", Clock.day(stored.addedAtMs)) } - if let id = app.vault.deviceIDOnBox { Row("This phone", id, number: true) } - } - - AccessSection(site: site, access: home.access) - NotificationsSection(site: site, notify: home.notify) - RestartSection(site: site, restart: home.restart) - if let copy = app.sealedCopy { - SealedCopySection(copy: copy) - } - signOut - } - .onAppear { - home.access.activate() - home.notify.activate() - app.sealedCopy?.reload() - } - .onDisappear { - home.access.deactivate() - home.notify.deactivate() - } - } - - @ViewBuilder private var signOut: some View { - Divider().overlay(Theme.line) - if leaving { - Text("Sign out on this phone?").font(.title3.weight(.semibold)) - Text("This phone stops showing your home and forgets its key. Nothing is removed from your box. It keeps running and keeps every reading.") - if app.sealedCopy?.kept == true { - Text("The sealed copy stays, so you can open this home again with your passkey. To remove it instead, turn off the sealed copy above before you sign out.") - } else { - Text("To come back, open the box's local dashboard and use Settings → FTW app → Show pairing code.") - } - Text("Signing out here does not remove this phone from your box. If you are handing the phone on, remove it there too: Settings, then FTW app\(app.vault.deviceIDOnBox.map { ", looking for \($0)" } ?? "").") - Button("Sign out") { app.leave() }.buttonStyle(.danger) - Button("Cancel") { leaving = false }.buttonStyle(.quiet) - } else { - Text("Sign out").font(.title3.weight(.semibold)) - Text("Clears this home from this phone. Your box and its readings are not touched.") - Button("Sign out") { leaving = true }.buttonStyle(.outline) - } - } - - /// A box that says "local" means the zone it runs in, which is almost - /// always the phone's own. - static func timeZone(_ zone: String?) -> String? { - guard let named = zone?.trimmingCharacters(in: .whitespaces), !named.isEmpty else { return nil } - return named.lowercased() == "local" ? TimeZone.current.identifier : named - } -} - -private struct Row: View { - let label: String - let value: String - var number = false - - init(_ label: String, _ value: String, number: Bool = false) { - self.label = label - self.value = value - self.number = number - } - - var body: some View { - HStack(alignment: .firstTextBaseline) { - Text(label).foregroundStyle(Theme.fgDim) - Spacer() - Text(value) - .font(number ? Theme.number(14, weight: .regular) : .body) - .multilineTextAlignment(.trailing) - .textSelection(.enabled) - } - .font(.callout) - } -} - -// MARK: Who can see this home - -private struct AccessSection: View { - let site: SiteModel - let access: AccessModel - @State private var confirming: String? - - var body: some View { - Divider().overlay(Theme.line) - Text("Who can see this home").font(.title3.weight(.semibold)) - if !site.heardFromBox { - Text("Reaching your box…") - } else if !site.canConfigure { - Text("You have view-only access. You can see this home's readings; changing anything, and who else can see it, belongs to its owner.") - } else if !site.hasPassthrough { - Text("Sharing needs newer software on your box.") - } else { - members - invite - if let error = access.error { Problem(error) } - } - } - - @ViewBuilder private var members: some View { - if access.members.isEmpty { - Hint(access.loading ? "Reading your box…" : access.loaded ? "No phones are paired with this box." : "The list has not come through yet. Still asking.") - } - ForEach(access.members) { member in - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 2) { - Text(member.id).font(Theme.number(15)) - Text("\(AccessModel.roleLabel(member.role)) · \(access.seen(member.lastSeenMs))\(member.isThisPhone ? " · this phone" : "")") - .font(.caption) - .foregroundStyle(Theme.fgDim) - } - Spacer() - if member.isThisPhone { - Text("use Sign out").font(.caption).foregroundStyle(Theme.fgMuted) - } else if member.role == Contract.roleOwner, access.owners <= 1 { - Text("last owner").font(.caption).foregroundStyle(Theme.fgMuted) - } else if confirming == member.id { - Button("Remove") { - Task { if await access.revoke(member.id) { confirming = nil } } - } - .buttonStyle(.danger) - .fixedSize() - .disabled(access.busy == .revoking) - Button("Cancel") { confirming = nil }.buttonStyle(.quiet) - } else { - Button("Remove") { confirming = member.id }.buttonStyle(.outline) - } - } - .padding(.vertical, 4) - } - } - - @ViewBuilder private var invite: some View { - if let invite = access.invite { - Text("Let the person joining point their camera at this. It lets them see this home and nothing else, and it works once\(invite.expiresAtMs > 0 ? ", until \(Clock.time(invite.expiresAtMs))" : "").") - // A square and never text: what is in it is everything it takes - // to become a phone this house trusts. - QRCodeView(text: invite.url) - .frame(width: 240, height: 240) - .frame(maxWidth: .infinity) - .accessibilityLabel("Pairing code for this home") - Button("Done") { access.dismissInvite() }.buttonStyle(.quiet) - } else { - Hint("A viewer sees your readings and can change nothing. Making an invitation cancels any pairing code already showing on your box.") - Button(access.busy == .inviting ? "Asking your box" : "Invite someone to view") { - Task { await access.inviteViewer() } - } - .buttonStyle(.outline) - .disabled(access.busy == .inviting) - } - } -} - -/// A QR drawn on the device, crisp at any size. -struct QRCodeView: View { - let text: String - - var body: some View { - if let image = Self.render(text) { - Image(decorative: image, scale: 1) - .interpolation(.none) - .resizable() - .scaledToFit() - .padding(12) - .background(Color.white, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) - } else { - Hint("The code didn't draw. Open this screen again to draw it.") - } - } - - static func render(_ text: String) -> CGImage? { - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(text.utf8) - filter.correctionLevel = "M" - guard let output = filter.outputImage else { return nil } - return CIContext().createCGImage(output, from: output.extent) - } -} - -// MARK: Notifications - -private struct NotificationsSection: View { - let site: SiteModel - let notify: NotifyModel - - var body: some View { - Divider().overlay(Theme.line) - Text("Notifications").font(.title3.weight(.semibold)) - if !site.heardFromBox { - Text("Reaching your box…") - } else if !site.canConfigure { - Text("Notifications are turned on by this home's owner.") - } else if !site.hasPassthrough { - Text("Notifications need newer software on your box.") - } else if notify.oldBox { - Text("Your box doesn't have that yet. It may be running older software.") - } else { - // The box reaches phones through web push today. This app has no - // push channel of its own, and says so rather than offering a - // switch that could not deliver. - Text("This app does not receive notifications yet. Your box sends them to phones that turned them on in the FTW web app, and the choices below apply to every one of them.") - if notify.boxEnabled { - rules - } else { - Hint("No phone has turned notifications on for this box. Turn them on from the FTW web app on your phone.") - } - if let error = notify.error { Problem(error) } - } - } - - @ViewBuilder private var rules: some View { - ForEach(NotifyModel.ruleKinds.filter { notify.availableKinds.contains($0) }, id: \.self) { kind in - Toggle(NotifyModel.labels[kind] ?? kind, isOn: Binding( - get: { notify.rules[kind] ?? false }, - set: { on in - var next = notify.rules - next[kind] = on - Task { await notify.save(next) } - } - )) - .disabled(notify.busy != .none) - } - Hint("\(NotifyModel.labels["box.unreachable"] ?? "") is always on while notifications are on.") - if notify.busy == .saving { Hint("Saving…") } - Button(notify.busy == .testing ? "Asking your box…" : "Send a test") { - Task { await notify.sendTest() } - } - .buttonStyle(.outline) - .disabled(notify.busy != .none) - if notify.testSent { - Hint("Sent. It shows up on the phones your box can reach in a moment.") - } - if !notify.history.isEmpty { - VStack(alignment: .leading, spacing: 6) { - ForEach(notify.history) { row in - HStack { - Text(row.title).font(.callout) - Spacer() - Text(notify.when(row.atMs)).font(.caption).foregroundStyle(Theme.fgMuted) - } - } - } - } - } -} - -// MARK: Restart - -private struct RestartSection: View { - let site: SiteModel - let restart: RestartModel - - var body: some View { - if restart.canAsk { - Divider().overlay(Theme.line) - switch restart.stage { - case .confirming: - Text("Restart this box?").font(.title3.weight(.semibold)) - Text("The software restarts. Devices keep running on their own until it comes back, usually within a minute. This phone reconnects by itself.") - Button("Restart now") { Task { await restart.restart() } }.buttonStyle(.danger) - Button("Cancel") { restart.stage = .idle }.buttonStyle(.quiet) - case .restarting: - Text("Restarting").font(.title3.weight(.semibold)) - Text("Your box is coming back on its own. This usually takes a minute.") - case .idle: - Text("Restart").font(.title3.weight(.semibold)) - Text("Restarts the software on this box. Use it when a device is stuck and will not come back on its own.") - Button("Restart this box") { restart.stage = .confirming }.buttonStyle(.outline) - } - if let error = restart.error { Problem(error) } - } - } -} - -// MARK: The spare key - -private struct SealedCopySection: View { - let copy: SealedCopyModel - - var body: some View { - Divider().overlay(Theme.line) - Text("If you lose this phone").font(.title3.weight(.semibold)) - if copy.kept { - Text("Sourceful holds a sealed copy it cannot open, with an opaque id and nothing beside it. A new phone gets this home back with your passkey alone.") - Text("Which also means your passkey is enough to open this home, on any device that can pass Face ID for it.") - Button(copy.stage == .working ? "Removing the copy" : "Remove the copy") { Task { await copy.set(false) } } - .buttonStyle(.outline) - .disabled(copy.stage == .working) - } else { - Text("No sealed recovery copy is saved for this home right now. FTW normally makes one when you pair on a phone that supports passkey recovery. Sourceful can hold that copy without opening it, with an opaque id and nothing beside it, so a new phone gets this home back with your passkey.") - Text("The cost: your passkey is then enough to open this home, on any device that can pass Face ID for it. Nothing is saved unless you ask.") - Button(copy.stage == .working ? "Saving a sealed copy" : "Keep a sealed copy") { Task { await copy.set(true) } } - .buttonStyle(.outline) - .disabled(copy.stage == .working) - } - if copy.stage == .failed, let problem = copy.problem { Problem(problem) } - } -} - -// MARK: The demo's Box screen - -struct DemoBoxView: View { - let site: SiteModel - let exit: () -> Void - - var body: some View { - Title("Demo home") - Text("Simulated FTW box").font(Theme.number(15)).foregroundStyle(Theme.fgDim) - Text("This is the same app and protocol as a connected home. The readings and changes stay in this demo and reset when you leave.") - Card { - Row("App version", "\(AppInfo.version) (\(AppInfo.buildNumber))", number: true) - if let build = site.session.box?.build { Row("Software", build, number: true) } - if let zone = BoxView.timeZone(site.session.box?.tz) { Row("Time zone", zone) } - Row("Data", "Simulated") - } - Divider().overlay(Theme.line) - Text("Access").font(.title3.weight(.semibold)) - Text("On a connected home, this screen lists owner and viewer phones. An owner can invite a viewer or remove access.") - Divider().overlay(Theme.line) - Text("Notifications").font(.title3.weight(.semibold)) - Text("A connected home can tell its phones about useful events, such as a finished EV charge, a device that stops answering, or a box that went quiet.") - Divider().overlay(Theme.line) - Text("Passkey recovery").font(.title3.weight(.semibold)) - Text("A real home can keep a sealed recovery copy for its passkey. Sourceful cannot open it, and the owner can remove it here.") - Button("Exit demo and connect your box", action: exit).buttonStyle(.primary) - } -} diff --git a/appleApp/FTW/UI/EVSheet.swift b/appleApp/FTW/UI/EVSheet.swift deleted file mode 100644 index b7e0c12..0000000 --- a/appleApp/FTW/UI/EVSheet.swift +++ /dev/null @@ -1,584 +0,0 @@ -import FTWKit -import SwiftUI - -/// The charger sheet. Everything on it is a fact the box served; every -/// control expresses intent with an expiry, and the box decides. After any -/// outcome the charger is read again, because the box's account is the -/// truth to repaint from. Controls are hidden from viewers as presentation; -/// the box's refusal is the actual gate. -struct EVSheet: View { - let site: SiteModel - let model: LoadpointsModel - let loadpointID: String? - @Environment(\.dismiss) private var dismiss - - struct GoalDraft: Equatable { - var loadpointID: String - var time: Date - var recurring: Bool - var days: Int - var socPct: Double - var surplusUnlockPct: Double - } - - struct BoostDraft: Equatable { - var loadpointID: String - var reservePct: Int - var durationS: Int - } - - @State private var goal: GoalDraft? - @State private var goalRevision = 0 - @State private var saving = false - @State private var saveError: String? - @State private var scheduleNote = "Changes apply as you make them." - @State private var justSavedGoal: String? - @State private var removedGoal: String? - @State private var boost: BoostDraft? - @State private var capacityDraft: [String: String] = [:] - @State private var capacityNote: [String: String] = [:] - @State private var capacityFailed: [String: Bool] = [:] - @State private var capacityBusy: String? - - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 14) { content } - .padding(20) - .frame(maxWidth: 560, alignment: .leading) - .frame(maxWidth: .infinity) - } - .background(Theme.surface) - .navigationTitle("EV charger") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Close") { dismiss() } - } - } - } - #if os(macOS) - .frame(minWidth: 460, minHeight: 560) - #endif - .onAppear { model.activate() } - .onDisappear { model.deactivate() } - .onChange(of: goal) { old, new in - // An edit, not the editor opening or closing. - guard let old, let new, old.loadpointID == new.loadpointID else { return } - goalRevision += 1 - saveError = nil - scheduleNote = "Applying schedule…" - } - .task(id: goalRevision) { - guard goalRevision > 0 else { return } - try? await Task.sleep(for: .milliseconds(400)) - guard !Task.isCancelled else { return } - await saveGoal() - } - } - - private var sending: Bool { model.command.isSending } - private var stale: Bool { model.stale } - - @ViewBuilder private var content: some View { - if site.session.phase == .streaming, !site.hasPassthrough { - Hint("Charging controls are not available from this box yet. Open the box's own page to manage charging.") - } else { - if site.session.phase != .streaming { - Hint("Connecting to your box. Charging status will appear here when it answers.") - } - if let error = model.error { Hint(error) } - if !model.loaded, model.error == nil { - if site.session.phase == .streaming { Hint("Reading your box…") } - } else { - if model.loaded, model.error == nil, model.points.isEmpty { - Hint("\(site.canConfigure ? "Connect your first charger on your box: open Settings → Chargers, then choose Connect a charger." : "Ask an owner to connect the first charger on the box, under Settings → Chargers.") Once connected and added there, it appears here too.") - } - let shown = model.points.filter { loadpointID == nil || $0.id == loadpointID } - ForEach(shown) { lp in - charger(lp) - if shown.count > 1 { Divider().overlay(Theme.line) } - } - if shown.isEmpty, model.loaded, model.error == nil, !model.points.isEmpty { - Hint("This charger is no longer listed. Close this view and choose a charger on the home screen.") - } - } - } - } - - // MARK: One charger - - @ViewBuilder private func charger(_ lp: Loadpoint) -> some View { - let canConfigure = site.canConfigure - Text(stale ? "Waiting for current charger status. The last reading is out of date." : EVText.status(lp, canControl: canConfigure)) - .font(.body.weight(.semibold)) - .fixedSize(horizontal: false, vertical: true) - if lp.manualSaveError { Hint(EVText.manualSaveErrorText) } - if !stale, let plan = planStatus(lp) { Hint(plan) } - if let seen = lp.charger?.updatedAtMs ?? lp.manual?.chargerUpdatedAtMs { - Hint("Charger last seen: \(EVText.clock(seen))", tone: Theme.fgMuted) - } - if let session = EVText.session(lp) { Text(session).font(.callout) } - - if lp.boostActive { - Text(EVText.boostActive(lp)) - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Theme.storage.opacity(0.18), in: Capsule()) - if canConfigure { - Button("Stop boost") { Task { await model.stopBoost(lp) } } - .buttonStyle(.outline) - .disabled(sending) - outcome(.boost, lp) - } - } - - if lp.pluggedIn { battery(lp) } - if canConfigure, let capacity = lp.vehicleCapacityWh { carBattery(lp, capacityWh: capacity) } - if canConfigure, lp.pluggedIn { chargeNow(lp) } - goalSection(lp) - if canConfigure, lp.pluggedIn { boostSection(lp) } - if let stopped = EVText.boostStopped(lp) { Hint(stopped) } - windows(lp) - } - - private func planStatus(_ lp: Loadpoint) -> String? { - if (lp.planPending || model.planPending), justSavedGoal == lp.id { return "Goal saved. Updating the plan…" } - var merged = lp - merged.planPending = lp.planPending || model.planPending - merged.planOutdated = lp.planOutdated || model.planOutdated - return EVText.plan(merged, nowMs: site.nowMs, canControl: site.canConfigure) - } - - @ViewBuilder private func outcome(_ control: LoadpointsModel.Control, _ lp: Loadpoint) -> some View { - if let text = model.outcome(for: control, lp) { Hint(text) } - } - - private func isSending(_ control: LoadpointsModel.Control, _ lp: Loadpoint) -> Bool { - if case .sending(let c) = model.command, c == control, model.commandLoadpointID == lp.id { return true } - return false - } - - // MARK: The car's battery now - - @ViewBuilder private func battery(_ lp: Loadpoint) -> some View { - let level = model.socShown(lp) - let unconfirmed = lp.socSource == "assumed" && model.socDraft[lp.id] == nil && model.acceptedSoc[lp.id] == nil - ControlCard { - HStack { - Text("Battery now").font(.subheadline.weight(.semibold)) - Spacer() - Text(unconfirmed ? "Not confirmed" : "\(Int(level)) %").font(Theme.number(15)) - } - if site.canConfigure { - Slider(value: Binding( - get: { model.socShown(lp) }, - set: { model.socDraft[lp.id] = $0.rounded() } - ), in: 0...100, step: 1) { editing in - guard !editing else { return } - let chosen = model.socShown(lp) - Task { - await model.setSoc(lp, pct: chosen) - if model.socDraft[lp.id] == chosen { model.socDraft[lp.id] = nil } - } - } - .disabled(sending) - .accessibilityLabel("Car's current charge, percent") - } - if isSending(.soc, lp) { - Hint("Sending charge level: \(Int(level)) %…") - } else if let text = model.outcome(for: .soc, lp) { - Hint(text) - } else if site.canConfigure { - Hint(EVText.socSource(lp)) - } else { - Hint(lp.socSource == "assumed" ? "Battery level needs confirmation by someone who can control this charger." : lp.socSource == "vehicle" ? "Reported by the car." : "Estimated from energy delivered.") - } - } - } - - // MARK: The car's battery size - - private func carBattery(_ lp: Loadpoint, capacityWh: Double) -> some View { - DisclosureGroup("Car battery · \(EnergyFormat.short(capacityWh / 1000)) kWh") { - VStack(alignment: .leading, spacing: 8) { - Hint(lp.capacitySource == "default" ? "FTW is using a default size. Check it against your car." : "Used for estimates. Check this size if you use another car.") - HStack { - Text("Usable battery size (kWh)").font(.callout) - Spacer() - TextField("kWh", text: Binding( - get: { capacityDraft[lp.id] ?? EnergyFormat.short(capacityWh / 1000) }, - set: { capacityDraft[lp.id] = $0 } - )) - .multilineTextAlignment(.trailing) - .frame(width: 90) - .textFieldStyle(.roundedBorder) - #if os(iOS) - .keyboardType(.decimalPad) - #endif - .accessibilityLabel("Usable battery size, kWh") - } - Button(capacityFailed[lp.id] == true ? "Try battery size again" : "Save battery size") { - Task { await setCapacity(lp) } - } - .buttonStyle(.outline) - .disabled(capacityBusy == lp.id) - Hint("Find the usable size in your car's specifications.", tone: Theme.fgMuted) - if let note = capacityNote[lp.id] { - Hint(note, tone: capacityFailed[lp.id] == true ? Theme.importing : Theme.fgDim) - } - } - .padding(.top, 6) - } - .font(.callout) - } - - private func setCapacity(_ lp: Loadpoint) async { - guard capacityBusy == nil else { return } - let typed = (capacityDraft[lp.id] ?? "").replacingOccurrences(of: ",", with: ".") - guard let kwh = Double(typed), (1...300).contains(kwh) else { - capacityNote[lp.id] = "Enter the usable battery size from 1 to 300 kWh." - capacityFailed[lp.id] = true - return - } - capacityBusy = lp.id - capacityFailed[lp.id] = false - capacityNote[lp.id] = "Sending battery size…" - do { - capacityNote[lp.id] = try await model.setCapacity(lp, kwh: kwh) - capacityDraft[lp.id] = nil - } catch { - capacityFailed[lp.id] = true - capacityNote[lp.id] = (error as? BoxAPIError)?.help ?? "Battery size is not confirmed. Check the current value before trying again." - } - capacityBusy = nil - } - - // MARK: Charge now - - @ViewBuilder private func chargeNow(_ lp: Loadpoint) -> some View { - let range = EVText.current(lp) - let chosen = model.ampsShown(lp) - let paused = EVText.isPaused(lp) - let holding = lp.manualActive && !lp.manualRestoreUnconfirmed && !paused - ControlCard { - if holding { - HStack { - Text("Charge now is active").font(.subheadline.weight(.semibold)) - Spacer() - Text(EVText.readout(lp, amps: chosen)).font(Theme.number(14)) - } - if range.maxA > range.minA { - Slider(value: Binding( - get: { Double(model.ampsShown(lp)) }, - set: { model.ampsDraft[lp.id] = Int($0.rounded()) } - ), in: Double(range.minA)...Double(range.maxA), step: 1) { editing in - guard !editing else { return } - let amps = model.ampsShown(lp) - Task { - await model.chargeNow(lp, amps: amps) - if model.ampsDraft[lp.id] == amps { model.ampsDraft[lp.id] = nil } - } - } - .disabled(sending) - .accessibilityLabel("Charging current") - } - } - if lp.manualActive || lp.manualRestoreUnconfirmed { - Button(paused || lp.manualRestoreUnconfirmed ? "Resume plan" : "Return to plan") { - Task { await model.stopCharging(lp) } - } - .buttonStyle(.outline) - .disabled(sending) - } - if !lp.manualActive || paused || lp.manualRestoreUnconfirmed { - Button(isSending(.hold, lp) ? "Sending charge request…" : "Charge now") { - Task { await model.chargeNow(lp, amps: chosen) } - } - .buttonStyle(.primary) - .disabled(sending) - } - if !paused { - Button("Pause charging") { Task { await model.pauseCharging(lp) } } - .buttonStyle(.quiet) - .disabled(sending) - } - if lp.manualRestoreUnconfirmed { - Hint("Choose Charge now to request charging, Resume plan to use your goal, or Pause charging to request a stop.") - } else if paused { - Hint("The goal and solar rule wait until you resume the plan. Charge now starts immediately.") - } else if lp.manualActive { - Hint("Changes apply when you release the slider. Return to plan restores your schedule and solar settings.") - } else { - Hint("Starts at up to \(EVText.readout(lp, amps: chosen)). Ignores the goal and solar rule until you return to the plan or unplug.") - } - outcome(.hold, lp) - } - } - - // MARK: Your goal - - @ViewBuilder private func goalSection(_ lp: Loadpoint) -> some View { - ControlCard { - Text("Your goal").font(.headline) - if lp.manualActive || lp.manualRestoreUnconfirmed { - Hint(EVText.isPaused(lp) || lp.manualRestoreUnconfirmed ? "Resume the plan to use this goal. Edits apply then." : "Charge now overrides this goal. Edits apply when you return to the plan.") - } - if let draft = goal, draft.loadpointID == lp.id { - goalEditor(lp) - } else { - if removedGoal == lp.id { - Hint(model.error != nil ? "Goal removed. Current charging status is unavailable." : "Goal removed.") - } - if let sentence = EVText.schedule(lp) { - HStack { - Text(sentence).font(.callout) - Spacer() - if site.canConfigure { - Button("Change goal") { beginEdit(lp) }.buttonStyle(.quiet) - } - } - } else if model.loaded, site.canConfigure { - Button("Set a ready time") { beginEdit(lp) }.buttonStyle(.outline) - } - if let saveError { Hint(saveError, tone: Theme.importing) } - } - surplus(lp) - } - } - - @ViewBuilder private func goalEditor(_ lp: Loadpoint) -> some View { - if let binding = Binding($goal) { - DatePicker("Ready by", selection: binding.time, displayedComponents: .hourAndMinute) - Toggle("Repeat on chosen days", isOn: binding.recurring) - if binding.wrappedValue.recurring { - HStack(spacing: 6) { - ForEach(Array(EVText.dayLabels.enumerated()), id: \.offset) { bit, day in - let on = binding.wrappedValue.days & (1 << bit) != 0 - Button(day) { binding.wrappedValue.days ^= 1 << bit } - .font(.caption.weight(.semibold)) - .padding(.vertical, 5) - .padding(.horizontal, 7) - .foregroundStyle(on ? Theme.onAccent : Theme.fgDim) - .background(on ? Theme.accent : Theme.surfaceSunken, in: Capsule()) - .buttonStyle(.plain) - .accessibilityAddTraits(on ? .isSelected : []) - } - } - } - HStack { - Text("Charge to") - Slider(value: binding.socPct, in: 10...100, step: 5) - .accessibilityLabel("Target charge, percent") - Text("\(Int(binding.wrappedValue.socPct)) %").font(Theme.number(14)).frame(width: 52, alignment: .trailing) - } - DisclosureGroup("Solar timing") { - Toggle("Also use spare solar before the planned hours", isOn: Binding( - get: { binding.wrappedValue.surplusUnlockPct > 0 }, - set: { binding.wrappedValue.surplusUnlockPct = $0 ? 50 : 0 } - )) - if binding.wrappedValue.surplusUnlockPct > 0 { - Stepper("Keep home battery above \(Int(binding.wrappedValue.surplusUnlockPct)) %", value: binding.surplusUnlockPct, in: 1...100, step: 5) - } - } - .font(.callout) - if lp.schedule == nil { - Button("Use \(Int(binding.wrappedValue.socPct)) % by \(binding.wrappedValue.time.formatted(date: .omitted, time: .shortened))") { - Task { await saveGoal() } - } - .buttonStyle(.primary) - .disabled(saving) - } - HStack { - Button("Close goal settings") { goal = nil }.buttonStyle(.quiet).disabled(saving) - Spacer() - if lp.schedule != nil { - Button("Remove") { Task { await removeGoal(lp) } }.buttonStyle(.quiet).disabled(saving) - } - } - Hint(saveError ?? (scheduleNote == "Schedule saved." && model.error != nil ? "Schedule saved. Current charging status is unavailable." : scheduleNote), tone: saveError != nil ? Theme.importing : Theme.fgDim) - if saveError != nil { - Button("Try again") { Task { await saveGoal() } }.buttonStyle(.quiet).disabled(saving) - } - } - } - - private func beginEdit(_ lp: Loadpoint) { - if removedGoal == lp.id { removedGoal = nil } - saveError = nil - scheduleNote = lp.schedule != nil ? "Changes apply as you make them." : "No goal set yet. Choose this goal, or change the level or time." - let clock = lp.schedule.map { EVText.localTime(minuteUTC: $0.timeOfDayMinUTC) } ?? (hour: 7, minute: 0) - let time = Calendar.current.date(bySettingHour: clock.hour, minute: clock.minute, second: 0, of: Date()) ?? Date() - let wireDays = lp.schedule?.days ?? 0 - let target = lp.targetSocPct.flatMap { $0 >= 10 ? $0 : nil } ?? 80 - goal = GoalDraft( - loadpointID: lp.id, - time: time, - recurring: lp.schedule?.recurring ?? false, - days: wireDays == 0 ? 0x7F : wireDays & 0x7F, - socPct: (lp.schedule?.socPct ?? target).rounded(), - surplusUnlockPct: lp.schedule?.surplusUnlockPct ?? 0 - ) - } - - private func saveGoal() async { - guard let draft = goal, !saving, let lp = model.points.first(where: { $0.id == draft.loadpointID }) else { return } - let revision = goalRevision - let parts = Calendar.current.dateComponents([.hour, .minute], from: draft.time) - saving = true - saveError = nil - do { - try await model.saveSchedule(lp, socPct: draft.socPct, hour: parts.hour ?? 7, minute: parts.minute ?? 0, recurring: draft.recurring, days: draft.days, surplusUnlockPct: draft.surplusUnlockPct) - justSavedGoal = lp.id - if revision == goalRevision { scheduleNote = "Schedule saved." } - } catch { - if revision == goalRevision { - saveError = (error as? BoxAPIError)?.help ?? "Your box didn't confirm the change. Check the current settings before trying again." - } - } - saving = false - // An edit that landed while this one was on the wire goes out now. - if revision != goalRevision, goal != nil { await saveGoal() } - } - - private func removeGoal(_ lp: Loadpoint) async { - saving = true - saveError = nil - scheduleNote = "Removing goal…" - do { - try await model.removeSchedule(lp) - if justSavedGoal == lp.id { justSavedGoal = nil } - removedGoal = lp.id - goal = nil - } catch { - saveError = (error as? BoxAPIError)?.help ?? "Your box didn't confirm the change. Check the current settings before trying again." - } - saving = false - } - - // MARK: Only spare solar - - @ViewBuilder private func surplus(_ lp: Loadpoint) -> some View { - if site.canConfigure { - let overridden = lp.manualActive || lp.manualRestoreUnconfirmed - Toggle("Only spare solar", isOn: Binding( - get: { model.surplusShown(lp) }, - set: { on in - model.surplusDraft[lp.id] = on - Task { - await model.setSurplusOnly(lp, on) - model.surplusDraft[lp.id] = nil - } - } - )) - .disabled(sending || overridden) - Hint(overridden - ? (EVText.isPaused(lp) || lp.manualRestoreUnconfirmed ? "This rule resumes with the plan." : "Charge now overrides this rule. It resumes when you return to the plan.") - : model.surplusShown(lp) ? "No grid or home battery. Your target may not be reached in time." : "The plan may use grid power to reach your target.") - if isSending(.surplus, lp) { - Hint("Asking your box…") - } else { - outcome(.surplus, lp) - } - } else if lp.surplusOnly { - Hint("Charges from spare solar only.") - } - } - - // MARK: Home battery boost - - private func boostSection(_ lp: Loadpoint) -> some View { - DisclosureGroup("Home battery boost") { - VStack(alignment: .leading, spacing: 8) { - if !lp.boostActive { - if let draft = boost, draft.loadpointID == lp.id { - Text("Let the house battery charge the car for a while.").font(.callout) - Stepper("Keep \(draft.reservePct) % in the house battery", value: Binding( - get: { boost?.reservePct ?? EVText.boostReserveDefaultPct }, - set: { boost?.reservePct = $0 } - ), in: EVText.boostReserveMinPct...100, step: 5) - .disabled(sending) - HStack(spacing: 6) { - ForEach(EVText.boostDurations, id: \.seconds) { d in - let on = draft.durationS == d.seconds - Button(d.label) { boost?.durationS = d.seconds } - .font(.caption.weight(.semibold)) - .padding(.vertical, 5) - .padding(.horizontal, 9) - .foregroundStyle(on ? Theme.onAccent : Theme.fgDim) - .background(on ? Theme.accent : Theme.surfaceSunken, in: Capsule()) - .buttonStyle(.plain) - .disabled(sending) - } - } - HStack { - Button(sending ? "Asking your box…" : "Start boost") { - Task { - await model.boost(lp, reservePct: draft.reservePct, durationS: draft.durationS) - if case .applied = model.command { boost = nil } - } - } - .buttonStyle(.primary) - .disabled(sending) - Button("Cancel") { boost = nil }.buttonStyle(.quiet).disabled(sending) - } - Hint("Ends when the time is up, the house battery reaches the reserve, or you stop it.") - } else if lp.manualActive { - Hint("Available after returning to the plan.") - } else if model.surplusShown(lp) { - Hint("Not while the charger uses spare solar only.") - } else { - Button("Boost from the house battery") { - boost = BoostDraft(loadpointID: lp.id, reservePct: EVText.boostReserveDefaultPct, durationS: EVText.boostDurationDefault) - } - .buttonStyle(.outline) - } - outcome(.boost, lp) - } - } - .padding(.top, 6) - } - .font(.callout) - } - - // MARK: Charging ahead - - @ViewBuilder private func windows(_ lp: Loadpoint) -> some View { - let planReady = !lp.manualActive && !lp.planPending && !model.planPending && !lp.planOutdated && !model.planOutdated - let ahead = model.windows[lp.id] ?? [] - if planReady, !stale, !ahead.isEmpty { - ControlCard { - Kicker("Charging ahead") - ForEach(ahead) { w in - HStack { - Text("\(EVText.clock(w.fromMs))–\(EVText.clock(w.toMs))").font(Theme.number(13, weight: .regular)) - Spacer() - if let wh = w.energyWh { - Text("\((wh / 1000).formatted(.number.precision(.fractionLength(0...1)))) kWh").font(.footnote) - } else if let peak = w.peakW { - Text("up to \(PowerFormat.text(peak))").font(.footnote) - } - } - } - } - } else if planReady, model.planMissing { - Hint("Charging times aren't readable right now.") - } - } -} - -/// A group of related controls, set off like a card. -private struct ControlCard: View { - @ViewBuilder var content: Content - - var body: some View { - VStack(alignment: .leading, spacing: 10) { content } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) - } -} diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift index e26859b..0c24500 100644 --- a/appleApp/FTW/UI/FlowDiagram.swift +++ b/appleApp/FTW/UI/FlowDiagram.swift @@ -100,7 +100,7 @@ private struct Hub: View { Image(systemName: "house.fill") .font(.title2) .foregroundStyle(Theme.accent) - (Text(parts.text).font(Theme.number(22, weight: .bold)) + Text(" \(parts.unit)").font(.caption)) + Text("\(Text(parts.text).font(Theme.number(22, weight: .bold)))\(Text(" \(parts.unit)").font(.caption))") .foregroundStyle(Theme.fg) Text("HOME").font(.caption2.weight(.semibold)).tracking(0.8).foregroundStyle(Theme.fgDim) if let pct = selfPoweredPct { @@ -131,7 +131,7 @@ private struct Bubble: View { if let name = node.name { Text(name).font(.caption2).foregroundStyle(Theme.fgMuted).lineLimit(1) } - (Text(parts.text).font(Theme.number(17, weight: .bold)) + Text(" \(parts.unit)").font(.caption2)) + Text("\(Text(parts.text).font(Theme.number(17, weight: .bold)))\(Text(" \(parts.unit)").font(.caption2))") .foregroundStyle(color) if let soc = node.socPct { SocBar(pct: soc) diff --git a/appleApp/FTW/UI/GlanceView.swift b/appleApp/FTW/UI/GlanceView.swift deleted file mode 100644 index 03a3491..0000000 --- a/appleApp/FTW/UI/GlanceView.swift +++ /dev/null @@ -1,61 +0,0 @@ -#if os(macOS) -import FTWKit -import SwiftUI - -/// The Mac's glance from the menu bar: the band, the sentence and the four -/// figures, without opening a window. -struct GlanceView: View { - let app: AppModel - @Environment(\.openWindow) private var openWindow - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - if let site = app.site { - FreshnessBandView(band: site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: site.lastFrameAtMs) - .clipShape(RoundedRectangle(cornerRadius: 8)) - if !site.session.fields.isEmpty { - Text(site.explanation.headline) - .font(.headline) - .fixedSize(horizontal: false, vertical: true) - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { - figure("Grid", Contract.FID.gridW, site) { $0 > 0 ? "drawing" : "exporting" } - figure("Solar", Contract.FID.pvW, site) { _ in "producing" } - figure("Battery", Contract.FID.batteryW, site) { $0 > 0 ? "charging" : "supplying" } - figure("Home", Contract.FID.loadW, site) { _ in "using" } - } - } else { - Text("Nothing from your box yet.").foregroundStyle(Theme.fgDim) - } - } else { - Text("No home is paired on this Mac.").foregroundStyle(Theme.fgDim) - } - Divider() - HStack { - Button("Open FTW") { - NSApplication.shared.activate() - if let window = NSApplication.shared.windows.first(where: \.canBecomeMain) { - window.makeKeyAndOrderFront(nil) - } else { - openWindow(id: FTWApp.mainWindow) - } - } - Spacer() - Button("Quit") { NSApplication.shared.terminate(nil) } - } - } - .padding(14) - .frame(width: 320) - } - - private func figure(_ label: String, _ fid: Int, _ site: SiteModel, words: (Double) -> String) -> some View { - let reading = site.session.fields[fid].map { fid == Contract.FID.pvW ? abs($0) : $0 } - let value = reading.map(PowerFormat.text) ?? "—" - let word = reading.map { PowerFormat.direction($0) == .idle ? "idle" : words($0) } ?? "" - return GridRow { - Text(label).foregroundStyle(Theme.fgDim) - Text(value).font(Theme.number(14)) - Text(word).foregroundStyle(Theme.fgMuted) - } - } -} -#endif diff --git a/appleApp/FTW/UI/HistoryView.swift b/appleApp/FTW/UI/HistoryView.swift deleted file mode 100644 index 47ecdff..0000000 --- a/appleApp/FTW/UI/HistoryView.swift +++ /dev/null @@ -1,311 +0,0 @@ -import Charts -import FTWKit -import SwiftUI - -/// What the house used, made, bought and sold, then power minute by minute. -struct HistoryView: View { - let home: HomeModels - - var body: some View { - Group { - if home.site.hasPassthrough { - EnergySection(energy: home.energy) - Divider().overlay(Theme.line) - } - PowerSection(history: home.history) - } - .onAppear { - home.energy.activate() - home.history.activate() - } - .onDisappear { - home.energy.deactivate() - home.history.deactivate() - } - } -} - -// MARK: Energy, day by day - -private struct EnergySection: View { - let energy: EnergyModel - @State private var shown: Series = .load - - enum Series: CaseIterable { - case load, pv, bought, sold - - var label: String { - switch self { - case .load: return "Used at home" - case .pv: return "Made by solar" - case .bought: return "Bought" - case .sold: return "Sold" - } - } - - var note: String { - switch self { - case .load: return "everything the house drew" - case .pv: return "what the panels produced" - case .bought: return "taken from the grid" - case .sold: return "sent back to the grid" - } - } - - var color: Color { - switch self { - case .load: return Theme.fgDim - case .pv: return Theme.generation - case .bought: return Theme.importing - case .sold: return Theme.exporting - } - } - - func wh(_ t: EnergyModel.Totals) -> Double { - switch self { - case .load: return t.loadWh - case .pv: return t.pvWh - case .bought: return t.importWh - case .sold: return t.exportWh - } - } - - func wh(_ d: EnergyModel.Day) -> Double { - switch self { - case .load: return d.loadWh - case .pv: return d.pvWh - case .bought: return d.importWh - case .sold: return d.exportWh - } - } - } - - var body: some View { - let totals = energy.totals - let hasDays = !energy.days.isEmpty - HStack { - Kicker(energy.range.title) - Spacer() - Segments(options: EnergyModel.Range.allCases, selected: energy.range, label: \.label) { energy.select($0) } - } - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { - ForEach(Series.allCases, id: \.self) { series in - let parts = EnergyFormat.parts(series.wh(totals)) - Button { shown = series } label: { - VStack(alignment: .leading, spacing: 3) { - Text(series.label).font(.caption.weight(.semibold)).foregroundStyle(Theme.fgDim) - Group { - if hasDays { - Text(parts.text).font(Theme.number(22)) + Text(" \(parts.unit)").font(.caption) - } else { - Text("—").font(Theme.number(22)) - } - } - .foregroundStyle(series == .load ? Theme.fg : series.color) - Text(series.note).font(.caption2).foregroundStyle(Theme.fgMuted) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) - .overlay(RoundedRectangle(cornerRadius: Theme.radiusSmall).strokeBorder(shown == series ? series.color : Theme.line, lineWidth: shown == series ? 2 : 1)) - } - .buttonStyle(.plain) - .accessibilityAddTraits(shown == series ? .isSelected : []) - } - } - if hasDays, energy.days.count > 1 { - Text("\(shown.label), kWh per day").font(.caption).foregroundStyle(Theme.fgDim) - Chart(energy.days) { day in - BarMark(x: .value("Day", day.day), y: .value("kWh", shown.wh(day) / 1000)) - .foregroundStyle(shown.color) - } - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 6)) { value in - AxisValueLabel { - if let day = value.as(String.self) { Text(Self.dayLabel(day)).font(.caption2) } - } - } - } - .chartYAxis { - AxisMarks(position: .leading) { _ in - AxisGridLine().foregroundStyle(Theme.line) - AxisValueLabel() - } - } - .frame(height: 140) - } - note(totals: totals, hasDays: hasDays) - } - - @ViewBuilder private func note(totals: EnergyModel.Totals, hasDays: Bool) -> some View { - if let error = energy.error { - Hint(error) - } else if !hasDays { - Hint(energy.loading || !energy.loaded ? "Reading your box…" : "Nothing recorded for this period yet.") - } else { - VStack(alignment: .leading, spacing: 4) { - if let share = energy.solarSharePct { - Hint("Solar made \(share)% as much energy as the home used.") - } - if totals.batChargedWh > 0 || totals.batDischargedWh > 0 { - Hint("The battery took in \(EnergyFormat.label(totals.batChargedWh)) and gave back \(EnergyFormat.label(totals.batDischargedWh)).") - } - if energy.range != .today { - Hint("Today is still running.", tone: Theme.fgMuted) - } - } - } - } - - /// "2026-09-24" to "24 Sep", short enough for thirty bars. - static func dayLabel(_ day: String) -> String { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "yyyy-MM-dd" - guard let date = f.date(from: day) else { return day } - return date.formatted(.dateTime.day().month(.abbreviated)) - } -} - -// MARK: Power, minute by minute - -private struct PowerSection: View { - let history: HistoryModel - - private struct Trace { - let name: String - let label: String - let color: Color - } - - private let traces = [ - Trace(name: "grid_w", label: "Grid", color: Theme.importing), - Trace(name: "pv_w", label: "Solar", color: Theme.generation), - Trace(name: "battery_w", label: "Battery", color: Theme.storage), - Trace(name: "load_w", label: "House", color: Theme.fgDim), - ] - - var body: some View { - HStack { - Kicker("Power, minute by minute") - Spacer() - Segments(options: HistoryModel.Range.allCases, selected: history.range, label: \.label) { history.select($0) } - } - Group { - if let frame = history.frame, frame.points > 0 { - chart(frame) - } else { - Text(history.loaded ? "Nothing recorded for this range yet." : "Reading your box…") - .font(.footnote) - .foregroundStyle(Theme.fgDim) - .frame(maxWidth: .infinity, minHeight: 200) - .background(Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) - } - } - readout - if !history.note.isEmpty { - Hint(history.note) - } - } - - private func chart(_ frame: HistoryGeometry.Frame) -> some View { - let step = max(1, frame.points / 400) - let indices = Array(stride(from: 0, to: frame.points, by: step)) - let spanMs = Double(frame.points) * frame.stepMs - // A day reads as clock times; anything longer as dates. - let format: Date.FormatStyle = spanMs <= 36 * 3_600_000 ? .dateTime.hour().minute() : .dateTime.day().month(.abbreviated) - return Chart { - ForEach(traces, id: \.name) { trace in - ForEach(indices, id: \.self) { i in - if let v = frame.value(trace.name, at: i) { - LineMark( - x: .value("Time", Date(timeIntervalSince1970: frame.time(at: i) / 1000)), - y: .value("Watts", Double(v)), - series: .value("Series", trace.label) - ) - .foregroundStyle(trace.color) - .lineStyle(StrokeStyle(lineWidth: trace.name == "load_w" ? 1 : 1.5)) - } - } - } - RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) - if let at = history.cursorAtMs { - RuleMark(x: .value("Cursor", Date(timeIntervalSince1970: at / 1000))) - .foregroundStyle(Theme.fgMuted) - } - } - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisGridLine().foregroundStyle(Theme.line) - AxisValueLabel { - if let w = value.as(Double.self) { - Text(w == 0 ? "0" : "\(PowerFormat.scale(w))\(w > 0 ? " in" : " out")").font(Theme.number(9, weight: .regular)) - } - } - } - } - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 3)) { _ in - AxisGridLine().foregroundStyle(Theme.line) - AxisValueLabel(format: format) - } - } - .chartOverlay { proxy in - GeometryReader { geo in - Rectangle().fill(.clear).contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { drag in - guard let plot = proxy.plotFrame else { return } - let x = drag.location.x - geo[plot].origin.x - guard let date: Date = proxy.value(atX: x) else { return } - let index = Int(((date.timeIntervalSince1970 * 1000 - frame.startMs) / frame.stepMs).rounded()) - history.cursor = min(frame.points - 1, max(0, index)) - } - .onEnded { _ in history.cursor = nil } - ) - } - } - .frame(height: 220) - } - - private var readout: some View { - VStack(alignment: .leading, spacing: 8) { - Text(history.cursorAtMs.map(Clock.dayAndTime) ?? "Latest") - .font(.caption.weight(.semibold)) - .foregroundStyle(Theme.fgDim) - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { - ForEach(traces, id: \.name) { trace in - let watts = history.value(trace.name) - HStack(alignment: .top, spacing: 8) { - RoundedRectangle(cornerRadius: 2).fill(trace.color).frame(width: 4, height: 30) - VStack(alignment: .leading, spacing: 1) { - Text(trace.label).font(.caption2).foregroundStyle(Theme.fgDim) - if let watts { - let parts = PowerFormat.parts(watts) - Text(parts.text).font(Theme.number(15)) + Text(" \(parts.unit)").font(.caption2) - } else { - Text("—").font(Theme.number(15)) - } - Text(words(trace.name, watts)).font(.caption2).foregroundStyle(Theme.fgMuted) - } - } - .accessibilityElement(children: .combine) - } - } - } - } - - /// Never a minus sign; a direction word instead. - private func words(_ name: String, _ watts: Double?) -> String { - guard let watts else { return "no reading" } - if name == "load_w" { return "used" } - let direction = PowerFormat.direction(watts) - if direction == .idle { return "idle" } - switch name { - case "pv_w": return "generated" - case "battery_w": return direction == .into ? "charged" : "supplied" - default: return direction == .into ? "drawn" : "exported" - } - } -} diff --git a/appleApp/FTW/UI/LiveSheet.swift b/appleApp/FTW/UI/LiveSheet.swift deleted file mode 100644 index e79ceba..0000000 --- a/appleApp/FTW/UI/LiveSheet.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Charts -import FTWKit -import SwiftUI - -/// Which bubble was tapped. Everything else follows from it. -enum LiveRole: String, Identifiable { - case grid, pv, battery, load - var id: String { rawValue } -} - -/// One part of the house over the last two minutes. The line moves on news -/// and freezes on silence: a repeated cache value moves nothing. -struct LiveSheet: View { - let site: SiteModel - let role: LiveRole - let fields: [Int: Double] - @Environment(\.dismiss) private var dismiss - - private struct Spec { - let title: String - let fid: Int - let signed: Bool - let words: (Double) -> String - } - - private var spec: Spec { - switch role { - case .grid: return Spec(title: "Grid", fid: Contract.FID.gridW, signed: true) { abs($0) < 20 ? "balanced" : $0 > 0 ? "drawing from the grid" : "exporting to the grid" } - case .pv: return Spec(title: "Solar", fid: Contract.FID.pvW, signed: false) { abs($0) < 20 ? "not producing" : "producing" } - case .battery: return Spec(title: "Battery", fid: Contract.FID.batteryW, signed: true) { abs($0) < 20 ? "resting" : $0 > 0 ? "charging" : "discharging" } - case .load: return Spec(title: "Home", fid: Contract.FID.loadW, signed: false) { _ in "used by the house" } - } - } - - var body: some View { - let spec = spec - let raw = fields[spec.fid] - let live = site.isLive - let color = Theme.color(Flow.tone(Flow.Role(rawValue: role.rawValue) ?? .load, raw)) - // Reading the stream's clock ties this view to every new frame. - let _ = site.session.uptimeMs - let points = site.recentField(spec.fid).map { (t: $0.t, v: spec.signed ? $0.v : abs($0.v)) } - NavigationStack { - VStack(alignment: .leading, spacing: 12) { - if let raw { - let parts = PowerFormat.parts(spec.signed ? raw : abs(raw)) - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(parts.text).font(Theme.number(44, weight: .bold)).foregroundStyle(color) - Text(parts.unit).font(.title3).foregroundStyle(color) - if role == .battery, let soc = fields[Contract.FID.batterySoc] { - Text("\(PowerFormat.soc(soc))%").font(Theme.number(20)).foregroundStyle(Theme.fgDim) - } - } - Text(spec.words(spec.signed ? raw : abs(raw)) + (live ? "" : " · last known")) - .foregroundStyle(Theme.fgDim) - chart(points, color: color) - .frame(height: 200) - Hint("last two minutes", tone: Theme.fgMuted) - } else { - Hint("No reading from your box yet.") - } - Spacer() - } - .padding(20) - .navigationTitle(spec.title) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Close") { dismiss() } - } - } - } - .presentationDetents([.medium, .large]) - } - - private func chart(_ points: [(t: Double, v: Double)], color: Color) -> some View { - let end = site.nowMs - return Chart { - ForEach(points, id: \.t) { p in - LineMark(x: .value("Time", Date(timeIntervalSince1970: p.t / 1000)), y: .value("Watts", p.v)) - .foregroundStyle(color) - .interpolationMethod(.monotone) - } - if spec.signed { - RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) - } - } - .chartXScale(domain: Date(timeIntervalSince1970: (end - 120_000) / 1000)...Date(timeIntervalSince1970: end / 1000)) - .chartXAxis(.hidden) - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisGridLine().foregroundStyle(Theme.line) - AxisValueLabel { - if let w = value.as(Double.self) { Text(PowerFormat.scale(w)).font(Theme.number(10, weight: .regular)) } - } - } - } - } -} diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 1305028..78c2797 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -2,13 +2,10 @@ import FTWKit import SwiftUI /// Now, the first screen. A glance: one sentence at the top, the house -/// under it, then price, what happens next, today and the fuse. +/// under it, then price, today and the fuse. struct NowView: View { let app: AppModel let home: HomeModels - let openCharger: (String?) -> Void - let open: (HomeTab) -> Void - @State private var liveRole: LiveRole? private var site: SiteModel { home.site } @@ -38,18 +35,13 @@ struct NowView: View { .onAppear { home.status.activate() home.prices.activate() - home.plan.activate() home.savings.activate() } .onDisappear { home.status.deactivate() home.prices.deactivate() - home.plan.deactivate() home.savings.deactivate() } - .sheet(item: $liveRole) { role in - LiveSheet(site: site, role: role, fields: flowFields) - } } /// Paired, and not one reading has ever arrived. A hollow house would @@ -102,19 +94,11 @@ struct NowView: View { .accessibilityAddTraits(.isHeader) // Motion claims power is flowing right now, so a cached view holds still. - FlowDiagram(readings: readings, moving: live) { role in - switch role { - case .ev: openCharger(nil) - case .grid: if live { liveRole = .grid } - case .pv: if live { liveRole = .pv } - case .battery: if live { liveRole = .battery } - case .load: if live { liveRole = .load } - } - } + FlowDiagram(readings: readings, moving: live) { _ in } if home.status.status != nil, !statusLive { Hint("Device details are out of date.\(live ? " Showing live totals." : "")") } - NowOutlook(site: site, home: home, statusLive: statusLive, open: open) + NowOutlook(site: site, home: home, statusLive: statusLive) } static func bootWords(_ phase: String) -> String { @@ -127,12 +111,11 @@ struct NowView: View { } } -/// Price, what FTW does next, today and the fuse: the rest of a glance. +/// Price, today and the fuse: the rest of a glance. private struct NowOutlook: View { let site: SiteModel let home: HomeModels let statusLive: Bool - let open: (HomeTab) -> Void var body: some View { if let prices = home.prices.prices { @@ -145,7 +128,6 @@ private struct NowOutlook: View { } } } - planCard if let today = today { todayCard(today) } @@ -154,30 +136,6 @@ private struct NowOutlook: View { } } - private var planCard: some View { - let brief = PlanText.brief(home.plan.plan, nowMs: site.nowMs, mode: home.plan.actualMode, dispatchBlockedBy: site.session.dispatchBlockedBy, clock: Clock.time) - return Card { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 2) { - Kicker("Automation") - Text("What FTW does next").font(.headline) - } - Spacer() - Text(brief.stateLabel) - .font(.caption.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .foregroundStyle(brief.tone == .active ? Theme.exporting : brief.tone == .warn ? Theme.generation : Theme.fgDim) - .background(Theme.surfaceSunken, in: Capsule()) - } - Text(brief.action).font(.body.weight(.semibold)) - if let time = brief.time { Hint(time) } - if let reason = brief.reason { Hint("\(reason).") } - Hint(brief.constraint, tone: Theme.fgMuted) - Button("Open full plan →") { open(.plan) }.buttonStyle(.quiet) - } - } - private struct Today { let importWh: Double let exportWh: Double @@ -216,7 +174,6 @@ private struct NowOutlook: View { } } } - Button("Open history →") { open(.history) }.buttonStyle(.quiet) } } @@ -247,7 +204,7 @@ struct EnergyTile: View { let parts = EnergyFormat.parts(wh) VStack(alignment: .leading, spacing: 2) { Text(label).font(.caption).foregroundStyle(Theme.fgDim) - (Text(parts.text).font(Theme.number(20)) + Text(" \(parts.unit)").font(.caption)) + Text("\(Text(parts.text).font(Theme.number(20)))\(Text(" \(parts.unit)").font(.caption))") .foregroundStyle(color) } } diff --git a/appleApp/FTW/UI/PairView.swift b/appleApp/FTW/UI/PairView.swift index b326090..149d621 100644 --- a/appleApp/FTW/UI/PairView.swift +++ b/appleApp/FTW/UI/PairView.swift @@ -1,13 +1,11 @@ import FTWKit import SwiftUI -import UniformTypeIdentifiers /// Pairing, the first thing anyone sees. Scan, then Face ID. Everything that /// can fail does so before the passkey prompt. struct PairView: View { let app: AppModel @State private var model: PairModel - @State private var importing = false init(app: AppModel) { self.app = app @@ -24,9 +22,6 @@ struct PairView: View { .frame(maxWidth: .infinity) } .background(Theme.surface) - .fileImporter(isPresented: $importing, allowedContentTypes: [.image]) { result in - read(result) - } } /// A screen reached from a home says what this phone cannot do. @@ -52,22 +47,6 @@ struct PairView: View { Title("Starting the demo") Text("Loading a simulated home.") - case .recovering: - Title("Checking") - Text("Asking your passkey what it can open.") - Button("Cancel") { model.cancel() }.buttonStyle(.quiet) - - case .choosing: - Title(model.recovered.count == 1 ? "Your home is here" : "Pick a home to open") - Text("Your passkey opened a sealed copy. Nothing was sent to your box.") - ForEach(model.recovered) { home in - Button { model.adopt(home) } label: { - Text("Open \(home.label) ") + Text(home.fingerprint).font(Theme.number(15)) - } - .buttonStyle(.primary) - } - Button("Not now") { model.cancel() }.buttonStyle(.quiet) - case .intro: if let fingerprint = model.offeredFingerprint { offer(fingerprint) @@ -84,9 +63,9 @@ struct PairView: View { Title(known != nil ? "Connect to a different box?" : "Connect this box?") Group { if let known { - Text("This link points at box ") + Text(fingerprint).font(Theme.number(16)) + Text(". Connecting it replaces \(known.label) as the home this app shows and controls. Your key for \(known.label) stays on this phone.") + Text("This link points at box \(Text(fingerprint).font(Theme.number(16))). Connecting it replaces \(known.label) as the home this app shows and controls. Your key for \(known.label) stays on this phone.") } else { - Text("This link points at box ") + Text(fingerprint).font(Theme.number(16)) + Text(". Only continue if you just opened Settings → FTW app and chose Show pairing code in this box's local dashboard.") + Text("This link points at box \(Text(fingerprint).font(Theme.number(16))). Only continue if you just opened Settings → FTW app and chose Show pairing code in this box's local dashboard.") } } if let message = model.message { Problem(message) } @@ -104,11 +83,11 @@ struct PairView: View { } else if model.canOpen { Text("Your key is still on this phone. Nothing to set up again.") } else { - Text("Connect your own box, open a home saved with your passkey, or try a live simulated home first.") + Text(app.canDemo ? "Connect your own box, or try a live simulated home first." : "Connect your own box.") } if let message = model.message { Problem(message) } - if problem == nil, !canDismiss, !model.canOpen { + if problem == nil, !canDismiss, !model.canOpen, app.canDemo { demoOffer } @@ -118,14 +97,6 @@ struct PairView: View { Hint("Use a new QR from Settings → FTW app if this key no longer works.") } else { setup - Button { Task { await model.recover() } } label: { - VStack(alignment: .leading, spacing: 4) { - Text("Open with your passkey").font(.body.weight(.semibold)).foregroundStyle(Theme.fg) - Text("Used FTW before? Ask Face ID or Touch ID for a saved home.").font(.footnote).foregroundStyle(Theme.fgDim) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.outline) } if canDismiss { @@ -137,7 +108,7 @@ struct PairView: View { Card { Kicker("Interactive demo") Text("See a home running").font(.title3.weight(.semibold)) - Text("Explore live solar, battery, grid, EV charging, plans and history. The data is simulated and nothing is saved.") + Text("Explore live solar, battery, grid, prices and EV charging. The data is simulated and nothing is saved.") .foregroundStyle(Theme.fgDim) Button("Try the live demo") { model.tryDemo() }.buttonStyle(.primary) } @@ -153,12 +124,8 @@ struct PairView: View { Text("3. Choose Show pairing code, then scan the QR here.") } .font(.callout) - Hint("Next, a supported device asks for Face ID or Touch ID to protect your FTW key. There is no FTW account or password. If that passkey supports recovery and the save reaches Sourceful, FTW keeps a sealed recovery copy that Sourceful cannot open. If not, pairing still works; a new Settings QR is the way back.") + Hint("Next, your phone asks for Face ID or Touch ID to protect your FTW key. There is no FTW account or password. If this phone loses its key, a new Settings QR is the way back.") Button("Scan the pairing QR") { model.stage = .scanning }.buttonStyle(.primary) - #if os(macOS) - Button("Read the QR from a picture") { importing = true }.buttonStyle(.quiet) - Hint("On a Mac, a screenshot of the code works as well as the camera.") - #endif DisclosureGroup("Can't see Show pairing code?") { Hint("Turn on Let the FTW app connect to this box, save, and restart the box first.") .frame(maxWidth: .infinity, alignment: .leading) @@ -166,17 +133,6 @@ struct PairView: View { .font(.footnote) } } - - private func read(_ result: Result) { - guard case .success(let url) = result else { return } - let scoped = url.startAccessingSecurityScopedResource() - defer { if scoped { url.stopAccessingSecurityScopedResource() } } - if let code = QRImage.pairingCode(at: url) { - Task { await model.pair(code) } - } else { - model.noCodeFound() - } - } } struct Title: View { diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift deleted file mode 100644 index db05cf9..0000000 --- a/appleApp/FTW/UI/PlanView.swift +++ /dev/null @@ -1,204 +0,0 @@ -import FTWKit -import SwiftUI - -/// What the box means to do, how the home is run, and what power costs. -struct PlanView: View { - let home: HomeModels - @State private var showAdvanced = false - - private var plan: PlanModel { home.plan } - private var site: SiteModel { home.site } - - var body: some View { - Group { - Text(PlanText.headline(plan.plan, nowMs: site.nowMs).text) - .font(.title3.weight(.semibold)) - .fixedSize(horizontal: false, vertical: true) - if let problem = plan.problem { Problem(problem) } - - modes - - if let prices = home.prices.prices { - Card { - PriceChart(prices: prices, nowMs: site.nowMs) - if home.prices.hasHole { - Hint("Some hours are missing their price.") - } else if prices.stale { - Hint("Tomorrow's rates aren't published yet.") - } - } - } - - timeline - } - .onAppear { - plan.activate() - home.prices.activate() - } - .onDisappear { - plan.deactivate() - home.prices.deactivate() - } - } - - // MARK: How the home is run - - @ViewBuilder private var modes: some View { - Kicker("How your home is run") - if plan.inManual, let planHome = plan.planHome { - Card { - Text("The plan is not running the battery.") - if plan.canControl { - Button(sendingMode == planHome.key ? "Sending…" : "Use the plan") { choose(planHome.key) } - .buttonStyle(.primary) - .disabled(isSending) - } - } - } - ForEach(plan.primaryModes) { choice($0) } - if !plan.advancedModes.isEmpty { - if showAdvanced { - ForEach(plan.advancedModes) { choice($0) } - Button("Fewer options") { showAdvanced = false }.buttonStyle(.quiet) - } else { - if let selected = plan.advancedModes.first(where: { $0.key == plan.shownMode }) { - choice(selected) - } - Button("More ways to run it") { showAdvanced = true }.buttonStyle(.quiet) - } - } - status - } - - private var isSending: Bool { - if case .sending = plan.command { return true } - return false - } - - private var sendingMode: String? { - if case .sending(let m) = plan.command { return m } - return nil - } - - private func choose(_ mode: String) { - Task { await plan.setMode(mode) } - if plan.advancedModes.contains(where: { $0.key == mode }) { showAdvanced = false } - } - - private func choice(_ info: ModeInfo) -> some View { - let pressed = plan.shownMode == info.key - return Button { choose(info.key) } label: { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(PlanText.label(info)).font(.body.weight(.semibold)).foregroundStyle(Theme.fg) - Spacer() - if sendingMode == info.key { - Text("Sending…").font(.caption).foregroundStyle(Theme.fgDim) - } else if pressed { - Text("In use").font(.caption.weight(.semibold)).foregroundStyle(Theme.accent) - } - } - Text(PlanText.help(info)) - .font(.footnote) - .foregroundStyle(Theme.fgDim) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(pressed ? Theme.accent : Theme.line, lineWidth: pressed ? 2 : 1)) - } - .buttonStyle(.plain) - .disabled(!plan.canControl || isSending) - .accessibilityAddTraits(pressed ? .isSelected : []) - } - - /// One line, in the band's voice. Never a modal. - @ViewBuilder private var status: some View { - switch plan.command { - case .sending: - Hint("Sending…") - case .applied: - Hint("Done.", tone: Theme.exporting) - case .unconfirmed: - Hint("Your box took it, but hasn't confirmed yet. It'll show here when it does.", tone: Theme.generation) - case .failed(let help): - Hint(help, tone: Theme.generation) - case .idle: - switch plan.whyNoControl { - case .role: Hint("You have view-only access, so this is the owner's to change.") - case .box: Hint("This box doesn't support changing how it runs.") - case nil: EmptyView() - } - } - } - - // MARK: The next twelve hours - - @ViewBuilder private var timeline: some View { - let now = site.nowMs - let slots = Array((plan.plan?.slots ?? []).filter { $0.startMs + $0.durationMs > now }.prefix(48)) - if !slots.isEmpty { - let peak = max(1, slots.map { abs($0.batteryW) }.max() ?? 1) - let currency = home.prices.currency - HStack { - Kicker("Next 12 hours") - Spacer() - Text("to import, \(PriceUnits.unit(currency).perKwh)").font(.caption).foregroundStyle(Theme.fgMuted) - } - VStack(spacing: 0) { - ForEach(slots) { slot in - SlotRow(slot: slot, peakW: peak, isNow: now >= slot.startMs && now < slot.startMs + slot.durationMs, currency: currency) - } - } - } else if plan.loading { - Hint("Asking your box…") - } - } -} - -private struct SlotRow: View { - let slot: PlanSlot - let peakW: Double - let isNow: Bool - let currency: String - - var body: some View { - let action = PlanText.action(slot) - let parts = PowerFormat.parts(slot.batteryW) - HStack(spacing: 10) { - Text(Clock.time(slot.startMs)) - .font(Theme.number(13, weight: isNow ? .bold : .regular)) - .frame(width: 52, alignment: .leading) - GeometryReader { geo in - Capsule() - .fill(action == .charge ? Theme.storage : action == .discharge ? Theme.accent : Theme.fgMuted) - .frame(width: max(2, geo.size.width * abs(slot.batteryW) / peakW)) - .frame(maxHeight: .infinity) - } - .frame(width: 60, height: 6) - Group { - if action == .idle { - Text("resting").foregroundStyle(Theme.fgDim) - } else { - Text(parts.text).font(Theme.number(13)) + Text(" \(parts.unit) \(action == .charge ? "in" : "out")").foregroundStyle(Theme.fgDim) - } - } - .font(.footnote) - .frame(width: 90, alignment: .leading) - Text(PlanText.reason(slot.reason)) - .font(.footnote) - .foregroundStyle(Theme.fgDim) - .lineLimit(1) - Spacer(minLength: 4) - if let price = PriceUnits.text(slot.priceMinor, currency) { - Text(price).font(Theme.number(12, weight: .regular)).foregroundStyle(Theme.fgDim) - } - } - .padding(.vertical, 7) - .padding(.horizontal, 8) - .background(isNow ? Theme.surfaceRaised : Color.clear, in: RoundedRectangle(cornerRadius: 8)) - .accessibilityElement(children: .combine) - } -} diff --git a/appleApp/FTW/UI/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift index 1c69a01..f7139f7 100644 --- a/appleApp/FTW/UI/PriceChart.swift +++ b/appleApp/FTW/UI/PriceChart.swift @@ -21,7 +21,7 @@ struct PriceChart: View { } HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { - (Text(PriceStrip.text(summary.current?.totalMinor, prices.currency)).font(Theme.number(28, weight: .bold)) + Text(" \(unit.perKwh)").font(.caption)) + Text("\(Text(PriceStrip.text(summary.current?.totalMinor, prices.currency)).font(Theme.number(28, weight: .bold)))\(Text(" \(unit.perKwh)").font(.caption))") Text("\(prices.zone.isEmpty ? "—" : prices.zone) · incl. fees and VAT").font(.caption).foregroundStyle(Theme.fgMuted) } Spacer() diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index bfda663..a9e017a 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -1,8 +1,8 @@ import FTWKit import SwiftUI -/// The shell: pairing when there is nothing to show, else one home in four -/// screens. Paints from what is on the phone and never waits on the network. +/// The shell: pairing when there is nothing to show, else one home. Paints +/// from what is on the phone and never waits on the network. struct RootView: View { let app: AppModel @@ -22,9 +22,9 @@ struct RootView: View { } enum HomeTab: String, Hashable { - case now, plan, history, box + case now - /// Where a home opens. Debug builds take `-FTWTab plan` and the like, so + /// Where a home opens. Debug builds take `-FTWTab now` and the like, so /// CI can photograph every screen of the demo. static var initial: HomeTab { #if DEBUG @@ -35,17 +35,10 @@ enum HomeTab: String, Hashable { } } -/// Which charger's sheet is open. Nil id means every charger. -struct ChargerRequest: Identifiable, Equatable { - let loadpointID: String? - var id: String { loadpointID ?? "*" } -} - struct HomeView: View { let app: AppModel let home: HomeModels @State private var tab: HomeTab = .initial - @State private var charger: ChargerRequest? var body: some View { VStack(spacing: 0) { @@ -54,36 +47,15 @@ struct HomeView: View { } else { FreshnessBandView(band: home.site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: home.site.lastFrameAtMs) } - ChargingNotice(site: home.site, charging: home.charging) { id in - tab = .now - charger = ChargerRequest(loadpointID: id) - } + ChargingNotice(site: home.site, charging: home.charging) + // The car view is the next tab here. TabView(selection: $tab) { Tab("Now", systemImage: "house", value: HomeTab.now) { Screen(app: app) { - NowView(app: app, home: home, openCharger: { charger = ChargerRequest(loadpointID: $0) }, open: { tab = $0 }) - } - } - Tab("Plan", systemImage: "calendar", value: HomeTab.plan) { - Screen(app: app) { PlanView(home: home) } - } - Tab("History", systemImage: "chart.xyaxis.line", value: HomeTab.history) { - Screen(app: app) { HistoryView(home: home) } - } - Tab("Box", systemImage: "shippingbox", value: HomeTab.box) { - Screen(app: app) { - if app.isDemo { - DemoBoxView(site: home.site) { app.exitDemo() } - } else { - BoxView(app: app, home: home) - } + NowView(app: app, home: home) } } } - .tabViewStyle(.sidebarAdaptable) - } - .sheet(item: $charger) { request in - EVSheet(site: home.site, model: home.loadpoints, loadpointID: request.loadpointID) } .onAppear { home.charging.activate() } .onDisappear { home.charging.deactivate() } @@ -151,6 +123,7 @@ struct FreshnessBandView: View { case .live: return Theme.freshLive case .stale: return Theme.freshStale case .reaching, .lost: return Theme.freshLost + case .busy: return Theme.freshStale } } } @@ -179,12 +152,11 @@ struct DemoBand: View { } } -/// A car on the cable, said once above every screen, with a way into its -/// sheet. Only what the box said, and dated when it is out of date. +/// A car on the cable, said once above every screen. Only what the box +/// said, and dated when it is out of date. struct ChargingNotice: View { let site: SiteModel let charging: ChargingWatch - let open: (String) -> Void var body: some View { let fresh = charging.fresh && site.session.phase == .streaming @@ -198,9 +170,6 @@ struct ChargingNotice: View { Hint(plan) } if lp.manualSaveError { Hint(EVText.manualSaveErrorText) } - Button("Check charging\(lp.socSource != "vehicle" ? " and battery level" : "")") { open(lp.id) } - .buttonStyle(.quiet) - .font(.footnote.weight(.semibold)) } .padding(.horizontal, 16) .padding(.vertical, 10) diff --git a/appleApp/FTW/UI/Theme.swift b/appleApp/FTW/UI/Theme.swift index fea21ef..258a9b0 100644 --- a/appleApp/FTW/UI/Theme.swift +++ b/appleApp/FTW/UI/Theme.swift @@ -48,29 +48,15 @@ enum Theme { } private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { - #if os(iOS) return Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) - #else - return Color(NSColor(name: nil) { appearance in - appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? NSColor(rgb: dark) : NSColor(rgb: light) - }) - #endif } } -#if os(iOS) private extension UIColor { convenience init(rgb: UInt32) { self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) } } -#else -private extension NSColor { - convenience init(rgb: UInt32) { - self.init(srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) - } -} -#endif /// A raised card, the web app's `.card`. struct Card: View { diff --git a/appleApp/FTWKit/Package.resolved b/appleApp/FTWKit/Package.resolved deleted file mode 100644 index ed2adca..0000000 --- a/appleApp/FTWKit/Package.resolved +++ /dev/null @@ -1,24 +0,0 @@ -{ - "originHash" : "887bf9d08cf6d2c6849bb790dc2fb6ad11cc2f85e25edea3cd00de3a4912a469", - "pins" : [ - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", - "version" : "1.7.3" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "da9d28d69ebe3894b18376c8f2395c2f37b8448f", - "version" : "4.5.2" - } - } - ], - "version" : 3 -} diff --git a/appleApp/FTWKit/Package.swift b/appleApp/FTWKit/Package.swift index f28fe2d..4ac35c7 100644 --- a/appleApp/FTWKit/Package.swift +++ b/appleApp/FTWKit/Package.swift @@ -1,38 +1,38 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.2 // -// FTWKit: everything the native app does that is not a pixel. Pairing, the -// passkey derivations, Noise IK, frames, the relay, the session and the -// state each screen reads. The SwiftUI app in ../FTW is a thin layer over it. +// Everything the iPhone app does that is not a pixel, in three modules: // -// It builds and tests on Linux as well as on Apple platforms, so the -// protocol can be checked anywhere Swift runs. CryptoKit provides the -// primitives on Apple platforms; swift-crypto provides the same API on -// Linux and is linked nowhere else. +// - FTWKit: pairing, the passkey derivations, Noise IK, frames, the relay, +// the session and the state each screen reads. The app alone links it. +// - FTWShared: plain `Sendable` values the app shares with its widgets and, +// later, a notification extension. The App Group cache, its records and +// the formatters a widget needs. Nothing on the main actor, no keys. +// - FTWDemo: a simulated box in this process, for the demo, App Review and +// previews. Kept out of FTWKit so the product module holds no fake box. +// +// CryptoKit provides the primitives. macOS is listed only so `swift test` +// runs on a Mac; the app itself is iPhone only. import PackageDescription let package = Package( name: "FTWKit", - platforms: [.iOS(.v18), .macOS(.v15)], + defaultLocalization: "en", + platforms: [.iOS(.v26), .macOS(.v26)], products: [ .library(name: "FTWKit", targets: ["FTWKit"]), - ], - dependencies: [ - // Below 5, whose manifest needs Swift 6.2: Xcode 16 must still - // resolve this package even though it links it nowhere. - .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0"..<"5.0.0"), + .library(name: "FTWShared", targets: ["FTWShared"]), + .library(name: "FTWDemo", targets: ["FTWDemo"]), ], targets: [ - .target( - name: "FTWKit", - dependencies: [ - .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])), - ] - ), + .target(name: "FTWShared", resources: [.process("Resources")]), + .target(name: "FTWKit", dependencies: ["FTWShared"]), + .target(name: "FTWDemo", dependencies: ["FTWKit"]), .testTarget( name: "FTWKitTests", - dependencies: ["FTWKit"], + dependencies: ["FTWKit", "FTWDemo"], resources: [.copy("Resources")] ), + .testTarget(name: "FTWSharedTests", dependencies: ["FTWShared"]), ] ) diff --git a/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift similarity index 75% rename from appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift rename to appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 69506d1..1b3ecb5 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -1,4 +1,5 @@ import Foundation +import FTWKit /// A box that lives in this process: the other end of the protocol, for the /// demo and for the tests. It speaks frames exactly as a box does, holds a @@ -21,7 +22,6 @@ public final class SimulatedBox { private var seq: UInt64 = 0 private var controlRev: UInt64 = 1 private var lastSent: [Int: Double] = [:] - private var planRev: UInt64 = 1 public var booting = false /// Silence instead of answers, to test the app's deadlines. public var mute = false @@ -30,16 +30,7 @@ public final class SimulatedBox { public private(set) var receivedCommands: [String] = [] public private(set) var stepUpsSeen = 0 - // Box-side records the Box screen reads. - var devices: [(id: String, role: String, addedAtMs: Double, lastSeenMs: Double)] = [] - var notifyEnabled = false - var notifyRules: [(type: String, enabled: Bool)] = [ - ("charging.connected", false), ("charging.session_complete", false), ("charging.interrupted", false), - ("update.installed", false), ("driver.offline", false), ("fuse.over_limit", false), - ] - var sentPushes: [(title: String, atMs: Double)] = [] - - public static let modes: [ModeInfo] = [ + public nonisolated static let modes: [ModeInfo] = [ ModeInfo(key: "planner_passive_arbitrage", label: "Passive arbitrage", tooltip: "Charge from the cheapest available source (PV when sunny, grid during cheap hours). Never exports from battery.", tier: "primary"), ModeInfo(key: "planner_arbitrage", label: "Active arbitrage", tooltip: "Full price arbitrage — charge cheap, discharge into expensive hours (battery may export to grid).", tier: "primary"), ModeInfo(key: "idle", label: "Stop batteries", tooltip: "Hold every battery at 0 W for as long as this mode is on.", tier: "advanced"), @@ -49,13 +40,19 @@ public final class SimulatedBox { ModeInfo(key: "planner_self", label: "Planner (self)", tooltip: "Forecast-driven self-consumption.", tier: "hidden"), ] + /// What a box with a meter, an inverter, a battery and a charger + /// offers. Registry names only; `ContractTests` checks. + public nonisolated static let caps = [ + "status.core", "status.drivers", "history.5m", "history.1h", "history.etag", "cmd.lease", + "cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough", + ] + public init(scheduler: Scheduler, house: SimulatedHouse = SimulatedHouse(), requireStepUp: Bool = false, uptimeAtStartMs: Double = 3_600_000) { self.scheduler = scheduler self.house = house self.requireStepUp = requireStepUp bootedAtMs = scheduler.nowMs - uptimeAtStartMs house.step(nowMs: scheduler.nowMs, dtMs: 1_000) - devices = [("Qm94T3du", Contract.roleOwner, scheduler.nowMs - 40 * 86_400_000, scheduler.nowMs - 3 * 3_600_000)] } var uptimeMs: Double { scheduler.nowMs - bootedAtMs } @@ -71,11 +68,6 @@ public final class SimulatedBox { send = nil } - /// Register a phone, as a pairing on the box would. - public func enroll(deviceID: String, role: String) { - devices.insert((deviceID, role, scheduler.nowMs, scheduler.nowMs), at: 0) - } - // MARK: Frames in public func receive(_ bytes: Bytes) { @@ -90,15 +82,11 @@ public final class SimulatedBox { subscription = Subscription(bucket: body["bucket"]?.int ?? 512, hz: body["hz"]?.double ?? 1) if first { sendSnap() } restartTelemetry() - case "plan.get": sendBulk(Envelope(t: "plan", id: env.id, b: planCBOR())) case "price.get": // The box decodes times into int64 and drops a body with a // fraction in one, answering nothing. So does this. guard let from = Self.wholeMs(body["fromMs"]), let to = Self.wholeMs(body["toMs"]) else { return } sendBulk(Envelope(t: "price", id: env.id, b: pricesCBOR(from: from, to: to))) - case "hist.query": - guard Self.wholeMs(body["fromMs"]) != nil, Self.wholeMs(body["toMs"]) != nil else { return } - onHistory(env.id, body) case "api.req": onAPI(env.id, body) case "cmd": onCommand(body) default: break @@ -121,7 +109,7 @@ public final class SimulatedBox { ("mode", .text(booting ? "booting" : "full")), ("box", .map([("id", .text("demo-box")), ("build", .text("0.131.0-demo")), ("tz", .text(house.timeZone.identifier))])), ("clock", .map([("source", .text("ntp")), ("syncedAtMs", .number(scheduler.nowMs - 60_000)), ("uptimeMs", .number(uptimeMs))])), - ("caps", .array(["status.core", "status.drivers", "history.5m", "history.1h", "history.etag", "cmd.lease", "cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough"].map { .text($0) })), + ("caps", .array(Self.caps.map { .text($0) })), ("capsHash", .text("demo")), ("modes", .array(Self.modes.map { .map([("key", .text($0.key)), ("label", .text($0.label)), ("tooltip", .text($0.tooltip)), ("tier", .text($0.tier))]) })), ("role", .text(Contract.roleOwner)), @@ -223,46 +211,7 @@ public final class SimulatedBox { } } - // MARK: Plan and prices - - func planSlots() -> [PlanSlot] { - let slotMs: Double = 900_000 - let start = (scheduler.nowMs / slotMs).rounded(.down) * slotMs - return (0..<96).map { i in - let t = start + Double(i) * slotMs - let price = house.priceAt(t) - let pv = house.pvAt(t) - let load = house.baseLoadAt(t) - var battery = 0.0 - var reason = "idle" - if -pv > load + 300 { - battery = min(house.batteryMaxW, -pv - load).rounded(); reason = "solar_surplus" - } else if price < 45 { - battery = house.modeKey == "planner_self" ? 0 : 3_000; reason = battery > 0 ? "cheap_import" : "idle" - } else if price > 110 { - battery = -min(house.batteryMaxW, load).rounded(); reason = "expensive_import" - } else if price > 80 { - reason = "reserve_held" - } - return PlanSlot(startMs: t, durationMs: slotMs, batteryW: battery, gridW: load + battery + pv, priceMinor: price, reason: reason) - } - } - - func planCBOR() -> CBOR { - .map([ - ("rev", .unsigned(planRev)), - ("uptimeMs", .number(uptimeMs)), - ("slots", .array(planSlots().map { s in - .map([ - ("startMs", .number(s.startMs)), ("durationMs", .number(s.durationMs)), - ("batteryW", .number(s.batteryW)), ("gridW", .number(s.gridW)), - ("priceMinor", s.priceMinor.map { CBOR.number($0) } ?? .null), ("reason", .text(s.reason)), - ]) - })), - ("stale", .bool(false)), - ("ceilingW", .int(11_000)), - ]) - } + // MARK: Prices func pricesCBOR(from: Double, to: Double) -> CBOR { let slotMs: Double = 3_600_000 @@ -280,49 +229,6 @@ public final class SimulatedBox { return .map([("zone", .text("SE3")), ("currency", .text("SEK")), ("slots", .array(slots)), ("stale", .bool(end < to))]) } - // MARK: History - - private func onHistory(_ id: UInt32?, _ body: CBOR) { - let series = body["series"]?.stringArray ?? [] - let res = body["res"]?.string.flatMap(Resolution.init(rawValue:)) ?? .fiveMinutes - let from = body["fromMs"]?.double ?? scheduler.nowMs - 86_400_000 - let to = body["toMs"]?.double ?? scheduler.nowMs - let maxPoints = body["maxPoints"]?.int ?? 2000 - var have = [String: String]() - for h in body["have"]?.array ?? [] { - if let t = h["tileId"]?.string, let e = h["etag"]?.string { have[t] = e } - } - let plan = HistoryGeometry.plan(res, fromMs: from, toMs: to, maxPoints: maxPoints) - for tile in plan.tiles { - let partial = tile.startMs + HistoryGeometry.spec(plan.res).tileSpanMs > scheduler.nowMs - let columns: [[Int32]] = series.map { name in - (0.. scheduler.nowMs { return missingSample } - let pv = house.pvAt(t) - let load = house.baseLoadAt(t) - let battery = max(-5_000, min(5_000, -(load + pv))) - switch name { - case "pv_w": return Int32(pv) - case "load_w": return Int32(load) - case "battery_w": return Int32(battery) - case "grid_w": return Int32(load + battery + pv) - default: return missingSample - } - } - } - let data = HistoryGeometry.pack(columns) - let etag = HistoryGeometry.etag(data) - if !partial, have[tile.tileId] == etag { continue } - sendBulk(Envelope(t: "hist.chunk", id: id, b: .map([ - ("tileId", .text(tile.tileId)), ("etag", .text(etag)), ("res", .text(plan.res.rawValue)), - ("startMs", .number(tile.startMs)), ("stepMs", .number(plan.stepMs)), - ("series", .array(series.map { .text($0) })), ("data", .bytes(data)), ("partial", .bool(partial)), - ]))) - } - sendBulk(Envelope(t: "hist.end", id: id, b: .map([("resActual", .text(plan.res.rawValue)), ("gaps", .array([]))]))) - } - // MARK: Commands private func onCommand(_ body: CBOR) { @@ -345,10 +251,7 @@ public final class SimulatedBox { } house.modeKey = mode controlRev += 1 - planRev += 1 result(cmdId, "applied", observed: Double(Self.modes.firstIndex { $0.key == mode } ?? 0)) - // A plan pushed unasked after a mode change, as the box does. - sendBulk(Envelope(t: "plan", b: planCBOR())) case Contract.opLoadpointHold: if args["clear"]?.bool == true { house.manualHoldW = nil @@ -400,9 +303,6 @@ public final class SimulatedBox { ("GET", "/api/status", .read, nil), ("GET", "/api/energy/daily", .read, nil), ("GET", "/api/savings/daily", .read, nil), - ("GET", "/api/app-link/devices", .read, nil), - ("POST", "/api/app-link/pairing", .configure, nil), - ("DELETE", "/api/app-link/devices/{id}", .configure, nil), ("GET", "/api/loadpoints", .read, nil), ("GET", "/api/mpc/plan", .read, nil), ("POST", "/api/loadpoints/{id}/vehicle", .configure, nil), @@ -410,12 +310,6 @@ public final class SimulatedBox { ("DELETE", "/api/loadpoints/{id}/schedule", .configure, nil), ("POST", "/api/loadpoints/{id}/soc", .actuate, Contract.opLoadpointSocSet), ("POST", "/api/mode", .actuate, Contract.opSetMode), - ("GET", "/api/notifications/vapid", .read, nil), - ("GET", "/api/notifications/history", .read, nil), - ("GET", "/api/notifications/rules", .read, nil), - ("PUT", "/api/notifications/rules", .configure, nil), - ("POST", "/api/notifications/test", .configure, nil), - ("POST", "/api/restart", .configure, nil), ("GET", "/api/config", .local, nil), ("GET", "/api/support/dump", .local, nil), ] @@ -524,46 +418,11 @@ public final class SimulatedBox { case ("DELETE", "/api/loadpoints/{id}/schedule"): house.schedule = nil return (200, ["ok": true]) - case ("GET", "/api/app-link/devices"): - return (200, ["devices": .array(devices.map { ["id": .string($0.id), "role": .string($0.role), "added_at_ms": .number($0.addedAtMs), "last_seen_ms": .number($0.lastSeenMs)] })]) - case ("POST", "/api/app-link/pairing"): - let role = body?["role"]?.string ?? "" - guard role == Contract.roleViewer || role == Contract.roleOwner else { return (400, ["code": "E_BAD_ROLE"]) } - let invite = Enrollment(boxStaticPublic: Bytes(repeating: 7, count: 32), pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) - return (200, ["url": .string(invite.url()), "role": .string(role), "expires_at_ms": .number(now + 600_000)]) - case ("DELETE", "/api/app-link/devices/{id}"): - guard let itemID, devices.contains(where: { $0.id == itemID }) else { return (404, [:]) } - let owners = devices.filter { $0.role == Contract.roleOwner } - if owners.count == 1, owners.first?.id == itemID { return (409, ["code": "E_LAST_OWNER_PROTECTED"]) } - devices.removeAll { $0.id == itemID } - return (200, ["ok": true]) - case ("GET", "/api/notifications/vapid"): - return (200, ["public_key": "BDemoKeyNotForSending"]) - case ("GET", "/api/notifications/history"): - return (200, ["events": .array(sentPushes.reversed().map { ["title": .string($0.title), "at_ms": .number($0.atMs)] })]) - case ("GET", "/api/notifications/rules"): - return (200, rulesJSON()) - case ("PUT", "/api/notifications/rules"): - notifyEnabled = body?["enabled"]?.bool ?? notifyEnabled - for rule in body?["events"]?.array ?? [] { - guard let type = rule["type"]?.string, let i = notifyRules.firstIndex(where: { $0.type == type }) else { continue } - notifyRules[i].enabled = rule["enabled"]?.bool ?? false - } - return (200, rulesJSON()) - case ("POST", "/api/notifications/test"): - sentPushes.append(("Test from your FTW box", now)) - return (200, ["ok": true]) - case ("POST", "/api/restart"): - return (200, ["status": "restarting"]) default: return (404, [:]) } } - private func rulesJSON() -> JSON { - ["enabled": .bool(notifyEnabled), "events": .array(notifyRules.map { ["type": .string($0.type), "enabled": .bool($0.enabled), "threshold": 0] })] - } - func loadpointJSON() -> JSON { let h = house var lp: JSON = [ @@ -698,7 +557,10 @@ public final class LoopbackCarrier: Carrier { } } - public func wake() {} + /// Pull to refresh in the demo: the house moves on a second. + public func wake() { + box.tick() + } public func close(reason: String) { if closed { return } @@ -707,3 +569,11 @@ public final class LoopbackCarrier: Carrier { status = .closed(reason: reason, retryable: false) } } + +extension SimulatedBox { + /// The demo as `AppModel` takes it: a fresh simulated home behind a + /// loopback carrier. + public static func loopback(_ scheduler: Scheduler) -> Carrier { + LoopbackCarrier(box: SimulatedBox(scheduler: scheduler), scheduler: scheduler) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedHouse.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift similarity index 100% rename from appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedHouse.swift rename to appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift index 9d7e04e..f3fdce7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift @@ -1,11 +1,5 @@ import Foundation - -/// How frames are reaching us. Orthogonal to `SourceState`, never merged -/// with it: "connected, but the inverter went quiet 40 seconds ago" needs -/// both. -public enum CarrierKind: String, Sendable, Codable { - case webrtc, relay, cache, none -} +import FTWShared public enum CarrierStatus: Equatable, Sendable { case connecting @@ -17,6 +11,14 @@ public enum CarrierStatus: Equatable, Sendable { return false } + /// The relay turned this phone away with 4429: the home already has as + /// many phones watching as the relay serves, or the relay is shedding + /// load. Someone else is looking; nothing is broken. + public var isBusy: Bool { + if case .closed(let reason, _) = self { return reason == RelayCarrier.closeReason(RelayCarrier.closeBusy) } + return false + } + var phaseName: String { switch self { case .connecting: return "connecting" diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift index 53f671c..72f725e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared /// The carrier that makes every other carrier private. /// diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift index 3ad1336..aaac9f8 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared /// A WebSocket to Sourceful's blind relay, wrapped so nothing above it ever /// learns the network went away. There is no reconnect button in this app @@ -15,10 +16,10 @@ import Foundation /// relay says the box is back: an hour offline costs one connection. @MainActor public final class RelayCarrier: Carrier { - public static let closeBadJoin = 4400 - public static let closeEpoch = 4409 - public static let closeRotated = 4410 - public static let closeBusy = 4429 + public nonisolated static let closeBadJoin = 4400 + public nonisolated static let closeEpoch = 4409 + public nonisolated static let closeRotated = 4410 + public nonisolated static let closeBusy = 4429 static let ctrlReady = "ready" static let ctrlGone = "gone" @@ -224,7 +225,7 @@ public final class RelayCarrier: Carrier { } } - static func closeReason(_ code: Int) -> String { + nonisolated static func closeReason(_ code: Int) -> String { switch code { case closeEpoch, closeRotated: return "rendezvous rotated" case closeBusy: return "relay is busy" diff --git a/appleApp/FTWKit/Sources/FTWKit/Exports.swift b/appleApp/FTWKit/Sources/FTWKit/Exports.swift new file mode 100644 index 0000000..3981723 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Exports.swift @@ -0,0 +1,2 @@ +// The app reads FTWShared's values through FTWKit, so it imports one module. +@_exported import FTWShared diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/CommandText.swift b/appleApp/FTWKit/Sources/FTWKit/Format/CommandText.swift new file mode 100644 index 0000000..67de5b7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/CommandText.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Sentences for a command's fate. One table for every op: the codes are +/// about the door, not about what was asked. +public enum CommandText { + public static func help(_ r: CmdResult) -> String { + switch r.errorCode { + case "E_PRECONDITION": return "Your home changed while that was sending. Have another go." + case "E_CONFLICT": return "Something else changed the setting first. Try again." + case "E_SCOPE_DENIED": return "You don't have permission to change how this home runs." + case "E_CMD_EXPIRED": return "That took too long to reach your box. Try again." + case "E_BOOTING": return "Your box is still starting. Give it a minute." + case "E_UNAVAILABLE": + if r.errorArgs["op"]?.string == Contract.opLoadpointSurplusOnlySet { return "Solar rule not saved. Your previous choice is unchanged. Try again." } + return "Your box can't reach the charger right now. Try again shortly." + default: return "That didn't go through. Try again." + } + } + + /// The boost's own refusals before the door's. + public static func boostHelp(_ r: CmdResult) -> String { + if r.errorCode == "E_UNAVAILABLE", r.errorArgs["op"]?.string != nil { + return "Your box won't boost right now — the house battery or the site isn't ready for it." + } + if r.errorCode == "E_UNKNOWN_OP", r.errorArgs["arg"]?.string == "lease" { + return "Your box refused that reserve and time. Try other values." + } + return help(r) + } + + /// A level for a car that is not on the cable is refused by name. + public static func socHelp(_ r: CmdResult) -> String { + if r.errorCode == "E_UNAVAILABLE", r.errorArgs["reason"]?.string == "unplugged" { + return "Plug the car in first — your box has no car to set a level for." + } + return help(r) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 826bc58..5ce6416 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared /// One charger as `/api/loadpoints` serves it, read tolerantly, and the /// sentences that describe it. No sentence claims what the box has not @@ -44,6 +45,9 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { public var powerW: Double public var socPct: Double? public var socSource: String + /// How old the car's own report of its level was when the box answered. + /// Sent only while the level comes from the car. + public var vehicleSocAgeS: Double? public var vehicleCapacityWh: Double? public var capacitySource: String public var socRetention: String @@ -79,6 +83,18 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { public var boostStopReason: String? public var schedule: Schedule? + /// When the level was read, on the phone's clock. A level from the car + /// is as old as the car's last report; any other level is the box's + /// account as of its answer. The box's own wall clock is never used: a + /// box without a clock source reads 1970. + public func socReadAtMs(answeredAtMs: Double) -> Double? { + guard socPct != nil else { return nil } + if socSource == "vehicle", let age = vehicleSocAgeS, age.isFinite, age >= 0 { + return answeredAtMs - age * 1000 + } + return answeredAtMs + } + /// A state of charge off the wire, whole percent. The box stores /// fractions and still reads a legacy percent; zero means unset. static func pct(_ v: JSON?) -> Double? { @@ -100,6 +116,7 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { socPct = num("current_soc_pct") } socSource = w["soc_source"]?.string ?? "" + vehicleSocAgeS = num("vehicle_soc_age_s") vehicleCapacityWh = num("vehicle_capacity_wh") capacitySource = w["capacity_source"]?.string ?? "" socRetention = w["soc_retention"]?.string ?? "" diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift index a8e175f..ea5a6ef 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared /// Why, not just what. /// diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift index 8587db6..3c8a933 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared /// From a site's readings to the energy diagram's nodes: the same corners, /// the same colours by role and direction, the same rules as the box's own diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift b/appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift deleted file mode 100644 index cbc7a5d..0000000 --- a/appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift +++ /dev/null @@ -1,296 +0,0 @@ -import Foundation - -/// A plan in sentences. The box sends reason codes; every word is decided -/// here. A plan is intent, not prophecy, so the copy says what the box means -/// to do and never promises it. -public enum PlanText { - public enum Action: Equatable, Sendable { case charge, discharge, idle } - - static let reasons: [String: String] = [ - "cheap_import": "Power is cheap", - "expensive_import": "Power is expensive", - "solar_surplus": "Spare solar", - "peak_shaving": "Holding the grid limit", - "reserve_held": "Keeping a reserve", - "export_paid": "Sending to the grid", - "idle": "Nothing scheduled", - ] - - /// A reason this app has not heard of is still the box's reason. - public static func reason(_ code: String) -> String { - reasons[code] ?? code.replacingOccurrences(of: "_", with: " ").capitalized - } - - public static func action(_ slot: PlanSlot) -> Action { - if slot.batteryW > 50 { return .charge } - if slot.batteryW < -50 { return .discharge } - return .idle - } - - /// Mode wording is the box's, from its own catalogue, so the dashboard - /// and this app never give one setting two names. - public static func label(_ mode: ModeInfo) -> String { mode.label } - public static func help(_ mode: ModeInfo) -> String { mode.tooltip } - - public struct Headline: Equatable, Sendable { - public let text: String - public let slotIndex: Int? - } - - /// One sentence about what happens next, which is what a plan is for. - public static func headline(_ plan: Plan?, nowMs: Double) -> Headline { - guard let plan else { return Headline(text: "No plan yet.", slotIndex: nil) } - if plan.stale { - return Headline(text: "Your box couldn't plan ahead just now, so it's running on safe defaults.", slotIndex: nil) - } - guard let current = plan.slots.firstIndex(where: { nowMs >= $0.startMs && nowMs < $0.startMs + $0.durationMs }) else { - return Headline(text: "No plan for right now.", slotIndex: nil) - } - let now = plan.slots[current] - let nowAction = action(now) - var next = current + 1 - while next < plan.slots.count, action(plan.slots[next]) == nowAction { next += 1 } - if next >= plan.slots.count { - return Headline(text: describe(now, later: false), slotIndex: current) - } - let change = plan.slots[next] - return Headline(text: "\(describe(now, later: false)) Then \(describe(change, later: true)) \(inWords(change.startMs - nowMs)).", slotIndex: current) - } - - public struct Brief: Equatable, Sendable { - public enum Tone: Sendable { case active, warn, idle } - public let stateLabel: String - public let tone: Tone - public let action: String - public let time: String? - public let reason: String? - public let constraint: String - } - - /// The box page's overview card: what, until when, why. - public static func brief(_ plan: Plan?, nowMs: Double, mode: String?, dispatchBlockedBy: [String], clock: (Double) -> String) -> Brief { - let planner = mode?.hasPrefix("planner_") ?? false - guard let plan else { - return Brief( - stateLabel: mode != nil && !planner ? "Manual" : "Checking…", - tone: .idle, - action: planner || mode == nil ? "Reading the current plan" : "Manual control is active", - time: nil, - reason: planner || mode == nil ? nil : "Planning is not controlling the battery", - constraint: constraint(nil, dispatchBlockedBy, nil) - ) - } - if plan.stale { - return Brief(stateLabel: "Fallback active", tone: .warn, action: "Your box couldn't plan ahead just now", time: nil, reason: "It's running on safe defaults", constraint: constraint(plan, dispatchBlockedBy, nil)) - } - let (label, tone) = state(mode, hasPlan: true) - guard let current = plan.slots.firstIndex(where: { nowMs >= $0.startMs && nowMs < $0.startMs + $0.durationMs }) else { - let (l, t) = state(mode, hasPlan: false) - return Brief(stateLabel: l, tone: t, action: "No plan for right now", time: nil, reason: nil, constraint: constraint(plan, dispatchBlockedBy, nil)) - } - let now = plan.slots[current] - let nowAction = action(now) - var runEnd = current - while runEnd + 1 < plan.slots.count, action(plan.slots[runEnd + 1]) == nowAction { runEnd += 1 } - let untilMs = plan.slots[runEnd].startMs + plan.slots[runEnd].durationMs - - var shown = now - var time: String? = "Now, until \(clock(untilMs))" - if nowAction == .idle { - if let future = plan.slots.enumerated().first(where: { $0.offset > current && action($0.element) != .idle })?.element { - shown = future - time = "At \(clock(future.startMs))" - } else { - time = nil - } - } - return Brief(stateLabel: label, tone: tone, action: actionLabel(shown), time: time, reason: reason(shown.reason), constraint: constraint(plan, dispatchBlockedBy, shown)) - } - - static func state(_ mode: String?, hasPlan: Bool) -> (String, Brief.Tone) { - if let mode, mode.hasPrefix("planner_") { - return hasPlan ? ("Plan active", .active) : ("Checking…", .idle) - } - if mode != nil { return ("Manual", .idle) } - return (hasPlan ? "Plan ready" : "Checking…", .idle) - } - - static func actionLabel(_ slot: PlanSlot) -> String { - let amount = PowerFormat.text(slot.batteryW) - switch action(slot) { - case .idle: return "Keep the battery steady" - case .charge: return "Charge battery at \(amount)" - case .discharge: return "Use battery at \(amount)" - } - } - - static func constraint(_ plan: Plan?, _ blocked: [String], _ slot: PlanSlot?) -> String { - if !blocked.isEmpty { return "Control is paused because a meter stopped reporting." } - if plan?.stale == true { return "The schedule is old, so FTW is using safe live balancing." } - if let slot, slot.reason == "peak_shaving", let cap = plan?.ceilingW { - return "Holding the grid limit at \(PowerFormat.text(cap))." - } - return "No active safety adjustment." - } - - static func describe(_ slot: PlanSlot, later: Bool) -> String { - let amount = PowerFormat.text(slot.batteryW) - switch action(slot) { - case .idle: - return later ? "it rests" : "The battery is resting — \(reason(slot.reason).lowercased())." - case .charge: - return later ? "it charges at \(amount)" : "The battery is charging at \(amount) — \(reason(slot.reason).lowercased())." - case .discharge: - return later ? "it covers the house at \(amount)" : "The battery is covering the house at \(amount) — \(reason(slot.reason).lowercased())." - } - } - - /// Coarse on purpose: a plan is intent that will be revised. - static func inWords(_ ms: Double) -> String { - let minutes = Int((ms / 60_000).rounded()) - if minutes < 1 { return "in a moment" } - if minutes < 25 { return "in \(minutes) min" } - if minutes < 50 { return "in about half an hour" } - let hours = Int((Double(minutes) / 60).rounded()) - if hours <= 1 { return "in about an hour" } - if hours < 10 { return "in about \(hours) hours" } - return "later today" - } -} - -/// What to call the number on a price, from the box's price-units.js. Every -/// price is minor units per kWh; only the label and the decimals differ. -public enum PriceUnits { - public struct Unit: Sendable { - public let label: String - public let perKwh: String - public let scale: Double - public let decimals: Int - } - - static let units: [String: Unit] = [ - "SEK": Unit(label: "öre", perKwh: "öre/kWh", scale: 1, decimals: 1), - "NOK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), - "DKK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), - "EUR": Unit(label: "cent", perKwh: "cent/kWh", scale: 1, decimals: 1), - "PLN": Unit(label: "gr", perKwh: "gr/kWh", scale: 1, decimals: 1), - "CHF": Unit(label: "Rp.", perKwh: "Rp./kWh", scale: 1, decimals: 1), - "CZK": Unit(label: "Kč", perKwh: "Kč/kWh", scale: 0.01, decimals: 2), - "HUF": Unit(label: "Ft", perKwh: "Ft/kWh", scale: 0.01, decimals: 1), - "RON": Unit(label: "lei", perKwh: "lei/kWh", scale: 0.01, decimals: 2), - ] - - public static func unit(_ currency: String?) -> Unit { - let code = (currency ?? "SEK").isEmpty ? "SEK" : (currency ?? "SEK").uppercased() - return units[code] ?? Unit(label: code, perKwh: "\(code)/kWh", scale: 0.01, decimals: 3) - } - - public static func display(_ minor: Double, _ currency: String?) -> Double { - minor * unit(currency).scale - } - - /// "17.4", in the chart's unit and precision. - public static func text(_ minor: Double?, _ currency: String?) -> String? { - guard let minor, minor.isFinite else { return nil } - return PowerFormat.fixed(display(minor, currency), unit(currency).decimals) - } - - /// Whether a price window misses hours rather than ending early: a head - /// that starts after the window asked for, or a hole in the middle. - public static func hasHole(_ slots: [PriceSlot], fromMs: Double) -> Bool { - guard let first = slots.first else { return false } - if first.startMs > fromMs { return true } - for i in slots.indices.dropFirst() where slots[i - 1].startMs + slots[i - 1].durationMs < slots[i].startMs { - return true - } - return false - } -} - -/// The box's compact savings card, from GET /api/savings/daily. -public enum Savings { - public struct Day: Equatable, Sendable { - public let day: String - public let savedOre: Double - public let resolution: String - } - - public struct Period: Equatable, Sendable { - public let savedMinor: Double - public let pricedDays: Int - public let totalDays: Int - public var available: Bool { pricedDays > 0 } - public var complete: Bool { totalDays > 0 && pricedDays == totalDays } - } - - public struct Periods: Equatable, Sendable { - public let today: Period - public let week: Period - public let month: Period - } - - public static func day(_ row: JSON) -> Day? { - guard let d = row["day"]?.string, d.count == 10, d.dropFirst(4).first == "-" else { return nil } - return Day(day: d, savedOre: row["saved_ore"]?.number ?? 0, resolution: row["resolution"]?.string ?? "slot") - } - - static func summarize(_ rows: [Day]) -> Period { - let priced = rows.filter { $0.resolution != "no_prices" } - return Period(savedMinor: priced.reduce(0) { $0 + $1.savedOre }, pricedDays: priced.count, totalDays: rows.count) - } - - public static func periods(_ days: [Day]) -> Periods { - let rows = days.sorted { $0.day < $1.day } - let month = rows.last.map { String($0.day.prefix(7)) } ?? "" - return Periods( - today: summarize(Array(rows.suffix(1))), - week: summarize(Array(rows.suffix(7))), - month: summarize(month.isEmpty ? [] : rows.filter { $0.day.hasPrefix(month + "-") }) - ) - } - - /// Signed major units: "+12.4", "−3.10". The currency sits in the heading. - public static func compact(_ minor: Double) -> String { - let major = minor / 100 - let a = abs(major) - let digits = a >= 100 ? 0 : a >= 10 ? 1 : 2 - return (major >= 0 ? "+" : "−") + PowerFormat.fixed(a, digits) - } -} - -/// Sentences for a command's fate. One table for every op: the codes are -/// about the door, not about what was asked. -public enum CommandText { - public static func help(_ r: CmdResult) -> String { - switch r.errorCode { - case "E_PRECONDITION": return "Your home changed while that was sending. Have another go." - case "E_CONFLICT": return "Something else changed the setting first. Try again." - case "E_SCOPE_DENIED": return "You don't have permission to change how this home runs." - case "E_CMD_EXPIRED": return "That took too long to reach your box. Try again." - case "E_BOOTING": return "Your box is still starting. Give it a minute." - case "E_UNAVAILABLE": - if r.errorArgs["op"]?.string == Contract.opLoadpointSurplusOnlySet { return "Solar rule not saved. Your previous choice is unchanged. Try again." } - return "Your box can't reach the charger right now. Try again shortly." - default: return "That didn't go through. Try again." - } - } - - /// The boost's own refusals before the door's. - public static func boostHelp(_ r: CmdResult) -> String { - if r.errorCode == "E_UNAVAILABLE", r.errorArgs["op"]?.string != nil { - return "Your box won't boost right now — the house battery or the site isn't ready for it." - } - if r.errorCode == "E_UNKNOWN_OP", r.errorArgs["arg"]?.string == "lease" { - return "Your box refused that reserve and time. Try other values." - } - return help(r) - } - - /// A level for a car that is not on the cable is refused by name. - public static func socHelp(_ r: CmdResult) -> String { - if r.errorCode == "E_UNAVAILABLE", r.errorArgs["reason"]?.string == "unplugged" { - return "Plug the car in first — your box has no car to set a level for." - } - return help(r) - } -} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Savings.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Savings.swift new file mode 100644 index 0000000..8f6b446 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Savings.swift @@ -0,0 +1,53 @@ +import Foundation +import FTWShared + +/// The box's compact savings card, from GET /api/savings/daily. +public enum Savings { + public struct Day: Equatable, Sendable { + public let day: String + public let savedOre: Double + public let resolution: String + } + + public struct Period: Equatable, Sendable { + public let savedMinor: Double + public let pricedDays: Int + public let totalDays: Int + public var available: Bool { pricedDays > 0 } + public var complete: Bool { totalDays > 0 && pricedDays == totalDays } + } + + public struct Periods: Equatable, Sendable { + public let today: Period + public let week: Period + public let month: Period + } + + public static func day(_ row: JSON) -> Day? { + guard let d = row["day"]?.string, d.count == 10, d.dropFirst(4).first == "-" else { return nil } + return Day(day: d, savedOre: row["saved_ore"]?.number ?? 0, resolution: row["resolution"]?.string ?? "slot") + } + + static func summarize(_ rows: [Day]) -> Period { + let priced = rows.filter { $0.resolution != "no_prices" } + return Period(savedMinor: priced.reduce(0) { $0 + $1.savedOre }, pricedDays: priced.count, totalDays: rows.count) + } + + public static func periods(_ days: [Day]) -> Periods { + let rows = days.sorted { $0.day < $1.day } + let month = rows.last.map { String($0.day.prefix(7)) } ?? "" + return Periods( + today: summarize(Array(rows.suffix(1))), + week: summarize(Array(rows.suffix(7))), + month: summarize(month.isEmpty ? [] : rows.filter { $0.day.hasPrefix(month + "-") }) + ) + } + + /// Signed major units: "+12.4", "−3.10". The currency sits in the heading. + public static func compact(_ minor: Double) -> String { + let major = minor / 100 + let a = abs(major) + let digits = a >= 100 ? 0 : a >= 10 ? 1 : 2 + return (major >= 0 ? "+" : "−") + PowerFormat.fixed(a, digits) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift deleted file mode 100644 index 63b28b1..0000000 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation - -/// The box code: eight characters somebody reads out loud from the box's -/// screen. They decode to the same five bytes a scanned code's payload -/// carries and are spent in the same place, handshake message 1. -/// -/// Crockford base32 without I, L, O and U. Every fold happens here, before an -/// attempt is spent: the box burns a code after five wrong tries, so a typo -/// this file could have normalised must never cost the household its code. -/// Mirrors DecodeSpokenCode in the box's appenroll/boxcode.go. -public enum BoxCode { - public static let bytes = 5 - public static let chars = 8 - - static let alphabet = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ") - static let ignored: Set = [" ", "-", "\t"] - - public struct BoxCodeError: Error, Equatable, HelpfulError { - public let message: String - public let help: String - } - - static let notACode = "That is not a code from your box. It is eight characters, like 04HM-ASW9." - - private static func fold(_ c: Character) -> Character { - switch c { - case "I", "L": return "1" - case "O": return "0" - default: return c - } - } - - /// Fold what someone typed onto the alphabet, dropping what does not - /// belong. For the input field, on every keystroke. - public static func fold(_ typed: String) -> String { - var out = "" - for raw in typed.uppercased() { - if ignored.contains(raw) { continue } - let c = fold(raw) - if alphabet.contains(c) { out.append(c) } - } - return String(out.prefix(chars)) - } - - /// XXXX-XXXX. The hyphen is for the reader. - public static func group(_ folded: String) -> String { - folded.count > 4 ? "\(folded.prefix(4))-\(folded.dropFirst(4))" : folded - } - - /// The five bytes the box drew. A character outside the alphabet means - /// the person is reading the wrong line, so it is refused rather than - /// dropped. Nothing here reaches the box. - public static func decode(_ typed: String) throws -> Bytes { - var chars = [Character]() - for raw in typed.uppercased() { - if ignored.contains(raw) { continue } - let c = fold(raw) - guard alphabet.contains(c) else { - throw BoxCodeError(message: "\(raw) is not in the alphabet", help: notACode) - } - chars.append(c) - } - guard chars.count == Self.chars else { - throw BoxCodeError(message: "\(chars.count) characters, expected \(Self.chars)", help: notACode) - } - var n: UInt64 = 0 - for c in chars { n = n << 5 | UInt64(alphabet.firstIndex(of: c)!) } - return (0..> UInt64(8 * (bytes - 1 - $0))) } - } -} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift deleted file mode 100644 index e4370f5..0000000 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift +++ /dev/null @@ -1,296 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// The sealed copy Sourceful holds, so a new phone is not a dead end. -/// -/// "Sourceful holds a sealed copy it cannot open, with an opaque id and -/// nothing beside it." Sealed under a key derived from PRF output; the id is -/// an HKDF sibling of that key; a write is signed, a read is not, because a -/// fresh install has a passkey and nothing else. The wire is the web app's, -/// so either app reads what the other wrote. -/// -/// Losing the service costs a QR scan. Nothing depends on it. -@MainActor -public final class Escrow { - /// Every request body is exactly this long; the service refuses others. - static let requestBytes = 1024 - - public enum SaveOutcome: Equatable, Sendable { - case saved, cleared, unsupported, unreachable, failed, conflict - } - - public enum RemoveOutcome: Equatable, Sendable { - case removed, nothingToRemove, declined, kept - } - - public struct EscrowError: Error, Equatable, HelpfulError { - public let code: String - public let message: String - public let help: String - } - - /// One POST to one path. Injected by tests. - public typealias Transport = @MainActor (_ body: Bytes) async throws -> (status: Int, body: Bytes) - - private let transport: Transport - private let vault: Vault - private let sites: SiteList - - public init(vault: Vault, sites: SiteList, transport: Transport? = nil) { - self.vault = vault - self.sites = sites - self.transport = transport ?? Escrow.urlSessionTransport(Origin.escrowURL.appendingPathComponent("e")) - } - - // MARK: Which homes - - /// The homes this household asked to be held, oldest first. - public func escrowedHomes() -> [RecoveryBlob.Home] { - sites.all() - .filter { $0.escrow && $0.rendezvousSecret != nil } - .sorted { $0.addedAtMs < $1.addedAtMs } - .map { RecoveryBlob.Home(siteId: $0.siteId, label: $0.label, boxStaticKey: $0.boxStaticKey.byteArray, rendezvousSecret: $0.rendezvousSecret!.byteArray) } - } - - public func mark(_ siteId: String, _ on: Bool) { - sites.update(siteId) { $0.escrow = on } - } - - // MARK: Writing - - /// Put the marked homes in the escrow, replacing what was there. Reports - /// rather than throws: a device with no spare copy is the device everyone - /// had yesterday. - public func save(_ wrapping: WrappingKey, pending: RecoveryBlob.Home? = nil) async -> SaveOutcome { - guard let keys = wrapping.escrow else { return .unsupported } - do { - var homes = escrowedHomes() - if let pending { - homes = homes.filter { $0.siteId != pending.siteId } + [pending] - } - if homes.isEmpty { - return try await clear(keys) ? .cleared : .conflict - } - var scalar = try vault.exportDeviceSecret(wrapping) - defer { wipe(&scalar) } - // Twice at most. The version is bound into the seal, so losing a - // race means sealing again under the new number. - for _ in 0..<2 { - let held = try await readHeld(keys.lookupID) - let version = (held?.version ?? 0) + 1 - let sealed = try RecoveryBlob.seal(key: keys.sealKey, .init(deviceScalar: scalar, homes: homes), escrowVersion: version) - if try await put(keys, version: version, blob: sealed) { return .saved } - } - return .conflict - } catch is EscrowError { - return .unreachable - } catch { - return .failed - } - } - - /// Take the copy away. Costs nothing at all when no home opted in. - public func remove(_ passkeys: PasskeyAuthenticator?) async -> RemoveOutcome { - if escrowedHomes().isEmpty { return .nothingToRemove } - let wrapping: WrappingKey - do { - wrapping = try await vault.unlockWrappingKey(passkeys) - } catch is PasskeyCancelled { - return .declined - } catch { - return .kept - } - guard let keys = wrapping.escrow else { return .kept } - do { - return try await clear(keys) ? .removed : .kept - } catch { - return .kept - } - } - - /// Empty the copy rather than delete the row: a delete would restart the - /// version at 1, and a kept old blob could then be written straight back. - private func clear(_ keys: EscrowKeys) async throws -> Bool { - for _ in 0..<2 { - guard let held = try await readHeld(keys.lookupID) else { return true } - if held.blob.isEmpty { return true } - if try await put(keys, version: held.version + 1, blob: []) { return true } - } - return false - } - - // MARK: Bringing a home back - - public struct Recovered: Identifiable, Sendable { - public let siteId: String - public let label: String - public let fingerprint: String - let deviceScalar: Bytes - let wrapping: WrappingKey - let everyHome: [RecoveryBlob.Home] - public var id: String { siteId } - } - - /// One ceremony yields the id and the key. An empty result is the - /// ordinary answer for a passkey that never saved anything; a copy that is - /// there and will not open throws, because that one means something - /// replaced it. - public func recover(_ passkeys: PasskeyAuthenticator?) async throws -> [Recovered] { - guard let passkeys, passkeys.isAvailable else { return [] } - let outcome = try await passkeys.assert(credentialIDs: []) - guard let prf = outcome.prfOutput else { - throw RecoveryBlob.BlobError(code: "E_BLOB_LOCKED", message: "no PRF output", help: "This passkey cannot unlock what was saved. Open your box's local dashboard and use Settings → FTW app → Show pairing code.") - } - let wrapping = PRF.wrappingKey(credentialID: outcome.credentialID, prfOutput: prf) - guard let keys = wrapping.escrow else { return [] } - guard let held = try await readHeld(keys.lookupID), !held.blob.isEmpty else { return [] } - let contents = try RecoveryBlob.open(key: keys.sealKey, held.blob, escrowVersion: held.version) - return contents.homes.map { - Recovered(siteId: $0.siteId, label: $0.label, fingerprint: SiteList.fingerprint($0.boxStaticKey), deviceScalar: contents.deviceScalar, wrapping: wrapping, everyHome: contents.homes) - } - } - - /// Write a recovered home down. The device key is put back rather than - /// minted, wrapped under the local key so connecting stays silent and - /// under the passkey that just answered. Every home in the copy is - /// written, because the next save rebuilds the copy from this disk. - @discardableResult - public func adopt(_ home: Recovered, nowMs: Double) throws -> String { - let local = vault.localWrappingKey() - try vault.restoreDeviceKey(local, scalar: home.deviceScalar) - try vault.addCredential(current: local, next: home.wrapping) - for each in home.everyHome { - sites.put(StoredSite( - siteId: each.siteId, - label: each.label, - boxStaticKey: Data(each.boxStaticKey), - rendezvousSecret: Data(each.rendezvousSecret), - pairingCode: nil, - lanHint: nil, - escrow: true, - addedAtMs: nowMs, - lastSeenAtMs: nowMs - )) - } - return home.siteId - } - - // MARK: The wire - - struct Held { - var version: UInt32 - var blob: Bytes - } - - private func readHeld(_ lookupID: String) async throws -> Held? { - let response = try await request([("op", .string("get")), ("id", .string(lookupID))]) - if response.status == 404 { return nil } - guard response.status == 200 else { throw refused(response.status) } - guard let object = try? JSONSerialization.jsonObject(with: Data(response.body)) as? [String: Any], - let version = (object["version"] as? NSNumber)?.uint32Value, - let text = object["blob"] as? String, - let blob = Data(base64Encoded: text)?.byteArray, - blob.count == RecoveryBlob.maxBytes || blob.isEmpty else { - throw refused(response.status) - } - return Held(version: version, blob: blob) - } - - /// The bytes a write is signed over; the service's `writeMessage`. - static func writeMessage(lookupID: String, version: UInt32, blob: Bytes) -> Bytes { - Array("ftw-escrow:v1:\(lookupID):\(version):\(Primitives.sha256(blob).hex)".utf8) - } - - /// True when it landed; false is a lost race and nothing else. - private func put(_ keys: EscrowKeys, version: UInt32, blob: Bytes) async throws -> Bool { - let signature = try keys.sign(Self.writeMessage(lookupID: keys.lookupID, version: version, blob: blob)) - let response = try await request([ - ("op", .string("put")), - ("id", .string(keys.lookupID)), - ("version", .number(Int(version))), - ("blob", .string(Data(blob).base64EncodedString())), - ("pub", .string(Base64url.encode(keys.writeKey))), - ("sig", .string(Base64url.encode(signature))), - ]) - if response.status == 200 { return true } - if response.status == 409 { return false } - throw refused(response.status) - } - - enum Field { - case string(String) - case number(Int) - } - - /// `body` as JSON with a `pad` member that makes the whole exactly - /// `bytes` long, the way the web app pads it. The id never goes in a URL: - /// a URL is what every layer writes down. - static func padded(_ fields: [(String, Field)], to bytes: Int = requestBytes) -> Bytes { - func render(_ pad: String) -> String { - var parts = fields.map { key, value -> String in - switch value { - case .string(let s): return "\(jsonString(key)):\(jsonString(s))" - case .number(let n): return "\(jsonString(key)):\(n)" - } - } - parts.append("\"pad\":\(jsonString(pad))") - return "{" + parts.joined(separator: ",") + "}" - } - let empty = render("") - return Array(render(String(repeating: "A", count: max(0, bytes - empty.utf8.count))).utf8) - } - - private func request(_ fields: [(String, Field)]) async throws -> (status: Int, body: Bytes) { - do { - return try await transport(Self.padded(fields)) - } catch { - throw EscrowError(code: "E_ESCROW_UNREACHABLE", message: "the escrow could not be reached", help: "The saved copy could not be reached. Your home works either way — try again when you are back online.") - } - } - - private func refused(_ status: Int) -> EscrowError { - EscrowError(code: "E_ESCROW_REFUSED", message: "the escrow answered \(status)", help: "The saved copy could not be read. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR instead.") - } - - /// No cookies, no cache, nothing an origin could be recognised by. - static func urlSessionTransport(_ url: URL) -> Transport { - { body in - let config = URLSessionConfiguration.ephemeral - config.httpCookieStorage = nil - config.urlCache = nil - config.requestCachePolicy = .reloadIgnoringLocalCacheData - let session = URLSession(configuration: config) - defer { session.finishTasksAndInvalidate() } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.httpBody = Data(body) - let (data, response) = try await session.data(for: request) - return ((response as? HTTPURLResponse)?.statusCode ?? 0, data.byteArray) - } - } -} - -/// A JSON string literal. Only what JSON requires is escaped, which is what -/// JavaScript's JSON.stringify does for these ASCII values. -func jsonString(_ s: String) -> String { - var out = "\"" - for scalar in s.unicodeScalars { - switch scalar { - case "\"": out += "\\\"" - case "\\": out += "\\\\" - case "\n": out += "\\n" - case "\r": out += "\\r" - case "\t": out += "\\t" - default: - if scalar.value < 0x20 { - out += String(format: "\\u%04x", scalar.value) - } else { - out.unicodeScalars.append(scalar) - } - } - } - return out + "\"" -} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift index 4a90b5e..8afd8bc 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift @@ -15,9 +15,6 @@ public enum Origin { /// The relay. One value, no setting. public static let relayURL = URL(string: "wss://relay.ftw.energy")! - /// The sealed recovery copy. Its own host, neither the relay's nor the app's. - public static let escrowURL = URL(string: "https://escrow.ftw.energy")! - /// Source and licence, as the AGPL asks a network client to offer them. public static let sourceURL = URL(string: "https://github.com/srcfl/ftw-app")! public static let licenseURL = URL(string: "https://github.com/srcfl/ftw-app/blob/main/LICENSE")! diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift index 49cb62b..ba22d1c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift @@ -6,26 +6,20 @@ import Foundation /// exists because the PRF extension hands back a secret only after the user /// verifies, and that secret is turned into keys here, the same way the web /// app turns it into keys. Same RP ID, same salt, same HKDF: a passkey that -/// sealed a recovery copy in the web app opens it in this app, and the +/// wraps a device key in the web app unwraps it in this app, and the /// reverse. public enum PRF { - /// One fixed salt per purpose, forever. The authenticator is only ever - /// asked for the vault salt; the escrow salt is an HKDF salt. + /// One fixed salt, forever. The authenticator is only ever asked for it. public static let vaultSalt = Array("ftw.prf.v1.vault".utf8) - public static let escrowSalt = Array("ftw.prf.v1.escrow".utf8) static let wrapInfo = Array("ftw.wrap.v1".utf8) - static let escrowIDInfo = Array("ftw.escrow.id.v1".utf8) - static let escrowKeyInfo = Array("ftw.escrow.key.v1".utf8) - static let escrowWriteInfo = Array("ftw.escrow.write.v1".utf8) /// The AES-256-GCM key that wraps the device key, from one PRF output. public static func wrappingKey(credentialID: String, prfOutput: Bytes) -> WrappingKey { WrappingKey( credentialID: credentialID, source: .prf, - key: Primitives.hkdf(ikm: prfOutput, salt: vaultSalt, info: wrapInfo, length: 32), - escrow: EscrowKeys(prfOutput: prfOutput) + key: Primitives.hkdf(ikm: prfOutput, salt: vaultSalt, info: wrapInfo, length: 32) ) } } @@ -42,33 +36,6 @@ public struct WrappingKey: Sendable { public let source: WrappingSource /// 32 bytes of AES-256-GCM key. let key: Bytes - /// Present only where PRF is. The local key derives nothing that may - /// leave this device. - public let escrow: EscrowKeys? -} - -/// What the escrow needs, as HKDF siblings of the vault key: the id says -/// nothing about the key, and neither can be produced from the other. -public struct EscrowKeys: Sendable { - /// base64url of 32 bytes. The only name the service ever learns. - public let lookupID: String - let sealKey: Bytes - /// Ed25519 public key the service pins on first write. - public let writeKey: Bytes - private let writeSeed: Bytes - - init(prfOutput: Bytes) { - let salt = PRF.escrowSalt - lookupID = Base64url.encode(Primitives.hkdf(ikm: prfOutput, salt: salt, info: PRF.escrowIDInfo, length: 32)) - sealKey = Primitives.hkdf(ikm: prfOutput, salt: salt, info: PRF.escrowKeyInfo, length: 32) - writeSeed = Primitives.hkdf(ikm: prfOutput, salt: salt, info: PRF.escrowWriteInfo, length: 32) - // A 32-byte seed is always a valid Ed25519 key. - writeKey = (try? Primitives.ed25519PublicKey(seed: writeSeed)) ?? [] - } - - func sign(_ message: Bytes) throws -> Bytes { - try Primitives.ed25519Sign(seed: writeSeed, message: message) - } } /// The platform side of a passkey ceremony, injected so this package never diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift index 3f3f358..eed537d 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift @@ -8,32 +8,23 @@ import Foundation /// 3. Unwrap or create the device key, and make the local copy. /// 4. Store the home, pinned to the box key from the QR. /// -/// Only then does anything touch the network, and the spare copy is written -/// in the background without delaying the house. +/// Only then does anything touch the network. @MainActor public final class Pairing { private let vault: Vault private let sites: SiteList - private let escrow: Escrow private let passkeys: PasskeyAuthenticator? private let now: () -> Double - public init(vault: Vault, sites: SiteList, escrow: Escrow, passkeys: PasskeyAuthenticator?, now: @escaping () -> Double = { Date().timeIntervalSince1970 * 1000 }) { + public init(vault: Vault, sites: SiteList, passkeys: PasskeyAuthenticator?, now: @escaping () -> Double = { Date().timeIntervalSince1970 * 1000 }) { self.vault = vault self.sites = sites - self.escrow = escrow self.passkeys = passkeys self.now = now } - public struct Paired: Sendable { - public let site: StoredSite - /// The background seal of the spare copy, for tests to await. - public let sealed: Task - } - /// Throws `EnrollmentError` with a sentence, or `PasskeyCancelled`. - public func pair(scanned: String, holdCopy: Bool = true) async throws -> Paired { + public func pair(scanned: String) async throws -> StoredSite { let enrollment = try Enrollment.parse(scanned: scanned) let wrapping = vault.isEnrolled @@ -53,39 +44,10 @@ public final class Pairing { rendezvousSecret: Data(enrollment.rendezvousSecret), pairingCode: Data(enrollment.pairingCode), lanHint: enrollment.lanHint.isEmpty ? nil : enrollment.lanHint, - escrow: sites.get(siteId)?.escrow ?? false, addedAtMs: at, lastSeenAtMs: at ) sites.put(site) - - // Hold a sealed copy from the first device, by default and in the - // background. The mark follows the write, so the Box screen never - // says a copy is held when it is not. - let escrow = self.escrow - let sealed = Task { @MainActor () -> Escrow.SaveOutcome? in - guard holdCopy, wrapping.escrow != nil else { return nil } - let outcome = await escrow.save(wrapping, pending: RecoveryBlob.Home( - siteId: site.siteId, - label: site.label, - boxStaticKey: site.boxStaticKey.byteArray, - rendezvousSecret: site.rendezvousSecret?.byteArray ?? [] - )) - escrow.mark(site.siteId, outcome == .saved) - return outcome - } - return Paired(site: site, sealed: sealed) - } - - /// Arm the next handshake with a code read off the box's screen. For a - /// phone that has been here before: a box code carries no box key and no - /// rendezvous secret, so a phone with no row for this home scans instead. - /// The decode happens before the disk is touched, so a typo costs nothing. - public func redeemBoxCode(siteId: String, typed: String) throws { - let code = try BoxCode.decode(typed) - guard sites.get(siteId) != nil else { - throw BoxCode.BoxCodeError(message: "no site \(siteId)", help: "This phone has no record of that home. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR instead.") - } - sites.update(siteId) { $0.pairingCode = Data(code) } + return site } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift deleted file mode 100644 index 320e458..0000000 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift +++ /dev/null @@ -1,143 +0,0 @@ -import Foundation - -/// The sealed copy of a household: the bytes, and nothing about where they -/// are kept. The same format the web app writes, so a copy either one -/// sealed opens in the other. -/// -/// sealed: [version 1][nonce 12][ciphertext + tag] -/// plain: [device scalar 32][home count 1] -/// per home: [id len 1][id][name len 1][name][box key 32][rendezvous secret 32] -/// then zeros out to the fixed plaintext length -/// AAD: [version 1][escrow version 4, big-endian] -/// -/// Padded to one length so whoever holds the ciphertext cannot count homes. -/// The escrow version is bound in as additional data, so a service that pairs -/// an old blob with a new number gets a refusal here rather than a household -/// getting back a home it had removed. -public enum RecoveryBlob { - public static let maxBytes = 512 - public static let version: UInt8 = 2 - public static let escrowVersionNone: UInt32 = 0 - static let nonceBytes = 12 - static let headerBytes = 1 + nonceBytes - static let tagBytes = 16 - public static let plainBytes = maxBytes - headerBytes - tagBytes - /// The name is for telling two homes apart, not a record. - public static let maxNameChars = 32 - - public struct Home: Equatable, Sendable { - public var siteId: String - public var label: String - public var boxStaticKey: Bytes - public var rendezvousSecret: Bytes - - public init(siteId: String, label: String, boxStaticKey: Bytes, rendezvousSecret: Bytes) { - self.siteId = siteId - self.label = label - self.boxStaticKey = boxStaticKey - self.rendezvousSecret = rendezvousSecret - } - } - - public struct Contents: Equatable, Sendable { - public var deviceScalar: Bytes - public var homes: [Home] - } - - public struct BlobError: Error, Equatable, HelpfulError { - public let code: String - public let message: String - public let help: String - } - - static func format(_ message: String) -> BlobError { - BlobError(code: "E_BLOB_FORMAT", message: message, help: "The saved copy of this home could not be read. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR instead.") - } - - public static func encode(_ contents: Contents) throws -> Bytes { - guard contents.deviceScalar.count == 32 else { throw format("device key is not 32 bytes") } - guard contents.homes.count <= 0xff else { throw format("more homes than a byte counts") } - var out = Bytes() - out.reserveCapacity(plainBytes) - out += contents.deviceScalar - out.append(UInt8(contents.homes.count)) - for home in contents.homes { - guard home.boxStaticKey.count == Enrollment.boxKeyBytes else { throw format("box key is not 32 bytes") } - guard home.rendezvousSecret.count == Enrollment.rendezvousSecretBytes else { throw format("rendezvous secret is not 32 bytes") } - let id = Array(home.siteId.utf8) - // Whole characters: a cut between the halves of an emoji would - // keep one of them. - let name = Array(String(String.UnicodeScalarView(home.label.unicodeScalars.prefix(maxNameChars))).utf8) - guard id.count <= 0xff, name.count <= 0xff else { throw format("a name is longer than a byte") } - out.append(UInt8(id.count)) - out += id - out.append(UInt8(name.count)) - out += name - out += home.boxStaticKey - out += home.rendezvousSecret - } - guard out.count <= plainBytes else { - throw BlobError(code: "E_BLOB_TOO_BIG", message: "payload is \(out.count) bytes", help: "There are more homes on this phone than a saved copy can carry. They all still work here.") - } - out += Bytes(repeating: 0, count: plainBytes - out.count) - return out - } - - /// Strict: anything short, long or unaccounted for is refused rather than - /// read half-way. - public static func decode(_ plain: Bytes) throws -> Contents { - guard plain.count == plainBytes else { throw format("payload is \(plain.count) bytes") } - let scalar = Array(plain[0..<32]) - var at = 32 - let count = Int(plain[at]) - at += 1 - var homes = [Home]() - - func slice() throws -> Bytes { - guard at < plain.count else { throw format("payload ends in the middle of a home") } - let n = Int(plain[at]) - at += 1 - guard at + n <= plain.count else { throw format("payload ends in the middle of a name") } - defer { at += n } - return Array(plain[at.. Bytes { - [version, UInt8(escrowVersion >> 24), UInt8(escrowVersion >> 16 & 0xff), UInt8(escrowVersion >> 8 & 0xff), UInt8(escrowVersion & 0xff)] - } - - public static func seal(key: Bytes, _ contents: Contents, escrowVersion: UInt32) throws -> Bytes { - var plain = try encode(contents) - defer { wipe(&plain) } - let nonce = randomBytes(nonceBytes) - let ct = try Primitives.aesGCMSeal(key: key, nonce: nonce, ad: aad(escrowVersion), plaintext: plain) - return [version] + nonce + ct - } - - public static func open(key: Bytes, _ sealed: Bytes, escrowVersion: UInt32) throws -> Contents { - guard sealed.count > headerBytes else { throw format("blob is too short to be sealed") } - guard sealed[0] == version else { throw format("blob version \(sealed[0])") } - var plain: Bytes - do { - plain = try Primitives.aesGCMOpen(key: key, nonce: Array(sealed[1.. WrappingKey { if let existing = store.get(Self.localWrapKey), existing.count == 32 { - return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: existing, escrow: nil) + return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: existing) } let key = randomBytes(32) store.put(Self.localWrapKey, key) - return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: key, escrow: nil) + return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: key) } /// The key for connecting, without a prompt, or nil when only passkey diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index 86d3b1d..6774f90 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -51,13 +51,28 @@ public enum Contract { public static let capAPIPassthrough = "api.passthrough" public static let opSetMode = "site.mode.set" + public static let opPlannerPrefsSet = "planner.prefs.set" public static let opBatteryHold = "battery.hold" public static let opLoadpointHold = "loadpoint.hold" public static let opLoadpointBoost = "loadpoint.boost" public static let opLoadpointSocSet = "loadpoint.soc.set" public static let opLoadpointSurplusOnlySet = "loadpoint.surplus_only.set" - public static let ops = [opSetMode, opBatteryHold, opLoadpointHold, opLoadpointBoost, opLoadpointSocSet, opLoadpointSurplusOnlySet] + public static let ops = [opSetMode, opPlannerPrefsSet, opBatteryHold, opLoadpointHold, opLoadpointBoost, opLoadpointSocSet, opLoadpointSurplusOnlySet] + + /// The box's dispatch modes and where each is drawn. The box sends its + /// own catalogue and is the authority on what exists; this copy is for + /// validating and wording, never for deciding. + public static let modes: [(key: String, tier: String)] = [ + ("planner_passive_arbitrage", "primary"), + ("planner_arbitrage", "primary"), + ("idle", "advanced"), + ("self_consumption", "advanced"), + ("peak_shaving", "advanced"), + ("charge", "advanced"), + ("planner_self", "hidden"), + ("planner_cheap", "hidden"), + ] /// Field ids frozen permanently as of v1. public enum FID { diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift deleted file mode 100644 index 102a014..0000000 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift +++ /dev/null @@ -1,184 +0,0 @@ -import Foundation - -/// History geometry: tiles, resolutions and the column packing. The same -/// numbers the box uses, so a tile asked for and a tile built are one tile. -/// -/// Downsampling always happens on the box. When a window is too wide the box -/// clamps to a coarser store and reports `resActual`; when even that is too -/// wide it averages whole buckets and widens `stepMs`. -public enum HistoryGeometry { - public struct Spec: Sendable { - public let stepMs: Double - public let tileSpanMs: Double - public let retentionMs: Double - } - - static let dayMs: Double = 86_400_000 - - /// Mirrors `resolutions` in protocol/registry.yaml. - public static func spec(_ res: Resolution) -> Spec { - switch res { - case .fiveMinutes: return Spec(stepMs: 300_000, tileSpanMs: 43_200_000, retentionMs: 30 * dayMs) - case .hour: return Spec(stepMs: 3_600_000, tileSpanMs: 604_800_000, retentionMs: 730 * dayMs) - } - } - - /// Fine to coarse: the order the box clamps along. - public static let order: [Resolution] = [.fiveMinutes, .hour] - - public static func pointsPerTile(_ res: Resolution) -> Int { - let s = spec(res) - return Int(s.tileSpanMs / s.stepMs) - } - - /// Tiles are epoch-aligned, so two peers never disagree where one starts. - public static func tileStart(_ res: Resolution, _ atMs: Double) -> Double { - let span = spec(res).tileSpanMs - return (atMs / span).rounded(.down) * span - } - - public struct PlannedTile: Equatable, Sendable { - public let tileId: String - public let startMs: Double - public let points: Int - } - - public struct Plan: Equatable, Sendable { - public let res: Resolution - public let stride: Int - public let stepMs: Double - public let tiles: [PlannedTile] - } - - /// Resolution, stride and tile list for a window, deterministically, so - /// the client plans the same tiles the box will send. - public static func plan(_ res: Resolution, fromMs: Double, toMs: Double, maxPoints: Int = 2000) -> Plan { - let cap = max(1, maxPoints) - // Transfer is whole tiles, so what must fit under the cap is the - // tile-aligned span, not the requested one. - func alignedSpan(_ r: Resolution) -> Double { - let first = tileStart(r, fromMs) - let last = tileStart(r, max(fromMs, toMs - 1)) - return last + spec(r).tileSpanMs - first - } - - // Clamp to a coarser store before aggregating: coarser stored data is - // real data, an aggregate of finer data is an average of it. - var chosen = order.last! - for candidate in order where order.firstIndex(of: candidate)! >= order.firstIndex(of: res)! { - chosen = candidate - if alignedSpan(candidate) / spec(candidate).stepMs <= Double(cap) { break } - } - - let base = spec(chosen).stepMs - let total = alignedSpan(chosen) - let n = pointsPerTile(chosen) - let options = (1...n).filter { n % $0 == 0 } - let stride = options.first { total / (base * Double($0)) <= Double(cap) } ?? options.last! - let span = spec(chosen).tileSpanMs - let firstStart = tileStart(chosen, fromMs) - let lastStart = tileStart(chosen, max(fromMs, toMs - 1)) - - var tiles = [PlannedTile]() - var start = firstStart - while start <= lastStart { - tiles.append(PlannedTile(tileId: tileID(chosen, stride: stride, startMs: start), startMs: start, points: n / stride)) - start += span - } - return Plan(res: chosen, stride: stride, stepMs: base * Double(stride), tiles: tiles) - } - - /// The stride is part of a tile's identity: six buckets averaged hold - /// different numbers from the same hours at full detail. - public static func tileID(_ res: Resolution, stride: Int, startMs: Double) -> String { - "\(res.rawValue)/\(stride)/\(Int64(startMs / spec(res).tileSpanMs))" - } - - /// One contiguous int32 LE block per series. - public static func pack(_ columns: [[Int32]]) -> Bytes { - var out = Bytes() - for column in columns { - for v in column { - let u = UInt32(bitPattern: v) - out += [UInt8(u & 0xff), UInt8(u >> 8 & 0xff), UInt8(u >> 16 & 0xff), UInt8(u >> 24)] - } - } - return out - } - - public static func unpack(_ data: Bytes, seriesCount: Int) -> [[Int32]] { - guard seriesCount > 0 else { return [] } - let points = data.count / 4 / seriesCount - guard points > 0 else { return Array(repeating: [], count: seriesCount) } - return (0.. Int32? { - guard let c = names.firstIndex(of: name), index >= 0, index < columns[c].count else { return nil } - let v = columns[c][index] - return v == missingSample ? nil : v - } - - public func time(at index: Int) -> Double { startMs + Double(index) * stepMs } - } - - /// Lay tiles end to end. A missing tile stays missing rather than - /// shifting what follows it: closing over a gap would show the wrong day. - public static func assemble(_ plan: Plan, names: [String], tiles: [String: HistChunk]) -> Frame { - let perTile = plan.tiles.first?.points ?? 0 - let points = perTile * plan.tiles.count - var columns = names.map { _ in [Int32](repeating: missingSample, count: points) } - for (index, planned) in plan.tiles.enumerated() { - guard let tile = tiles[planned.tileId] else { continue } - let unpacked = unpack(tile.data, seriesCount: tile.series.count) - let offset = index * perTile - for (target, name) in names.enumerated() { - guard let s = tile.series.firstIndex(of: name), s < unpacked.count else { continue } - let source = unpacked[s].prefix(perTile) - for (i, v) in source.enumerated() { columns[target][offset + i] = v } - } - } - return Frame(startMs: plan.tiles.first?.startMs ?? 0, stepMs: plan.stepMs, points: points, names: names, columns: columns) - } - - /// Trim a tile-aligned frame to the window asked for. - public static func clip(_ frame: Frame, fromMs: Double, toMs: Double) -> Frame { - guard frame.points > 0 else { return frame } - let first = max(0, Int(((fromMs - frame.startMs) / frame.stepMs).rounded(.down))) - let last = min(frame.points, Int(((toMs - frame.startMs) / frame.stepMs).rounded(.up))) - if first == 0 && last == frame.points { return frame } - if last <= first { return Frame(startMs: frame.startMs, stepMs: frame.stepMs, points: 0, names: frame.names, columns: frame.names.map { _ in [] }) } - return Frame( - startMs: frame.startMs + Double(first) * frame.stepMs, - stepMs: frame.stepMs, - points: last - first, - names: frame.names, - columns: frame.columns.map { Array($0[first.. String { - var h: UInt32 = 0x811c9dc5 - for b in data { - h ^= UInt32(b) - h = h &* 0x01000193 - } - let s = String(h, radix: 16) - return String(repeating: "0", count: 8 - s.count) + s - } -} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift index adb6cbc..0636938 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared /// The message shapes. Names shared with the box come from /// protocol/registry.yaml, through `Contract`. @@ -17,22 +18,6 @@ public enum BoxMode: String, Sendable { case full, floor, booting, readonly } -public enum SourceState: String, Sendable, Codable, Comparable { - case live, lagging, stale, down, never - - var rank: Int { - switch self { - case .live: return 0 - case .lagging: return 1 - case .stale: return 2 - case .down: return 3 - case .never: return 4 - } - } - - public static func < (a: SourceState, b: SourceState) -> Bool { a.rank < b.rank } -} - public struct Source: Equatable, Sendable, Codable { public var kind: String public var name: String @@ -212,207 +197,27 @@ public struct Delta: Sendable { } } -// MARK: - Plan and prices - -public struct PlanSlot: Equatable, Sendable, Identifiable { - /// Wall clock: these are future times a person reads. - public var startMs: Double - public var durationMs: Double - /// Signed watts at the battery, site convention: positive charges. - public var batteryW: Double - /// Expected import at the meter; negative means expected export. - public var gridW: Double - /// Import price in minor units per kWh, nil when unknown. - public var priceMinor: Double? - /// A stable code, never prose. The app owns every word. - public var reason: String - public var id: Double { startMs } - - public init(startMs: Double, durationMs: Double, batteryW: Double, gridW: Double, priceMinor: Double?, reason: String) { - self.startMs = startMs - self.durationMs = durationMs - self.batteryW = batteryW - self.gridW = gridW - self.priceMinor = priceMinor - self.reason = reason - } -} - -public struct Plan: Equatable, Sendable { - public var rev: UInt64 - public var uptimeMs: Double - public var slots: [PlanSlot] - /// The planner could not run and the box fell back. "Nothing scheduled" - /// and "we do not know what is scheduled" are different sentences. - public var stale: Bool - public var ceilingW: Double? - - public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?) { - self.rev = rev - self.uptimeMs = uptimeMs - self.slots = slots - self.stale = stale - self.ceilingW = ceilingW - } - - init(_ c: CBOR) { - rev = UInt64(max(0, c["rev"]?.int64 ?? 0)) - uptimeMs = c["uptimeMs"]?.double ?? 0 - slots = (c["slots"]?.array ?? []).compactMap { s in - guard let start = s["startMs"]?.double else { return nil } - return PlanSlot( - startMs: start, - durationMs: s["durationMs"]?.double ?? 900_000, - batteryW: s["batteryW"]?.double ?? 0, - gridW: s["gridW"]?.double ?? 0, - priceMinor: s["priceMinor"]?.double, - reason: s["reason"]?.string ?? "idle" - ) - } - stale = c["stale"]?.bool ?? false - ceilingW = c["ceilingW"]?.double - } -} - -public struct PriceSlot: Equatable, Sendable, Identifiable { - public var startMs: Double - public var durationMs: Double - /// Integer minor units per kWh. Money never crosses as a float. - public var spotMinor: Double - /// What the household pays, tariff and tax included, computed by the box. - public var totalMinor: Double - public var id: Double { startMs } - - public init(startMs: Double, durationMs: Double, spotMinor: Double, totalMinor: Double) { - self.startMs = startMs - self.durationMs = durationMs - self.spotMinor = spotMinor - self.totalMinor = totalMinor - } -} - -public struct Prices: Equatable, Sendable { - public var zone: String - public var currency: String - public var slots: [PriceSlot] - /// The answer does not cover the window asked for. Which of the three - /// shapes it is has to be read off the slots. - public var stale: Bool - - public init(zone: String, currency: String, slots: [PriceSlot], stale: Bool) { - self.zone = zone - self.currency = currency - self.slots = slots - self.stale = stale - } - - init(_ c: CBOR) { - zone = c["zone"]?.string ?? "" - currency = c["currency"]?.string ?? "SEK" - slots = (c["slots"]?.array ?? []).compactMap { s in - guard let start = s["startMs"]?.double else { return nil } - return PriceSlot( - startMs: start, - durationMs: s["durationMs"]?.double ?? 3_600_000, - spotMinor: s["spotMinor"]?.double ?? 0, - totalMinor: s["totalMinor"]?.double ?? s["spotMinor"]?.double ?? 0 - ) - } - stale = c["stale"]?.bool ?? false - } -} - -// MARK: - History - -public enum Resolution: String, Sendable, CaseIterable { - case fiveMinutes = "5m" - case hour = "1h" -} - -public struct HistQuery: Sendable { - public var series: [String] - public var res: Resolution - public var fromMs: Double - public var toMs: Double - public var have: [(tileId: String, etag: String)] - public var maxPoints: Int? - - var cbor: CBOR { - var pairs: [(String, CBOR)] = [ - ("series", .array(series.map { .text($0) })), - ("res", .text(res.rawValue)), - // Whole milliseconds: the box reads these into int64 and drops a - // query whose times carry a fraction. - ("fromMs", .ms(fromMs)), - ("toMs", .ms(toMs)), - ] - if !have.isEmpty { - pairs.append(("have", .array(have.map { .map([("tileId", .text($0.tileId)), ("etag", .text($0.etag))]) }))) - } - if let maxPoints { pairs.append(("maxPoints", .int(maxPoints))) } - return .map(pairs) - } -} - -public struct HistChunk: Sendable { - public var tileId: String - public var etag: String - public var res: Resolution - public var startMs: Double - public var stepMs: Double - public var series: [String] - /// Column-packed int32 little-endian, one block per series. - public var data: Bytes - /// The trailing tile is still filling; never cache it. - public var partial: Bool - - init?(_ c: CBOR) { - guard let tileId = c["tileId"]?.string else { return nil } - self.tileId = tileId - etag = c["etag"]?.string ?? "" - res = c["res"]?.string.flatMap(Resolution.init(rawValue:)) ?? .fiveMinutes - startMs = c["startMs"]?.double ?? 0 - stepMs = c["stepMs"]?.double ?? 300_000 - series = c["series"]?.stringArray ?? [] - data = c["data"]?.byteString ?? [] - partial = c["partial"]?.bool ?? false - } - - public init(tileId: String, etag: String, res: Resolution, startMs: Double, stepMs: Double, series: [String], data: Bytes, partial: Bool) { - self.tileId = tileId - self.etag = etag - self.res = res - self.startMs = startMs - self.stepMs = stepMs - self.series = series - self.data = data - self.partial = partial - } -} - -public struct HistGap: Equatable, Sendable { - public var fromMs: Double - public var toMs: Double - public var reason: String -} - -public struct HistEnd: Sendable { - /// What the box actually served, which may be coarser than asked for. - public var resActual: Resolution - public var gaps: [HistGap] +// MARK: - Prices +extension Prices { init(_ c: CBOR) { - resActual = c["resActual"]?.string.flatMap(Resolution.init(rawValue:)) ?? .fiveMinutes - gaps = (c["gaps"]?.array ?? []).compactMap { g in - guard let from = g["fromMs"]?.double, let to = g["toMs"]?.double else { return nil } - return HistGap(fromMs: from, toMs: to, reason: g["reason"]?.string ?? "no_data") - } + self.init( + zone: c["zone"]?.string ?? "", + currency: c["currency"]?.string ?? "SEK", + slots: (c["slots"]?.array ?? []).compactMap { s in + guard let start = s["startMs"]?.double else { return nil } + return PriceSlot( + startMs: start, + durationMs: s["durationMs"]?.double ?? 3_600_000, + spotMinor: s["spotMinor"]?.double ?? 0, + totalMinor: s["totalMinor"]?.double ?? s["spotMinor"]?.double ?? 0 + ) + }, + stale: c["stale"]?.bool ?? false + ) } } -/// Distinct from zero, which is a real reading. -public let missingSample = Int32.min - // MARK: - The box's own API public enum APIMethod: String, Sendable { diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index 7ae4e18..7380ae2 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared /// The session: handshake, subscription and the field register. /// @@ -7,10 +8,6 @@ import Foundation /// /// Freshness is two facts, never one: `carrier`, how frames are reaching us, /// and `sources`, whether the box's own devices are answering. -public enum SessionPhase: String, Sendable { - case idle, handshaking, subscribing, streaming, booting, terminated, failed -} - public struct SessionState: Sendable { public var phase: SessionPhase = .idle public var carrier: CarrierKind = .none @@ -41,9 +38,9 @@ public struct SessionState: Sendable { public var terminated: TerminateReason? /// The box says this app is too old for everything. public var needsUpdate = false - /// What the box intends to do. The box pushes a fresh one unasked after - /// a mode change, so it lives here rather than only as an answer. - public var plan: Plan? + /// The relay turned this phone away: the home already has as many + /// phones watching as it serves. Someone else is looking; nothing broke. + public var busy = false /// The last delta could not carry every changed field. Not a fault. public var truncated = false /// Every mode the box accepts, in its order. Field 1 indexes this list. @@ -89,11 +86,7 @@ public struct CommandError: Error, Equatable, HelpfulError { @MainActor public final class Session { - /// History over a relay can be dozens of bulk frames; the point is that - /// the request always settles, not that it fails fast. - public static let historyTimeoutMs: Double = 20_000 public static let apiTimeoutMs: Double = 20_000 - public static let planTimeoutMs: Double = 8_000 public static let priceTimeoutMs: Double = 8_000 /// A box that is starting refuses a subscription and does not announce /// when it is ready, so the session asks again on its own. @@ -122,12 +115,6 @@ public final class Session { private var nextRequestID: UInt32 = 1 private var bootRetry: Cancellable? - private struct PendingHistory { - var onChunk: @MainActor (HistChunk) -> Void - var continuation: CheckedContinuation - var timer: Cancellable? - } - private struct PendingSingle { var continuation: CheckedContinuation var timer: Cancellable? @@ -148,8 +135,6 @@ public final class Session { var acked = false } - private var pendingHistory: [UInt32: PendingHistory] = [:] - private var pendingPlan: [UInt32: PendingSingle] = [:] private var pendingPrices: [UInt32: PendingSingle] = [:] private var pendingAPI: [UInt32: PendingAPI] = [:] private var pendingCommands: [String: PendingCommand] = [:] @@ -222,6 +207,7 @@ public final class Session { var s = state s.phase = .idle s.carrier = .none + s.busy = false state = s } @@ -266,35 +252,6 @@ public final class Session { return id } - /// A history window. Chunks arrive as they land; the call returns on - /// `hist.end`. Bulk lane: a query carrying a `have` list varies in length. - public func history(_ query: HistQuery, onChunk: @escaping @MainActor (HistChunk) -> Void) async throws -> HistEnd { - guard carrier != nil else { throw SessionError.noCarrier } - let id = takeRequestID() - let frame = try Frame.encodeBulk(envelope: Envelope(t: "hist.query", id: id, b: query.cbor)) - // Registered before the frame leaves, so an answer can never arrive - // for a request nobody is holding. - return try await withCheckedThrowingContinuation { cont in - pendingHistory[id] = PendingHistory(onChunk: onChunk, continuation: cont, timer: nil) - armHistory(id) - carrier?.send(frame) - } - } - - public func plan() async throws -> Plan { - guard carrier != nil else { throw SessionError.noCarrier } - let id = takeRequestID() - let frame = try Frame.encodeBulk(envelope: Envelope(t: "plan.get", id: id)) - return try await withCheckedThrowingContinuation { cont in - let timer = scheduler.after(Self.planTimeoutMs) { [weak self] in - guard let p = self?.pendingPlan.removeValue(forKey: id) else { return } - p.continuation.resume(throwing: SessionError.timedOut("plan")) - } - pendingPlan[id] = PendingSingle(continuation: cont, timer: timer) - carrier?.send(frame) - } - } - /// Prices across a window. Wall clock, unlike every age: prices are /// about hours a person plans around. public func prices(fromMs: Double, toMs: Double) async throws -> Prices { @@ -440,6 +397,7 @@ public final class Session { var s = state s.phase = .handshaking s.carrier = kind + s.busy = false state = s sendHello() case .closed: @@ -448,6 +406,7 @@ public final class Session { var s = state s.phase = .failed s.carrier = .none + s.busy = status.isBusy state = s bootRetry?.cancel() // The ordinary way a carrier goes away: it reconnects inside @@ -473,24 +432,6 @@ public final class Session { var s = state s.uptimeMs = body["uptimeMs"]?.double ?? s.uptimeMs state = s - case "hist.chunk": - guard let id = env.id, let chunk = HistChunk(body), let pending = pendingHistory[id] else { return } - // A window still arriving is not a window gone quiet. - armHistory(id) - pending.onChunk(chunk) - case "hist.end": - guard let id = env.id, let pending = pendingHistory.removeValue(forKey: id) else { return } - pending.timer?.cancel() - pending.continuation.resume(returning: HistEnd(body)) - case "plan": - let plan = Plan(body) - var s = state - s.plan = plan - state = s - if let id = env.id, let pending = pendingPlan.removeValue(forKey: id) { - pending.timer?.cancel() - pending.continuation.resume(returning: plan) - } case "price": guard let id = env.id, let pending = pendingPrices.removeValue(forKey: id) else { return } pending.timer?.cancel() @@ -631,16 +572,6 @@ public final class Session { /// window the box could not serve never makes the whole app look broken. private func onError(_ b: ErrorMsg, id: UInt32?) { if let id { - if let p = pendingHistory.removeValue(forKey: id) { - p.timer?.cancel() - p.continuation.resume(throwing: BoxRefusal(detail: b)) - return - } - if let p = pendingPlan.removeValue(forKey: id) { - p.timer?.cancel() - p.continuation.resume(throwing: BoxRefusal(detail: b)) - return - } if let p = pendingPrices.removeValue(forKey: id) { p.timer?.cancel() p.continuation.resume(throwing: BoxRefusal(detail: b)) @@ -682,17 +613,6 @@ public final class Session { // MARK: Deadlines - /// The deadline measures silence, not total time: every chunk re-arms it. - private func armHistory(_ id: UInt32) { - guard var p = pendingHistory[id] else { return } - p.timer?.cancel() - p.timer = scheduler.after(Self.historyTimeoutMs) { [weak self] in - guard let p = self?.pendingHistory.removeValue(forKey: id) else { return } - p.continuation.resume(throwing: SessionError.timedOut("history")) - } - pendingHistory[id] = p - } - private func armAPI(_ id: UInt32) { guard var p = pendingAPI[id] else { return } p.timer?.cancel() @@ -718,10 +638,6 @@ public final class Session { /// means it did not reach the box; acked means it may well have run. private func settlePending() { apiGeneration += 1 - for (_, p) in pendingHistory { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } - pendingHistory = [:] - for (_, p) in pendingPlan { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } - pendingPlan = [:] for (_, p) in pendingPrices { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } pendingPrices = [:] for (_, p) in pendingAPI { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 59ff3f6..06f0bf9 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared import Observation /// Every model one home's screens read, built once per home and kept while @@ -6,34 +7,26 @@ import Observation @MainActor public final class HomeModels { public let site: SiteModel - public let plan: PlanModel public let prices: PriceModel public let status: StatusWatch public let charging: ChargingWatch public let savings: SavingsModel - public let energy: EnergyModel - public let history: HistoryModel public let loadpoints: LoadpointsModel - public let access: AccessModel - public let notify: NotifyModel - public let restart: RestartModel - init(site: SiteModel, files: SealedFiles?, thisPhone: String?) { + init(site: SiteModel) { self.site = site - plan = PlanModel(site: site) prices = PriceModel(site: site) status = StatusWatch(site: site) charging = ChargingWatch(site: site) savings = SavingsModel(site: site) - energy = EnergyModel(site: site) - history = HistoryModel(site: site, tiles: TileCache(files: site.isDemo ? nil : files, siteId: site.siteId ?? "demo")) loadpoints = LoadpointsModel(site: site, charging: charging) - access = AccessModel(site: site, thisPhone: thisPhone) - notify = NotifyModel(site: site) - restart = RestartModel(site: site) } } +/// Builds the carrier to a simulated home. FTWDemo supplies it; FTWKit holds +/// no fake box of its own. +public typealias DemoCarrier = @MainActor (Scheduler) -> Carrier + /// No carrier could be built from what this phone holds. Every other /// failure heals itself because the carrier reconnects from inside; these /// are the ones with nothing to reconnect, so the screen offers a way back. @@ -58,37 +51,43 @@ public final class AppModel { /// A pairing link that arrived from outside, shown before it is trusted. public var offeredLink: String? public private(set) var isDemo = false - /// The Box screen's spare-key switch for the open home. Nil in the demo. - public private(set) var sealedCopy: SealedCopyModel? @ObservationIgnored public let vault: Vault @ObservationIgnored public let sites: SiteList - @ObservationIgnored public let escrow: Escrow @ObservationIgnored public let pairing: Pairing @ObservationIgnored public let passkeys: PasskeyAuthenticator? @ObservationIgnored let store: SecureStore @ObservationIgnored let files: SealedFiles? + @ObservationIgnored let shared: AppGroupFiles? + @ObservationIgnored let sharedWritten: (@MainActor () -> Void)? + @ObservationIgnored let demo: DemoCarrier? @ObservationIgnored let scheduler: Scheduler @ObservationIgnored let build: String @ObservationIgnored let ua: String @ObservationIgnored let relayURL: URL @ObservationIgnored let makeSocket: WebSocketFactory - @ObservationIgnored private var demoBox: SimulatedBox? @ObservationIgnored private var connectGeneration = 0 + /// `suspend` closed the session; the next look builds a new one. + @ObservationIgnored private var suspended = false public init( store: SecureStore, files: SealedFiles?, + shared: AppGroupFiles? = nil, + sharedWritten: (@MainActor () -> Void)? = nil, passkeys: PasskeyAuthenticator?, scheduler: Scheduler = LiveScheduler.shared, build: String, ua: String, relayURL: URL = Origin.relayURL, - escrowTransport: Escrow.Transport? = nil, - makeSocket: @escaping WebSocketFactory = URLSessionWebSocket.factory() + makeSocket: @escaping WebSocketFactory = URLSessionWebSocket.factory(), + demo: DemoCarrier? = nil ) { self.store = store self.files = files + self.shared = shared + self.sharedWritten = sharedWritten + self.demo = demo self.passkeys = passkeys self.scheduler = scheduler self.build = build @@ -97,8 +96,7 @@ public final class AppModel { self.makeSocket = makeSocket vault = Vault(store: store) sites = SiteList(store: store) - escrow = Escrow(vault: vault, sites: sites, transport: escrowTransport) - pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys, now: { [scheduler] in scheduler.nowMs }) + pairing = Pairing(vault: vault, sites: sites, passkeys: passkeys, now: { [scheduler] in scheduler.nowMs }) } public var site: SiteModel? { home?.site } @@ -117,13 +115,14 @@ public final class AppModel { public func open(_ siteId: String) { exitDemo() home?.site.destroy() - let site = SiteModel(siteId: siteId, build: build, ua: ua, scheduler: scheduler, files: files) + let site = SiteModel(siteId: siteId, build: build, ua: ua, scheduler: scheduler, files: files, shared: shared) site.stepUp = { [weak self] in await self?.stepUp() ?? .unavailable } - home = HomeModels(site: site, files: files, thisPhone: vault.deviceIDOnBox) - sealedCopy = SealedCopyModel(app: self, siteId: siteId) + site.sharedWritten = sharedWritten + home = HomeModels(site: site) sites.setCurrent(siteId) site.start() recovering = false + suspended = false Task { await connect() } } @@ -183,7 +182,7 @@ public final class AppModel { /// was none. public func refresh() async { if isDemo { - demoBox?.tick() + site?.refresh() return } if connectHelp != nil || site?.core.hasCarrier == false { @@ -193,8 +192,27 @@ public final class AppModel { } } + /// Whether anyone is looking. Coming back from the background builds a + /// new carrier, because `suspend` closed the old one. public func setVisible(_ visible: Bool) { - site?.setVisible(visible) + guard let site else { return } + site.setVisible(visible) + if visible, suspended { + suspended = false + Task { await connect() } + } + } + + /// The app went to the background: close the session and keep what is + /// on screen. The demo has no socket to free and keeps running. + public func suspend() { + guard let site else { return } + if isDemo { + site.setVisible(false) + } else { + site.suspend() + suspended = true + } } public func networkOnline() { @@ -223,30 +241,29 @@ public final class AppModel { // MARK: The demo + /// Whether this build carries the simulated home. + public var canDemo: Bool { demo != nil } + /// A simulated home in this process. Nothing is written and no passkey /// is asked for; leaving it touches no saved home. public func startDemo() { + guard let demo else { return } home?.site.destroy() let site = SiteModel(siteId: nil, build: build, ua: ua, scheduler: scheduler, files: nil, isDemo: true) site.ceilingW = 11_000 - let box = SimulatedBox(scheduler: scheduler) - demoBox = box - home = HomeModels(site: site, files: nil, thisPhone: "Qm94T3du") - sealedCopy = nil + home = HomeModels(site: site) isDemo = true recovering = false offeredLink = nil connectHelp = nil site.start() - site.connect(LoopbackCarrier(box: box, scheduler: scheduler)) + site.connect(demo(scheduler)) } public func exitDemo() { guard isDemo else { return } home?.site.destroy() home = nil - sealedCopy = nil - demoBox = nil isDemo = false if let current = sites.current() { open(current.siteId) } } @@ -255,14 +272,15 @@ public final class AppModel { /// Sign out on this phone: the stream stops first and the disk is /// cleared second, so a reading landing mid-clear cannot write the home - /// back. The sealed copy at Sourceful stays; removing it is its own act. + /// back. The widgets lose their records too. public func leave() { home?.site.destroy() home = nil - sealedCopy = nil vault.reset() sites.clear() files?.clear() + shared?.removeAll() + sharedWritten?() connectHelp = nil leaveFailed = false recovering = false diff --git a/appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift deleted file mode 100644 index 7477c8c..0000000 --- a/appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift +++ /dev/null @@ -1,335 +0,0 @@ -import Foundation -import Observation - -/// Who can see this home, through the box's own roster. Reading is a GET; -/// inviting and removing are writes, so the box asks for a ceremony first. -/// -/// An invite is the ordinary pairing code minted with the viewer role and a -/// shorter life. The role is never in the payload: a role its holder could -/// edit is not a role. -@Observable -@MainActor -public final class AccessModel: Activatable { - public struct Member: Equatable, Sendable, Identifiable { - /// The box's name for the row: the first eight characters of its key. - public let id: String - public let role: String - public let addedAtMs: Double? - public let lastSeenMs: Double? - public let isThisPhone: Bool - } - - public struct Invite: Equatable, Sendable { - /// The whole QR payload. It may only leave as a square for a camera. - public let url: String - public let role: String - public let expiresAtMs: Double - } - - public enum Busy: Sendable { case none, inviting, revoking } - - public private(set) var members: [Member] = [] - public private(set) var loading = false - public private(set) var loaded = false - public private(set) var error: String? - public private(set) var invite: Invite? - public private(set) var busy: Busy = .none - - @ObservationIgnored private unowned let site: SiteModel - @ObservationIgnored private let thisPhone: String? - @ObservationIgnored private var ask: LiveAsk? - @ObservationIgnored private var active = 0 - @ObservationIgnored private var token = 0 - - public init(site: SiteModel, thisPhone: String?) { - self.site = site - self.thisPhone = thisPhone - } - - public var canManage: Bool { site.canConfigure && site.hasPassthrough } - public var owners: Int { members.filter { $0.role == Contract.roleOwner }.count } - - public func activate() { - active += 1 - if ask == nil { - ask = LiveAsk(site: site, want: { [weak self] in - guard let self, self.active > 0, self.canManage else { return nil } - return "members" - }, ask: { [weak self] in try await self?.load() }) - } - ask?.evaluate() - } - - public func deactivate() { - active = max(0, active - 1) - ask?.evaluate() - } - - func load() async throws { - token += 1 - let mine = token - loading = true - error = nil - defer { if mine == token { loading = false } } - do { - let wire = try await site.callBox(.get, "/api/app-link/devices") - guard mine == token else { return } - members = (wire?["devices"]?.array ?? []).map { d in - let id = d["id"]?.string ?? "" - // A row from a box before roles is an owner. - let role = d["role"]?.string.flatMap { $0.isEmpty ? nil : $0 } ?? Contract.roleOwner - return Member(id: id, role: role, addedAtMs: d["added_at_ms"]?.number, lastSeenMs: d["last_seen_ms"]?.number, isThisPhone: id == thisPhone) - } - loaded = true - } catch { - guard mine == token else { return } - self.error = (error as? BoxAPIError)?.help ?? "Your box didn't answer. Still trying." - throw error - } - } - - /// Mint a code that admits a viewer. The role goes in the body, where the - /// box reads it, and an answer naming any other role is refused here. - @discardableResult - public func inviteViewer() async -> Bool { - busy = .inviting - error = nil - defer { busy = .none } - do { - let wire = try await site.callBox(.post, "/api/app-link/pairing", body: ["role": .string(Contract.roleViewer)]) - guard wire?["role"]?.string == Contract.roleViewer, let url = wire?["url"]?.string else { - throw BoxAPIError(code: "E_BAD_BODY", help: "Your box didn't make a view-only invitation. Nothing has been shared.", status: nil) - } - invite = Invite(url: url, role: Contract.roleViewer, expiresAtMs: wire?["expires_at_ms"]?.number ?? 0) - return true - } catch { - self.error = (error as? BoxAPIError)?.help ?? "That did not work. Nothing has changed." - return false - } - } - - public func dismissInvite() { invite = nil } - - /// Withdraw a phone's access. The row goes on the box's answer alone. - @discardableResult - public func revoke(_ id: String) async -> Bool { - busy = .revoking - error = nil - defer { busy = .none } - do { - _ = try await site.callBox(.delete, "/api/app-link/devices/\(LoadpointsModel.escape(id))") - members.removeAll { $0.id == id } - return true - } catch let e as BoxAPIError where e.status == 404 { - // Already gone is what was asked for. - members.removeAll { $0.id == id } - return true - } catch { - self.error = (error as? BoxAPIError)?.help ?? "That did not work. Nothing has changed." - return false - } - } - - public static func roleLabel(_ role: String) -> String { - Contract.roleLabels[role] ?? role - } - - public func seen(_ ms: Double?) -> String { - guard let ms, ms > 0 else { return "not seen yet" } - let since = site.nowMs - ms - if since < 120_000 { return "here now" } - if since < 3_600_000 { return "\(Int((since / 60_000).rounded())) min ago" } - if since < 86_400_000 { return "\(Int((since / 3_600_000).rounded())) h ago" } - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("dMMM") - return f.string(from: Date(timeIntervalSince1970: ms / 1000)) - } -} - -/// What the box sends when something at home matters, and what it has sent. -/// -/// The rules are the box's document and govern every phone it can reach. -/// Each entry goes out whole on a save, flipped only on `enabled`, because -/// the box replaces a rule wholesale and a partial one would wipe the -/// thresholds it seeded. -@Observable -@MainActor -public final class NotifyModel: Activatable { - /// The kinds the rules govern. box.unreachable is not one: the box cannot - /// gate a message about its own absence. - public static let ruleKinds = [ - "charging.connected", "charging.session_complete", "charging.interrupted", - "update.installed", "driver.offline", "fuse.over_limit", - ] - - public static let labels: [String: String] = [ - "charging.connected": "When the car is plugged in", - "charging.session_complete": "When the car finishes charging", - "charging.interrupted": "If charging stops before it is done", - "update.installed": "When your box updates itself", - "driver.offline": "If a device goes quiet", - "fuse.over_limit": "If the house draws more than the fuse allows", - "box.unreachable": "If your box goes out of reach", - ] - - public struct Sent: Equatable, Sendable, Identifiable { - public let title: String - public let atMs: Double? - public var id: String { "\(title)\(atMs ?? 0)" } - } - - public enum Busy: Sendable { case none, saving, testing } - - public private(set) var boxEnabled = false - public private(set) var rules: [String: Bool] = [:] - public private(set) var availableKinds: [String] = [] - public private(set) var history: [Sent] = [] - public private(set) var oldBox = false - public private(set) var busy: Busy = .none - public private(set) var error: String? - public private(set) var testSent = false - - @ObservationIgnored private unowned let site: SiteModel - @ObservationIgnored private var doc: [JSON] = [] - @ObservationIgnored private var ask: LiveAsk? - @ObservationIgnored private var active = 0 - - public init(site: SiteModel) { - self.site = site - } - - public var canManage: Bool { site.canConfigure && site.hasPassthrough } - - public func activate() { - active += 1 - if ask == nil { - ask = LiveAsk(site: site, want: { [weak self] in - guard let self, self.active > 0, self.canManage, !self.oldBox else { return nil } - return "push-history" - }, ask: { [weak self] in - try await self?.loadHistory() - try await self?.loadRules() - }) - } - ask?.evaluate() - } - - public func deactivate() { - active = max(0, active - 1) - ask?.evaluate() - } - - private func apply(_ wire: JSON?) { - guard let events = wire?["events"]?.array else { return } - doc = events.filter { $0["type"]?.string != nil && $0["enabled"]?.bool != nil } - boxEnabled = wire?["enabled"]?.bool == true - availableKinds = doc.compactMap { $0["type"]?.string } - var next = [String: Bool]() - for k in Self.ruleKinds { next[k] = false } - for r in doc { if let t = r["type"]?.string, next[t] != nil { next[t] = r["enabled"]?.bool == true } } - rules = next - } - - func loadRules() async throws { - do { - apply(try await site.callBox(.get, "/api/notifications/rules")) - } catch let e as BoxAPIError where e.code == "E_UNKNOWN_OP" { - oldBox = true - } - } - - func loadHistory() async throws { - do { - let wire = try await site.callBox(.get, "/api/notifications/history") - history = (wire?["events"]?.array ?? []).map { Sent(title: $0["title"]?.string ?? "", atMs: $0["at_ms"]?.number) } - } catch let e as BoxAPIError where e.code == "E_UNKNOWN_OP" { - oldBox = true - } - } - - /// One PUT for the whole set, so one ceremony. Kinds the box's document - /// does not carry are not sent: an older box must not meet a kind it - /// would refuse by name. - @discardableResult - public func save(_ next: [String: Bool]) async -> Bool { - guard busy == .none else { return false } - busy = .saving - error = nil - testSent = false - defer { busy = .none } - do { - if doc.isEmpty { try await loadRules() } - guard !doc.isEmpty else { - error = "Your box didn't answer. Nothing has changed." - return false - } - let events: [JSON] = doc.compactMap { rule in - guard let t = rule["type"]?.string, Self.ruleKinds.contains(t), let on = next[t] else { return nil } - return rule.setting("enabled", .bool(on)) - } - apply(try await site.callBox(.put, "/api/notifications/rules", body: ["enabled": true, "events": .array(events)])) - return true - } catch { - fail(error) - return false - } - } - - /// One real push through the whole pipe, to the phones the box can reach. - public func sendTest() async { - guard busy == .none else { return } - busy = .testing - error = nil - testSent = false - defer { busy = .none } - do { - _ = try await site.callBox(.post, "/api/notifications/test") - testSent = true - } catch { - fail(error) - } - } - - private func fail(_ err: Error) { - if let e = err as? BoxAPIError, e.code == "E_UNKNOWN_OP" { oldBox = true } - error = (err as? BoxAPIError)?.help ?? "That didn't work. Nothing has changed." - } - - public func when(_ ms: Double?) -> String { - guard let ms, ms > 0 else { return "" } - let since = site.nowMs - ms - if since < 3_600_000 { return "\(max(1, Int((since / 60_000).rounded()))) min ago" } - if since < 86_400_000 { return "\(Int((since / 3_600_000).rounded())) h ago" } - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("dMMM") - return f.string(from: Date(timeIntervalSince1970: ms / 1000)) - } -} - -/// Ask the box to come back up: a recovery act, owner only, confirmed. -@Observable -@MainActor -public final class RestartModel { - public enum Stage: Sendable { case idle, confirming, restarting } - public var stage: Stage = .idle - public private(set) var error: String? - @ObservationIgnored private unowned let site: SiteModel - - public init(site: SiteModel) { - self.site = site - } - - public var canAsk: Bool { site.heardFromBox && site.canConfigure && site.hasPassthrough } - - public func restart() async { - guard stage != .restarting else { return } - error = nil - stage = .restarting - do { - _ = try await site.callBox(.post, "/api/restart") - } catch { - stage = .idle - self.error = (error as? BoxAPIError)?.help ?? "That didn't work. Nothing has changed." - } - } -} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift b/appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift deleted file mode 100644 index 2f055a5..0000000 --- a/appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift +++ /dev/null @@ -1,288 +0,0 @@ -import Foundation -import Observation - -/// What the house used, made, bought and sold, day by day, from -/// GET /api/energy/daily. Integer watt-hours throughout, rounded once at the -/// door, so the total over the chart is the sum of the bars under it. -@Observable -@MainActor -public final class EnergyModel: Activatable { - public enum Range: String, CaseIterable, Sendable, Identifiable { - case today, week = "7d", month = "30d" - public var id: String { rawValue } - public var label: String { self == .today ? "Today" : self == .week ? "7 days" : "30 days" } - public var title: String { self == .today ? "Today" : self == .week ? "Last 7 days" : "Last 30 days" } - public var days: Int { self == .today ? 1 : self == .week ? 7 : 30 } - } - - public struct Day: Equatable, Sendable, Identifiable { - public let day: String - public let loadWh: Double - public let pvWh: Double - public let importWh: Double - public let exportWh: Double - public let batChargedWh: Double - public let batDischargedWh: Double - public var id: String { day } - } - - public struct Totals: Equatable, Sendable { - public var loadWh: Double = 0 - public var pvWh: Double = 0 - public var importWh: Double = 0 - public var exportWh: Double = 0 - public var batChargedWh: Double = 0 - public var batDischargedWh: Double = 0 - } - - public private(set) var range: Range = .week - /// Oldest first, today last. - public private(set) var days: [Day] = [] - public private(set) var loading = false - /// Whether the box has answered for this period. A period not yet read - /// is not a period with nothing in it. - public private(set) var loaded = false - public private(set) var error: String? - - @ObservationIgnored private unowned let site: SiteModel - @ObservationIgnored private var ask: LiveAsk? - @ObservationIgnored private var active = 0 - @ObservationIgnored private var token = 0 - @ObservationIgnored private let calendar: Calendar - - public init(site: SiteModel, calendar: Calendar = .current) { - self.site = site - self.calendar = calendar - } - - public var totals: Totals { - days.reduce(into: Totals()) { t, d in - t.loadWh += d.loadWh; t.pvWh += d.pvWh; t.importWh += d.importWh - t.exportWh += d.exportWh; t.batChargedWh += d.batChargedWh; t.batDischargedWh += d.batDischargedWh - } - } - - /// "As much as", never "of": the sun and the kettle rarely coincide. - public var solarSharePct: Int? { - let t = totals - guard t.loadWh > 0, t.pvWh > 0 else { return nil } - return Int((t.pvWh / t.loadWh * 100).rounded()) - } - - public func activate() { - active += 1 - if ask == nil { - ask = LiveAsk(site: site, want: { [weak self] in - guard let self, self.active > 0, self.site.documentVisible else { return nil } - guard self.site.hasPassthrough else { - if !self.days.isEmpty { self.days = [] } - return nil - } - // Today's column fills all day, so the hour is in the name. - let now = Date(timeIntervalSince1970: self.site.nowMs / 1000) - return "energy \(self.range.rawValue) \(self.calendar.startOfDay(for: now).timeIntervalSince1970) \(self.calendar.component(.hour, from: now))" - }, ask: { [weak self] in try await self?.load() }) - } - ask?.evaluate() - } - - public func deactivate() { - active = max(0, active - 1) - ask?.evaluate() - } - - public func select(_ next: Range) { - guard next != range else { return } - token += 1 - range = next - days = [] - loaded = false - loading = false - error = nil - ask?.evaluate() - } - - func load() async throws { - token += 1 - let mine = token - loading = true - error = nil - defer { if mine == token { loading = false } } - do { - let wire = try await site.callBox(.get, "/api/energy/daily", query: ["days": String(range.days)]) - guard mine == token else { return } - days = (wire?["days"]?.array ?? []).map { row in - Day( - day: row["day"]?.string ?? "", - loadWh: EnergyFormat.wholeWh(row["load_wh"]?.number), - pvWh: EnergyFormat.wholeWh(row["pv_wh"]?.number), - importWh: EnergyFormat.wholeWh(row["import_wh"]?.number), - exportWh: EnergyFormat.wholeWh(row["export_wh"]?.number), - batChargedWh: EnergyFormat.wholeWh(row["bat_charged_wh"]?.number), - batDischargedWh: EnergyFormat.wholeWh(row["bat_discharged_wh"]?.number) - ) - } - loaded = true - } catch { - guard mine == token else { return } - if let e = error as? BoxAPIError, days.isEmpty { - self.error = e.help - } else { - self.error = "Not up to date — your box is out of reach" - } - throw error - } - } -} - -/// Power over time: cache first, box second, then the difference only. -@Observable -@MainActor -public final class HistoryModel: Activatable { - public enum Range: String, CaseIterable, Sendable, Identifiable { - case day = "24h", week = "7d", month = "30d", year = "1y" - public var id: String { rawValue } - public var label: String { - switch self { - case .day: return "24 h" - case .week: return "7 d" - case .month: return "30 d" - case .year: return "1 y" - } - } - var spanMs: Double { - switch self { - case .day: return 86_400_000 - case .week: return 7 * 86_400_000 - case .month: return 30 * 86_400_000 - case .year: return 365 * 86_400_000 - } - } - var res: Resolution { self == .year ? .hour : .fiveMinutes } - } - - /// Names from the registry, in the order they are drawn. - public static let series = ["grid_w", "pv_w", "battery_w", "load_w"] - static let maxPoints = 1500 - - public private(set) var range: Range = .day - public private(set) var frame: HistoryGeometry.Frame? - public private(set) var resActual: Resolution? - public private(set) var gaps: [HistGap] = [] - public private(set) var loading = false - public private(set) var loaded = false - public private(set) var error: String? - /// The sample under the finger, as an index into `frame`. - public var cursor: Int? - - @ObservationIgnored private unowned let site: SiteModel - @ObservationIgnored private let tiles: TileCache - @ObservationIgnored private var ask: LiveAsk? - @ObservationIgnored private var active = 0 - @ObservationIgnored private var token = 0 - - public init(site: SiteModel, tiles: TileCache) { - self.site = site - self.tiles = tiles - } - - public func activate() { - active += 1 - if ask == nil { - ask = LiveAsk(site: site, want: { [weak self] in - guard let self, self.active > 0, self.site.documentVisible else { return nil } - // The window ends now, so which five minutes "now" is belongs - // in the name; otherwise the right edge says "now" all day. - return "\(self.range.rawValue) \(Int(self.site.nowMs / 300_000))" - }, ask: { [weak self] in try await self?.load() }) - } - ask?.evaluate() - } - - public func deactivate() { - active = max(0, active - 1) - ask?.evaluate() - } - - public func select(_ next: Range) { - // The cursor is an index into this frame, and the same index in - // another range is another moment. - if next != range { cursor = nil } - range = next - ask?.evaluate() - } - - /// The time under the cursor. - public var cursorAtMs: Double? { - guard let frame, let cursor else { return nil } - return frame.time(at: cursor) - } - - /// The value under the cursor, or the latest. - public func value(_ name: String) -> Double? { - guard let frame, frame.points > 0 else { return nil } - return frame.value(name, at: cursor ?? frame.points - 1).map(Double.init) - } - - public var missingMs: Double { gaps.reduce(0) { $0 + ($1.toMs - $1.fromMs) } } - - func load() async throws { - token += 1 - let mine = token - let names = Self.series - let to = site.scheduler.nowMs - let from = to - range.spanMs - let plan = HistoryGeometry.plan(range.res, fromMs: from, toMs: to, maxPoints: Self.maxPoints) - let cached = tiles.get(plan.tiles.map(\.tileId)) - var have = cached - func show() { frame = HistoryGeometry.clip(HistoryGeometry.assemble(plan, names: names, tiles: have), fromMs: from, toMs: to) } - // Whatever is on disk goes up now. - if !cached.isEmpty { show() } - - loading = true - error = nil - defer { if mine == token { loading = false } } - do { - let end = try await site.history( - HistQuery(series: names, res: range.res, fromMs: from, toMs: to, have: cached.values.map { ($0.tileId, $0.etag) }, maxPoints: Self.maxPoints) - ) { [weak self] chunk in - guard let self, mine == self.token else { return } - have[chunk.tileId] = chunk - self.tiles.put(chunk) - } - guard mine == token else { return } - show() - resActual = end.resActual - gaps = end.gaps - loaded = true - tiles.flush(nowMs: to) - } catch { - guard mine == token else { return } - if !have.isEmpty { show() } - // Both sentences are about the wire, never about the house: an - // answer that did not arrive says nothing about what was recorded. - self.error = have.isEmpty ? "Nothing through yet — your box is out of reach" : "Not up to date — your box is out of reach" - throw error - } - } - - /// "5 min trend · 5 min box readings" or "One point every hour". - public var note: String { - if let error { return error } - if loading { return "Reading your box…" } - guard let frame, resActual != nil else { return "" } - let step = frame.stepMs - var text: String - if step < 3_600_000 { - text = "One point every \(Int((step / 60_000).rounded())) minutes, from your box" - } else { - let hours = Int((step / 3_600_000).rounded()) - text = "One point every \(hours == 1 ? "hour" : hours == 24 ? "day" : "\(hours) hours"), from your box" - } - if missingMs > 0 { - let m = missingMs - text += " · \(m < 3_600_000 ? "\(max(1, Int((m / 60_000).rounded()))) min" : "\(Int((m / 3_600_000).rounded())) h") not recorded" - } - return text - } -} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index a8b17ea..5e29750 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared import Observation /// The charger sheet. Everything on it is a fact the box served; the @@ -116,6 +117,7 @@ public final class LoadpointsModel: Activatable { loaded = true readAtMs = site.scheduler.nowMs error = nil + site.shareCars(points, fresh: true) } catch { guard mine == token else { return } if let e = error as? BoxAPIError, points.isEmpty { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index fa38b09..897f1b1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared import Observation /// A screen that is on screen. Models only ask the box for what someone can @@ -171,6 +172,7 @@ public final class ChargingWatch: Activatable { } points = next fresh = site.documentVisible && site.session.phase == .streaming + site.shareCars(next, fresh: fresh) expiry?.cancel() expiry = site.scheduler.after(15_000) { [weak self] in self?.fresh = false } } catch { @@ -245,6 +247,7 @@ public final class PriceModel: Activatable { guard mine == generation else { return } prices = wire fromMs = from + site.sharePrices(wire, fromMs: from) } catch { // A window already drawn stays drawn; today's prices are still // today's. Rethrown so the ask heals. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift index 7de035c..f17b706 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift @@ -7,7 +7,7 @@ import Observation @MainActor public final class PairModel { public enum Stage: Equatable, Sendable { - case intro, scanning, pairing, recovering, choosing, demoing + case intro, scanning, pairing, demoing } public var stage: Stage = .intro @@ -16,7 +16,6 @@ public final class PairModel { /// The box a link points at, by the same six characters the Box screen /// names a paired box by. public private(set) var offeredFingerprint: String? - public private(set) var recovered: [Escrow.Recovered] = [] /// This phone already holds a key and a home: something local went /// missing, not a stranger arriving. public private(set) var known: StoredSite? @@ -44,8 +43,8 @@ public final class PairModel { stage = .pairing message = nil do { - let result = try await app.pairing.pair(scanned: raw) - app.paired(result.site.siteId) + let site = try await app.pairing.pair(scanned: raw) + app.paired(site.siteId) } catch is PasskeyCancelled { // A dismissed sheet is not a fault and gets no error voice. stage = .intro @@ -69,55 +68,11 @@ public final class PairModel { offeredFingerprint = nil } - /// Ask the passkey what Sourceful is holding. Costs a prompt, so it is a - /// button and never a check on arrival. - public func recover() async { - stage = .recovering - message = nil - do { - recovered = try await app.escrow.recover(app.passkeys) - if recovered.isEmpty { - stage = .intro - message = "Nothing was saved for this passkey. Open Settings → FTW app in your box dashboard and scan a new pairing code instead." - return - } - stage = .choosing - } catch is PasskeyCancelled { - stage = .intro - } catch let e as HelpfulError { - stage = .intro - message = e.help - } catch { - stage = .intro - message = "That didn't work. Open Settings → FTW app in your box dashboard and scan a new pairing code instead." - } - } - - public func adopt(_ home: Escrow.Recovered) { - stage = .pairing - do { - let id = try app.escrow.adopt(home, nowMs: app.scheduler.nowMs) - app.paired(id) - } catch let e as HelpfulError { - stage = .intro - message = e.help - } catch { - stage = .intro - message = "That didn't work. Try scanning the code instead." - } - } - public func openKnown() { guard let known else { return } app.paired(known.siteId) } - /// A picture was read and held no pairing code. - public func noCodeFound() { - stage = .intro - message = "There is no FTW pairing code in that picture. Try a closer screenshot of the QR." - } - public func tryDemo() { stage = .demoing app.startDemo() diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift deleted file mode 100644 index 5f7077b..0000000 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift +++ /dev/null @@ -1,152 +0,0 @@ -import Foundation -import Observation - -/// The plan, and changing how the site is run. The plan lives on session -/// state because the box pushes a fresh one unasked after a mode change -/// made from any phone. -@Observable -@MainActor -public final class PlanModel: Activatable { - public enum Command: Equatable, Sendable { - case idle - case sending(String) - case applied(String) - case unconfirmed(String) - case failed(String) - } - - /// How long a settled outcome stays before the screen goes quiet. - static let settleMs: Double = 4_000 - - public private(set) var loading = false - /// A sentence, never a code. - public private(set) var problem: String? - public private(set) var command: Command = .idle - - @ObservationIgnored private unowned let site: SiteModel - @ObservationIgnored private var ask: LiveAsk? - @ObservationIgnored private var active = 0 - @ObservationIgnored private var want = 0 - @ObservationIgnored private var settle: Cancellable? - - public init(site: SiteModel) { - self.site = site - } - - public func activate() { - active += 1 - if ask == nil { - ask = LiveAsk(site: site, want: { [weak self] in - guard let self, self.active > 0 else { return nil } - return "plan \(self.want)" - }, ask: { [weak self] in try await self?.load() }) - } - ask?.evaluate() - } - - public func deactivate() { - active = max(0, active - 1) - ask?.evaluate() - } - - public var plan: Plan? { site.session.plan } - - /// Every mode the box accepts, in its order. Hidden ones are valid over - /// the API but never buttons. - public var modes: [ModeInfo] { site.session.modes.filter { $0.tier != "hidden" } } - public var primaryModes: [ModeInfo] { modes.filter { $0.tier == "primary" } } - public var advancedModes: [ModeInfo] { modes.filter { $0.tier == "advanced" } } - - /// The mode the box reports, which is the only one that counts. - public var actualMode: String? { - guard let i = site.session.fields[Contract.FID.mode].map({ Int($0) }), i >= 0, i < site.session.modes.count else { return nil } - return site.session.modes[i].key - } - - /// The pending choice while one is in flight, else the real one. A - /// refusal snaps back because this never overrides what the box said. - public var shownMode: String? { - switch command { - case .sending(let m), .applied(let m), .unconfirmed(let m): return m - default: return actualMode - } - } - - public var inManual: Bool { - guard let m = shownMode else { return false } - return advancedModes.contains { $0.key == m } - } - - /// The way back from a manual fallback: the box's first primary mode. - public var planHome: ModeInfo? { primaryModes.first } - - /// Both have to hold: the box offers dispatch, and this enrolment carries - /// the scope the box checks. Drawing a control a viewer will be refused - /// is the one thing this app must not do. - public var canControl: Bool { - site.session.caps.contains(Contract.capPlanDispatch) && site.session.scopes.contains(Contract.scopeModeWrite) - } - - public enum NoControl: Sendable { case box, role } - - /// Nil until the box has answered: before its hello, "this box can't do - /// that" would be a sentence about a box that has said nothing. - public var whyNoControl: NoControl? { - guard site.heardFromBox, !canControl else { return nil } - return site.session.scopes.contains(Contract.scopeModeWrite) ? .box : .role - } - - func load() async throws { - loading = true - problem = nil - defer { loading = false } - do { - _ = try await site.plan() - } catch { - problem = "Couldn't get the plan from your box. Still trying." - throw error - } - } - - /// Optimistic on screen, never in the model. - public func setMode(_ mode: String) async { - if case .sending = command { return } - if mode == shownMode { return } - settle?.cancel() - command = .sending(mode) - do { - let result = try await site.command(Contract.opSetMode, args: [("mode", .text(mode))]) - switch result.state { - case .applied: - command = .applied(mode) - followPlan() - case .unconfirmed: - command = .unconfirmed(mode) - default: - command = .failed(CommandText.help(result)) - } - } catch let e as CommandError { - command = .failed(e.help) - } catch { - command = .failed("That didn't go through. Try again.") - } - settle = site.scheduler.after(Self.settleMs) { [weak self] in self?.command = .idle } - } - - /// The box acknowledges a mode before its optimiser has replanned, so the - /// plan is asked for again every three seconds, for thirty at most, - /// until its revision moves. - private func followPlan() { - let before = plan?.rev - func step(_ n: Int) { - guard n < 10, plan?.rev == before else { return } - want += 1 - ask?.evaluate() - site.scheduler.after(3_000) { [weak self] in - guard self != nil else { return } - step(n + 1) - } - } - step(0) - } -} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift deleted file mode 100644 index 46dc3d1..0000000 --- a/appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import Observation - -/// The spare key on the Box screen: whether Sourceful holds a sealed copy of -/// this home. Written as the web app does it: mark the home, seal the set, -/// and put the mark back when the seal did not land, so the switch never -/// says something the escrow does not hold. -@Observable -@MainActor -public final class SealedCopyModel { - public enum Stage: Sendable { case idle, working, failed } - - public private(set) var kept: Bool - public private(set) var stage: Stage = .idle - /// A sentence, when the last try did not work. - public private(set) var problem: String? - - @ObservationIgnored private unowned let app: AppModel - @ObservationIgnored private let siteId: String - - init(app: AppModel, siteId: String) { - self.app = app - self.siteId = siteId - kept = app.sites.get(siteId)?.escrow == true - } - - /// Read the mark again. Pairing seals its copy in the background, so the - /// answer can land after this model was made. - public func reload() { - guard stage != .working else { return } - kept = app.sites.get(siteId)?.escrow == true - } - - public func set(_ on: Bool) async { - guard stage != .working else { return } - stage = .working - problem = nil - app.escrow.mark(siteId, on) - do { - let wrapping = try await app.vault.unlockWrappingKey(app.passkeys) - let outcome = await app.escrow.save(wrapping) - if outcome == .saved || outcome == .cleared { - kept = on - stage = .idle - return - } - app.escrow.mark(siteId, !on) - stage = .failed - switch outcome { - case .unsupported: problem = "This phone has no passkey that can seal a copy. Everything else works exactly as it does now." - case .unreachable: problem = "That didn't reach Sourceful. Your home works either way. Try again when you're back online." - default: problem = "That didn't work. Your home works either way. Try again." - } - } catch is PasskeyCancelled { - // A dismissed sheet is an answer, not a fault. - app.escrow.mark(siteId, !on) - stage = .idle - } catch { - app.escrow.mark(siteId, !on) - stage = .failed - problem = "That didn't work. Try again." - } - } -} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 9257af5..62eab3e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared import Observation /// The bridge between the session and the screens. @@ -36,6 +37,11 @@ public final class SiteModel { @ObservationIgnored let core: Session @ObservationIgnored let scheduler: Scheduler @ObservationIgnored private let files: SealedFiles? + /// What widgets read. Nil for the demo and in tests. + @ObservationIgnored private let shared: AppGroupFiles? + @ObservationIgnored private var lastCars: (entry: CarEntry, atMs: Double)? + /// Called after every write to `shared`, so the app can wake its widgets. + @ObservationIgnored public var sharedWritten: (@MainActor () -> Void)? @ObservationIgnored private var attemptStartedAtMs: Double @ObservationIgnored private var ticker: Cancellable? @ObservationIgnored private var resumeTimer: Cancellable? @@ -49,10 +55,11 @@ public final class SiteModel { /// Runs the passkey ceremony a write needs. Injected by the shell. @ObservationIgnored public var stepUp: (@MainActor () async -> StepUpOutcome)? - public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, isDemo: Bool = false) { + public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, shared: AppGroupFiles? = nil, isDemo: Bool = false) { self.siteId = siteId self.scheduler = scheduler self.files = files + self.shared = isDemo ? nil : shared self.isDemo = isDemo nowMs = scheduler.nowMs attemptStartedAtMs = scheduler.nowMs @@ -105,6 +112,18 @@ public final class SiteModel { listeners = [:] } + /// The app went to the background. Write what is on screen and close the + /// session: iOS freezes the app within seconds anyway, and an open socket + /// holds one of the four streams the relay allows a home. The readings + /// stay, and the band dates them until a new carrier streams again. + public func suspend() { + guard !destroyed else { return } + persistNow() + core.close() + // After the close, so no last change of rate goes out on the wire. + setVisible(false) + } + /// Match the stream's cadence to whether anyone can see it. public func setVisible(_ visible: Bool) { let was = documentVisible @@ -235,6 +254,25 @@ public final class SiteModel { public var connectionWaitMs: Double { max(0, nowMs - attemptStartedAtMs) } + /// The relay turned this phone away: the home has as many phones + /// watching as it serves. Said as such, never as a fault. + public var busy: Bool { session.busy } + + /// The band for this home, read the way the web app's shell reads it. + public func freshness(noCarrier: Bool) -> Freshness.Band { + Freshness.band( + carrier: carrier, + transport: session.carrier, + srcState: srcState, + ageMs: ageMs, + phase: session.phase, + waitMs: connectionWaitMs, + bootPct: session.boot?.pct, + noCarrier: noCarrier, + busy: busy + ) + } + /// The sources behind what the Now screen draws, named by the box. var nowSourceIDs: [String] { var ids = [String]() @@ -310,12 +348,6 @@ public final class SiteModel { // MARK: Requests, through this layer - public func history(_ q: HistQuery, onChunk: @escaping @MainActor (HistChunk) -> Void) async throws -> HistEnd { - try await core.history(q, onChunk: onChunk) - } - - public func plan() async throws -> Plan { try await core.plan() } - public func prices(fromMs: Double, toMs: Double) async throws -> Prices { try await core.prices(fromMs: fromMs, toMs: toMs) } @@ -327,4 +359,57 @@ public final class SiteModel { public func api(_ req: APIRequest) async throws -> APIResponse { try await core.api(req) } + + // MARK: What widgets read + + /// A rewrite of the same car is skipped for this long. The charger is + /// read every five seconds while the app is open; a widget does not need + /// each of those. + static let carRewriteMs: Double = 5 * 60_000 + + /// Leave today's and tomorrow's prices for the widgets. + func sharePrices(_ prices: Prices, fromMs: Double) { + guard let siteId, let shared else { return } + let record = CacheRecord( + siteId: siteId, + carrier: session.carrier, + srcState: prices.stale ? .stale : .live, + readAtMs: scheduler.nowMs, + value: PriceEntry(fromMs: fromMs, prices: prices) + ) + write(record, to: shared) + } + + /// Leave the chargers for the widgets, as the box just described them. + /// `fresh` says whether the box's own reading of the charger is current. + func shareCars(_ points: [Loadpoint], fresh: Bool) { + guard let siteId, let shared else { return } + let now = scheduler.nowMs + let entry = CarEntry(cars: points.map { lp in + CarEntry.Car( + loadpointID: lp.id, + pluggedIn: lp.pluggedIn, + socPct: lp.socPct, + socSource: lp.socSource, + socReadAtMs: lp.socReadAtMs(answeredAtMs: now), + powerW: lp.powerW, + targetSocPct: lp.targetSocPct + ) + }) + if let last = lastCars, last.entry.sameReading(entry), now - last.atMs < Self.carRewriteMs { return } + lastCars = (entry, now) + let answering = fresh && points.allSatisfy { $0.charger?.available != false } + write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared) + } + + private func write(_ record: CacheRecord, to shared: AppGroupFiles) { + guard !destroyed else { return } + do { + try shared.write(record) + sharedWritten?() + } catch { + // A widget showing yesterday's record with its date is better + // than no app; the next read tries again. + } + } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift index ce4a707..95a0929 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift @@ -81,58 +81,3 @@ public final class SealedFiles { return try? JSONDecoder().decode(T.self, from: Data(raw)) } } - -/// Closed history tiles, per home, so opening the chart paints at once and -/// the box sends only what changed. -@MainActor -public final class TileCache { - struct Tile: Codable { - var tileId: String - var etag: String - var res: String - var startMs: Double - var stepMs: Double - var series: [String] - var data: Data - } - - private let files: SealedFiles? - private let siteId: String - private var tiles: [String: Tile] - private var dirty = false - - public init(files: SealedFiles?, siteId: String) { - self.files = files - self.siteId = siteId - tiles = files?.readJSON([String: Tile].self, "tiles-\(siteId)") ?? [:] - } - - public func get(_ ids: [String]) -> [String: HistChunk] { - var out = [String: HistChunk]() - for id in ids { - guard let t = tiles[id] else { continue } - out[id] = HistChunk(tileId: t.tileId, etag: t.etag, res: Resolution(rawValue: t.res) ?? .fiveMinutes, startMs: t.startMs, stepMs: t.stepMs, series: t.series, data: t.data.byteArray, partial: false) - } - return out - } - - /// The trailing tile is still filling and never cached. - public func put(_ chunk: HistChunk) { - guard !chunk.partial else { return } - tiles[chunk.tileId] = Tile(tileId: chunk.tileId, etag: chunk.etag, res: chunk.res.rawValue, startMs: chunk.startMs, stepMs: chunk.stepMs, series: chunk.series, data: Data(chunk.data)) - dirty = true - } - - /// Drop tiles past each resolution's retention, then write. - public func flush(nowMs: Double) { - let before = tiles.count - tiles = tiles.filter { _, t in - let res = Resolution(rawValue: t.res) ?? .fiveMinutes - return t.startMs + HistoryGeometry.spec(res).tileSpanMs >= nowMs - HistoryGeometry.spec(res).retentionMs - } - if dirty || tiles.count != before { - files?.writeJSON("tiles-\(siteId)", tiles) - dirty = false - } - } -} diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift index c8e63fc..58b2fe7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift @@ -12,21 +12,18 @@ public struct StoredSite: Codable, Equatable, Sendable, Identifiable { /// Single use, spent in the first handshake. Kept until then. public var pairingCode: Data? public var lanHint: String? - /// Whether this household asked Sourceful to hold a sealed copy of it. - public var escrow: Bool public var addedAtMs: Double public var lastSeenAtMs: Double public var id: String { siteId } - public init(siteId: String, label: String, boxStaticKey: Data, rendezvousSecret: Data?, pairingCode: Data?, lanHint: String?, escrow: Bool, addedAtMs: Double, lastSeenAtMs: Double) { + public init(siteId: String, label: String, boxStaticKey: Data, rendezvousSecret: Data?, pairingCode: Data?, lanHint: String?, addedAtMs: Double, lastSeenAtMs: Double) { self.siteId = siteId self.label = label self.boxStaticKey = boxStaticKey self.rendezvousSecret = rendezvousSecret self.pairingCode = pairingCode self.lanHint = lanHint - self.escrow = escrow self.addedAtMs = addedAtMs self.lastSeenAtMs = lastSeenAtMs } diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift b/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift index c7b851d..70c82c8 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift @@ -231,3 +231,25 @@ extension JSON: ExpressibleByDictionaryLiteral, ExpressibleByArrayLiteral, Expre public init(booleanLiteral value: Bool) { self = .bool(value) } public init(nilLiteral: ()) { self = .null } } + +/// A JSON string literal. Only what JSON requires is escaped, which is what +/// JavaScript's JSON.stringify does for these ASCII values. +func jsonString(_ s: String) -> String { + var out = "\"" + for scalar in s.unicodeScalars { + switch scalar { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if scalar.value < 0x20 { + out += String(format: "\\u%04x", scalar.value) + } else { + out.unicodeScalars.append(scalar) + } + } + } + return out + "\"" +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift new file mode 100644 index 0000000..88f835b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -0,0 +1,228 @@ +import Foundation + +// What the app leaves on disk for its widgets, and later for a notification +// extension: one home's prices and its car, each with where it came from +// and when. Widgets read this and nothing else. They never open the relay +// and never touch a key. +// +// The app writes and widgets read. Both go through the same records, so a +// widget cannot draw a reading the app never had. + +/// The shape of something kept in the cache. +public protocol CacheValue: Codable, Sendable, Equatable { + /// The record lives at "-". + static var name: String { get } + /// Bumped when the shape changes. A record of another shape is not read. + static var version: Int { get } +} + +/// One cached reading of one home. +/// +/// Freshness is two fields here as everywhere. `carrier` is how the reading +/// reached the phone when it was taken, `srcState` whether the box's own +/// source was answering then. Read back, a record is always `.cache` +/// (`CacheRead.carrier`), so old data never looks live. +public struct CacheRecord: Codable, Sendable, Equatable { + public var version: Int + public var siteId: String + public var carrier: CarrierKind + public var srcState: SourceState + /// Wall clock on the phone when the reading arrived. + public var readAtMs: Double + public var value: Value + + public init(siteId: String, carrier: CarrierKind, srcState: SourceState, readAtMs: Double, value: Value) { + version = Value.version + self.siteId = siteId + self.carrier = carrier + self.srcState = srcState + self.readAtMs = readAtMs + self.value = value + } +} + +/// Which source a record came from. +public enum CacheOrigin: String, Sendable { + /// Written by the app, from its own session with the box. + case app + // A sealed copy of the box's state from a cloud mirror would be a second + // case here, opened by its own `CacheSource` into the same records. +} + +/// Somewhere records can come from. The App Group files are the only source +/// today. A cloud copy becomes one more source that decrypts into the same +/// `CacheRecord`, so nothing that reads the cache has to change. +public protocol CacheSource: Sendable { + var origin: CacheOrigin { get } + func read(_ type: Value.Type, siteId: String) -> CacheRecord? + /// Homes with a record of this kind, most recently written first. + func siteIDs(_ type: Value.Type) -> [String] +} + +/// A record as a reader hands it out. +public struct CacheRead: Sendable { + public let record: CacheRecord + public let origin: CacheOrigin + + public var value: Value { record.value } + /// Always `.cache`. A file on this phone is not a connection to the box. + public var carrier: CarrierKind { .cache } + /// As it was when the reading arrived. Never better. + public var srcState: SourceState { record.srcState } + + public func ageMs(nowMs: Double) -> Double { max(0, nowMs - record.readAtMs) } +} + +/// Reads the newest record any source holds. +public struct CacheReader: Sendable { + public let sources: [any CacheSource] + + public init(sources: [any CacheSource]) { + self.sources = sources + } + + /// The App Group files, when this process has the group. + public static func shared(bundle: Bundle = .main) -> CacheReader? { + AppGroupFiles.shared(bundle: bundle).map { CacheReader(sources: [$0]) } + } + + public func read(_ type: Value.Type, siteId: String) -> CacheRead? { + var best: CacheRead? + for source in sources { + guard let record = source.read(type, siteId: siteId) else { continue } + if best == nil || record.readAtMs > best!.record.readAtMs { + best = CacheRead(record: record, origin: source.origin) + } + } + return best + } + + /// The home most recently written: the one the app had open last. + public func latestSiteID(_ type: Value.Type) -> String? { + sources.lazy.compactMap { $0.siteIDs(type).first }.first + } +} + +/// Files in the App Group container, which the app and its widgets share. +/// +/// Not sealed: prices are not secret and the car record says no more than +/// the charger's own app. Files carry `completeUntilFirstUserAuthentication`, +/// so a widget can read them while the phone is locked, but not after a +/// restart before the first unlock. +public struct AppGroupFiles: CacheSource { + /// The Info.plist key that names the App Group. The build fills it from + /// the same setting as the entitlements, so Swift never spells the name. + public static let infoKey = "FTWAppGroup" + + public let directory: URL + public var origin: CacheOrigin { .app } + + public init(directory: URL) { + self.directory = directory + } + + /// The container named in `bundle`'s Info.plist. Nil when the process has + /// no such group: a test, or a build without the entitlement. + public static func shared(bundle: Bundle = .main) -> AppGroupFiles? { + guard let group = bundle.object(forInfoDictionaryKey: infoKey) as? String, !group.isEmpty, + let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else { return nil } + return AppGroupFiles(directory: container.appendingPathComponent("Library/Application Support/cache", isDirectory: true)) + } + + func url(_ name: String, _ siteId: String) -> URL { + directory.appendingPathComponent("\(name)-\(siteId)") + } + + public func read(_ type: Value.Type, siteId: String) -> CacheRecord? { + guard let data = try? Data(contentsOf: url(Value.name, siteId)), + let record = try? JSONDecoder().decode(CacheRecord.self, from: data), + record.version == Value.version, record.siteId == siteId else { return nil } + return record + } + + public func siteIDs(_ type: Value.Type) -> [String] { + let prefix = "\(Value.name)-" + let keys: Set = [.contentModificationDateKey] + let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: Array(keys))) ?? [] + return files + .filter { $0.lastPathComponent.hasPrefix(prefix) } + .map { (id: String($0.lastPathComponent.dropFirst(prefix.count)), at: (try? $0.resourceValues(forKeys: keys))?.contentModificationDate ?? .distantPast) } + .sorted { $0.at > $1.at } + .map(\.id) + } + + /// Only the app writes. Atomic, so a widget never reads half a file. + public func write(_ record: CacheRecord) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(record) + try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + /// Everything, for signing out. A widget then has nothing to draw. + public func removeAll() { + try? FileManager.default.removeItem(at: directory) + } +} + +// MARK: - What is kept + +/// One home's prices across today and tomorrow, as the box served them. +public struct PriceEntry: CacheValue { + public static let name = "prices" + public static let version = 1 + + /// Local midnight the window starts at. + public var fromMs: Double + public var prices: Prices + + public init(fromMs: Double, prices: Prices) { + self.fromMs = fromMs + self.prices = prices + } +} + +/// The cars on one home's chargers, as the box last described them. +public struct CarEntry: CacheValue { + public static let name = "car" + public static let version = 1 + + public struct Car: Codable, Sendable, Equatable { + public var loadpointID: String + public var pluggedIn: Bool + /// Whole percent. Nil when the box has no level for the car. + public var socPct: Double? + /// Where the level came from, in the box's words: vehicle, inferred, + /// assumed or completed. A level the box assumed is not a reading. + public var socSource: String + /// Wall clock of the level itself. For a level from the car, when + /// the car reported it, which can be well before the record. + public var socReadAtMs: Double? + /// Charger power, site convention: positive while charging. + public var powerW: Double + /// The goal the box holds, whole percent. + public var targetSocPct: Double? + + public init(loadpointID: String, pluggedIn: Bool, socPct: Double?, socSource: String, socReadAtMs: Double?, powerW: Double, targetSocPct: Double?) { + self.loadpointID = loadpointID + self.pluggedIn = pluggedIn + self.socPct = socPct + self.socSource = socSource + self.socReadAtMs = socReadAtMs + self.powerW = powerW + self.targetSocPct = targetSocPct + } + } + + public var cars: [Car] + + public init(cars: [Car]) { + self.cars = cars + } + + /// The same cars at the same levels, whenever each level was read. A + /// rewrite of that tells a widget nothing new. + public func sameReading(_ other: CarEntry) -> Bool { + func levels(_ e: CarEntry) -> [Car] { e.cars.map { var c = $0; c.socReadAtMs = nil; return c } } + return levels(self) == levels(other) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift similarity index 61% rename from appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift rename to appleApp/FTWKit/Sources/FTWShared/Freshness.swift index dda66dd..521b286 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift @@ -8,7 +8,7 @@ import Foundation /// never claims a problem it has not confirmed: connecting while the cache /// is on screen is normal, not a fault. public enum Freshness { - public enum Tone: Sendable { case live, stale, reaching, lost } + public enum Tone: Sendable { case live, stale, reaching, lost, busy } public struct Band: Equatable, Sendable { public let tone: Tone @@ -28,46 +28,51 @@ public enum Freshness { phase: SessionPhase, waitMs: Double, bootPct: Int?, - noCarrier: Bool + noCarrier: Bool, + busy: Bool = false ) -> Band { let reaching = !noCarrier && [.idle, .handshaking, .subscribing, .failed, .booting].contains(phase) let connected = carrier == .relay || carrier == .webrtc + // The relay turned this phone away because the home already has as + // many phones watching as it serves. Not a fault, and not ours to fix. + let turnedAway = busy && !connected && reaching - let tone: Tone = connected ? (srcState == .live ? .live : .stale) : (reaching ? .reaching : .lost) + let tone: Tone = connected ? (srcState == .live ? .live : .stale) : turnedAway ? .busy : (reaching ? .reaching : .lost) let message: String if connected { - let liveWhere = carrier == .webrtc ? "Live at home" : "Live via encrypted relay" - let link = carrier == .webrtc ? "Home link connected" : "Encrypted relay connected" + let webrtc = carrier == .webrtc switch srcState { - case .live: message = liveWhere - case .never: message = "\(link) · no reading yet" - case .down: message = "\(link) · a device stopped responding" - default: message = "\(link) · readings" + case .live: message = webrtc ? Words.text("Live at home") : Words.text("Live via encrypted relay") + case .never: message = webrtc ? Words.text("Home link connected · no reading yet") : Words.text("Encrypted relay connected · no reading yet") + case .down: message = webrtc ? Words.text("Home link connected · a device stopped responding") : Words.text("Encrypted relay connected · a device stopped responding") + default: message = webrtc ? Words.text("Home link connected · readings") : Words.text("Encrypted relay connected · readings") } } else if phase == .terminated { // The box is reachable; it told this phone to leave. - message = "Access ended" + message = Words.text("Access ended") + } else if turnedAway { + message = Words.text("FTW is busy") } else if phase == .booting { - message = "Your box is starting" + message = Words.text("Your box is starting") } else if reaching { if phase == .failed { - message = "Reconnecting to your box" + message = Words.text("Reconnecting to your box") } else if phase == .subscribing { - message = "Waiting for live readings" + message = Words.text("Waiting for live readings") } else if phase == .handshaking, transport == .relay { - message = "Securing encrypted relay" + message = Words.text("Securing encrypted relay") } else if phase == .handshaking, transport == .webrtc { - message = "Securing home link" + message = Words.text("Securing home link") } else { - message = "Connecting to your box" + message = Words.text("Connecting to your box") } } else { - message = "Can't reach your box" + message = Words.text("Can't reach your box") } var wait: String? - if reaching { + if reaching, !turnedAway { if phase == .booting, let bootPct { wait = "\(bootPct)%" } else { @@ -86,19 +91,3 @@ public enum Freshness { return Band(tone: tone, message: message, wait: wait, age: age) } } - -extension SiteModel { - /// The band for this home, read the way the web app's shell reads it. - public func freshness(noCarrier: Bool) -> Freshness.Band { - Freshness.band( - carrier: carrier, - transport: session.carrier, - srcState: srcState, - ageMs: ageMs, - phase: session.phase, - waitMs: connectionWaitMs, - bootPct: session.boot?.pct, - noCarrier: noCarrier - ) - } -} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Power.swift b/appleApp/FTWKit/Sources/FTWShared/Power.swift similarity index 92% rename from appleApp/FTWKit/Sources/FTWKit/Format/Power.swift rename to appleApp/FTWKit/Sources/FTWShared/Power.swift index bbf4bea..5afcc38 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/Power.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Power.swift @@ -67,15 +67,15 @@ public enum PowerFormat { /// How old a reading is, coarse on purpose: a number ticking up every /// second draws the eye to the staleness rather than the reading. public static func age(_ ms: Double?) -> String { - guard let ms, ms.isFinite, ms >= 0 else { return "unknown" } + guard let ms, ms.isFinite, ms >= 0 else { return Words.text("unknown") } let s = Int(ms / 1000) - if s < 5 { return "just now" } - if s < 60 { return "\(s)s ago" } + if s < 5 { return Words.text("just now") } + if s < 60 { return Words.format("%lds ago", s) } let m = s / 60 - if m < 60 { return "\(m) min ago" } + if m < 60 { return Words.format("%ld min ago", m) } let h = m / 60 - if h < 24 { return "\(h) h ago" } - return "\(h / 24) d ago" + if h < 24 { return Words.format("%ld h ago", h) } + return Words.format("%ld d ago", h / 24) } /// JavaScript's toFixed, which rounds half away from zero on the diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/PriceStrip.swift b/appleApp/FTWKit/Sources/FTWShared/PriceStrip.swift similarity index 100% rename from appleApp/FTWKit/Sources/FTWKit/Format/PriceStrip.swift rename to appleApp/FTWKit/Sources/FTWShared/PriceStrip.swift diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceUnits.swift b/appleApp/FTWKit/Sources/FTWShared/PriceUnits.swift new file mode 100644 index 0000000..60847ba --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceUnits.swift @@ -0,0 +1,50 @@ +import Foundation + +/// What to call the number on a price, from the box's price-units.js. Every +/// price is minor units per kWh; only the label and the decimals differ. +public enum PriceUnits { + public struct Unit: Sendable { + public let label: String + public let perKwh: String + public let scale: Double + public let decimals: Int + } + + static let units: [String: Unit] = [ + "SEK": Unit(label: "öre", perKwh: "öre/kWh", scale: 1, decimals: 1), + "NOK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), + "DKK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), + "EUR": Unit(label: "cent", perKwh: "cent/kWh", scale: 1, decimals: 1), + "PLN": Unit(label: "gr", perKwh: "gr/kWh", scale: 1, decimals: 1), + "CHF": Unit(label: "Rp.", perKwh: "Rp./kWh", scale: 1, decimals: 1), + "CZK": Unit(label: "Kč", perKwh: "Kč/kWh", scale: 0.01, decimals: 2), + "HUF": Unit(label: "Ft", perKwh: "Ft/kWh", scale: 0.01, decimals: 1), + "RON": Unit(label: "lei", perKwh: "lei/kWh", scale: 0.01, decimals: 2), + ] + + public static func unit(_ currency: String?) -> Unit { + let code = (currency ?? "SEK").isEmpty ? "SEK" : (currency ?? "SEK").uppercased() + return units[code] ?? Unit(label: code, perKwh: "\(code)/kWh", scale: 0.01, decimals: 3) + } + + public static func display(_ minor: Double, _ currency: String?) -> Double { + minor * unit(currency).scale + } + + /// "17.4", in the chart's unit and precision. + public static func text(_ minor: Double?, _ currency: String?) -> String? { + guard let minor, minor.isFinite else { return nil } + return PowerFormat.fixed(display(minor, currency), unit(currency).decimals) + } + + /// Whether a price window misses hours rather than ending early: a head + /// that starts after the window asked for, or a hole in the middle. + public static func hasHole(_ slots: [PriceSlot], fromMs: Double) -> Bool { + guard let first = slots.first else { return false } + if first.startMs > fromMs { return true } + for i in slots.indices.dropFirst() where slots[i - 1].startMs + slots[i - 1].durationMs < slots[i].startMs { + return true + } + return false + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Prices.swift b/appleApp/FTWKit/Sources/FTWShared/Prices.swift new file mode 100644 index 0000000..20bbd9f --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Prices.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct PriceSlot: Equatable, Sendable, Identifiable, Codable { + public var startMs: Double + public var durationMs: Double + /// Integer minor units per kWh. Money never crosses as a float. + public var spotMinor: Double + /// What the household pays, tariff and tax included, computed by the box. + public var totalMinor: Double + public var id: Double { startMs } + + public init(startMs: Double, durationMs: Double, spotMinor: Double, totalMinor: Double) { + self.startMs = startMs + self.durationMs = durationMs + self.spotMinor = spotMinor + self.totalMinor = totalMinor + } +} + +public struct Prices: Equatable, Sendable, Codable { + public var zone: String + public var currency: String + public var slots: [PriceSlot] + /// The answer does not cover the window asked for. Which of the three + /// shapes it is has to be read off the slots. + public var stale: Bool + + public init(zone: String, currency: String, slots: [PriceSlot], stale: Bool) { + self.zone = zone + self.currency = currency + self.slots = slots + self.stale = stale + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Readings.swift b/appleApp/FTWKit/Sources/FTWShared/Readings.swift new file mode 100644 index 0000000..7099d3b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Readings.swift @@ -0,0 +1,36 @@ +import Foundation + +// FTWShared is what the app shares with its widgets, and later with a +// notification extension. Every type here is a plain `Sendable` value with +// no main actor, so an extension can read it without the app's session. +// +// The names below are the registry's `carrier_states` and `source_states`, +// checked by `ContractTests`. + +/// How frames are reaching us. Orthogonal to `SourceState`, never merged +/// with it: "connected, but the inverter went quiet 40 seconds ago" needs +/// both. +public enum CarrierKind: String, Sendable, Codable { + case webrtc, relay, cache, none +} + +public enum SourceState: String, Sendable, Codable, Comparable { + case live, lagging, stale, down, never + + var rank: Int { + switch self { + case .live: return 0 + case .lagging: return 1 + case .stale: return 2 + case .down: return 3 + case .never: return 4 + } + } + + public static func < (a: SourceState, b: SourceState) -> Bool { a.rank < b.rank } +} + +/// Where the session is between a carrier and a stream. +public enum SessionPhase: String, Sendable { + case idle, handshaking, subscribing, streaming, booting, terminated, failed +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..f430f6d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* English is the key in Words.text, so this table stays empty. It exists so + the bundle lists English as a language of its own. */ diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings new file mode 100644 index 0000000..8d2de29 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -0,0 +1,28 @@ +/* Swedish for FTWShared. The key is the English sentence. */ + +/* Freshness band */ +"Live at home" = "Live hemma"; +"Live via encrypted relay" = "Live via krypterat relä"; +"Home link connected · no reading yet" = "Hemlänk ansluten · inget mätvärde än"; +"Home link connected · a device stopped responding" = "Hemlänk ansluten · en enhet svarar inte"; +"Home link connected · readings" = "Hemlänk ansluten · mätvärden"; +"Encrypted relay connected · no reading yet" = "Krypterat relä anslutet · inget mätvärde än"; +"Encrypted relay connected · a device stopped responding" = "Krypterat relä anslutet · en enhet svarar inte"; +"Encrypted relay connected · readings" = "Krypterat relä anslutet · mätvärden"; +"Access ended" = "Åtkomsten är avslutad"; +"FTW is busy" = "FTW är upptaget"; +"Your box is starting" = "Din box startar"; +"Reconnecting to your box" = "Ansluter till din box igen"; +"Waiting for live readings" = "Väntar på mätvärden"; +"Securing encrypted relay" = "Krypterar anslutningen"; +"Securing home link" = "Krypterar hemlänken"; +"Connecting to your box" = "Ansluter till din box"; +"Can't reach your box" = "Når inte din box"; + +/* Age of a reading */ +"unknown" = "okänt"; +"just now" = "nyss"; +"%lds ago" = "för %ld s sedan"; +"%ld min ago" = "för %ld min sedan"; +"%ld h ago" = "för %ld h sedan"; +"%ld d ago" = "för %ld d sedan"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Words.swift b/appleApp/FTWKit/Sources/FTWShared/Words.swift new file mode 100644 index 0000000..4e011c6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Words.swift @@ -0,0 +1,17 @@ +import Foundation + +/// The words FTWShared puts on screen, in English or Swedish. +/// +/// The English sentence is the key, so a missing translation shows English +/// rather than a code. Swedish lives in `Resources/sv.lproj`. The app and +/// the widget list both languages in `CFBundleLocalizations`; without that, +/// iOS shows a package in the app's development language only. +enum Words { + static func text(_ key: String) -> String { + Bundle.module.localizedString(forKey: key, value: key, table: nil) + } + + static func format(_ key: String, _ args: CVarArg...) -> String { + String(format: text(key), arguments: args) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index e176f19..5d177e7 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing @testable import FTWKit +import FTWDemo @MainActor @Suite struct DemoHomeTests { @@ -8,7 +9,7 @@ import Testing /// passkey, no relay and nothing written. func demo() async -> (AppModel, HomeModels, ManualScheduler) { let scheduler = ManualScheduler() - let app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test") + let app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: SimulatedBox.loopback) app.startDemo() let home = app.home! await run(scheduler, 1_000) @@ -36,43 +37,13 @@ import Testing #expect(home.site.recentField(Contract.FID.gridW).count >= 2) } - @Test func thePlanLoadsAndAModeChangeFollowsIt() async { - let (_, home, s) = await demo() - home.plan.activate() - await run(s, 500) - #expect(home.plan.plan?.slots.count == 96) - #expect(home.plan.canControl) - #expect(home.plan.primaryModes.map(\.key) == ["planner_passive_arbitrage", "planner_arbitrage"]) - #expect(home.plan.actualMode == "planner_passive_arbitrage") - let task = Task { await home.plan.setMode("self_consumption") } - await run(s, 500) - await task.value - #expect(home.plan.command == .applied("self_consumption")) - await run(s, 1_500) - #expect(home.plan.actualMode == "self_consumption") - #expect(home.plan.inManual) - await run(s, PlanModel.settleMs + 100) - #expect(home.plan.command == .idle) - } - - @Test func pricesEnergyHistoryAndSavingsFill() async { + @Test func pricesSavingsAndStatusFill() async { let (_, home, s) = await demo() home.prices.activate() - home.energy.activate() - home.history.activate() home.savings.activate() home.status.activate() await run(s, 3_000) #expect(home.prices.prices?.currency == "SEK") - #expect(home.energy.loaded) - #expect(home.energy.days.count == 7) - #expect(home.energy.totals.loadWh > 0) - home.energy.select(.month) - #expect(!home.energy.loaded) - await run(s, 1_000) - #expect(home.energy.days.count == 30) - #expect(home.history.loaded) - #expect((home.history.frame?.points ?? 0) > 200) #expect(home.savings.periods?.week.available == true) #expect(home.status.fresh) #expect(Flow.fuse(status: home.status.status!) != nil) @@ -107,31 +78,36 @@ import Testing #expect(home.loadpoints.points.first?.schedule?.socPct == 90) } - @Test func theBoxScreenReadsTheRosterAndInvitesAViewer() async { - let (_, home, s) = await demo() - home.access.activate() - home.notify.activate() - await run(s, 1_000) - #expect(home.access.loaded) - #expect(home.access.members.first?.isThisPhone == true) - let invite = Task { await home.access.inviteViewer() } - await run(s, 500) - #expect(await invite.value) - #expect(home.access.invite?.role == Contract.roleViewer) - #expect(home.notify.availableKinds.count == 6) - let save = Task { await home.notify.save(["charging.connected": true]) } - await run(s, 500) - #expect(await save.value) - #expect(home.notify.rules["charging.connected"] == true) - #expect(home.notify.boxEnabled) - } - @Test func leavingTheDemoTouchesNoSavedHome() async { let (app, _, _) = await demo() app.exitDemo() #expect(app.home == nil) #expect(!app.isDemo) } + + /// A build without FTWDemo has no simulated home to open. + @Test func noDemoWithoutTheDemoModule() { + let app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: ManualScheduler(), build: "test", ua: "test") + #expect(!app.canDemo) + app.startDemo() + #expect(app.home == nil) + #expect(!app.isDemo) + } + + /// The demo writes nothing a widget could mistake for a home. + @Test func theDemoLeavesNothingForWidgets() async throws { + let scheduler = ManualScheduler() + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)")) + let app = AppModel(store: MemoryStore(), files: nil, shared: shared, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: SimulatedBox.loopback) + app.startDemo() + let home = try #require(app.home) + home.prices.activate() + home.charging.activate() + await run(scheduler, 7_000) + #expect(home.prices.prices != nil) + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(shared.siteIDs(CarEntry.self).isEmpty) + } } @MainActor @@ -144,8 +120,9 @@ import Testing let box: SimulatedBox let endpoint: NoiseBoxEndpoint let relay: FakeRelay - let escrow = FakeEscrowService() let passkeys = FakePasskeys() + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)")) + let widgetWakes = Counter() init() { files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-test-\(UUID().uuidString)"), store: store) @@ -155,7 +132,7 @@ import Testing } func app() -> AppModel { - AppModel(store: store, files: files, passkeys: passkeys, scheduler: scheduler, build: "test", ua: "test", relayURL: URL(string: "wss://relay.test")!, escrowTransport: escrow.transport, makeSocket: relay.factory) + AppModel(store: store, files: files, shared: shared, sharedWritten: { [widgetWakes] in widgetWakes.count += 1 }, passkeys: passkeys, scheduler: scheduler, build: "test", ua: "test", relayURL: URL(string: "wss://relay.test")!, makeSocket: relay.factory) } var pairingURL: String { @@ -187,9 +164,6 @@ import Testing #expect(site.session.phase == .streaming) #expect(site.carrier == .relay) #expect(world.passkeys.registrations == 1) - // Held a sealed copy in the background. - await world.run(200) - #expect(world.escrow.rows.count == 1) // Long enough for the snapshot to be written. await world.run(SiteModel.snapshotIntervalMs + 500, step: 250) @@ -226,22 +200,18 @@ import Testing #expect(PairModel(app: app).offeredFingerprint != nil) } - @Test func aConfigureWriteRunsTheCeremonyOnce() async throws { + @Test func aConfigureWriteGoesThrough() async throws { let world = World() let app = world.app() let pair = PairModel(app: app) let t = Task { await pair.pair(world.pairingURL) } await world.run(500) await t.value - // The simulated box refuses configure writes without the flag. - let strict = SimulatedBox(scheduler: world.scheduler, requireStepUp: true) - _ = strict let site = try #require(app.site) - let restart = RestartModel(site: site) - let task = Task { await restart.restart() } + let task = Task { try await site.callBox(.put, "/api/loadpoints/garage/schedule", body: ["soc": .number(0.7), "time_of_day_min_utc": 300, "recurring": true, "days": 0, "surplus_unlock_bat_soc": 0]) } await world.run(300) - await task.value - #expect(restart.error == nil) + _ = try await task.value + #expect(world.box.house.schedule?.socFraction == 0.7) } @Test func noKeyIsSaidPlainlyAndNothingRetries() async throws { @@ -272,60 +242,78 @@ import Testing #expect(!app.vault.isEnrolled) #expect(app.sites.all().isEmpty) #expect(world.store.keys.isEmpty) - // The sealed copy stays: removing it is its own act. - #expect(world.escrow.rows.count == 1) + // The widgets lose their records and are told. + #expect(world.shared.siteIDs(PriceEntry.self).isEmpty) + #expect(world.widgetWakes.count > 0) } -} -@MainActor -@Suite struct SealedCopyTests { - @Test func theSwitchFollowsWhatTheEscrowHolds() async throws { - let world = ShellTests.World() + /// iOS freezes a backgrounded app within seconds. The session closes + /// first, freeing one of the relay's four streams, and a new one opens + /// on return without a passkey. + @Test func theBackgroundClosesTheSessionAndTheForegroundReopensIt() async throws { + let world = World() let app = world.app() let pair = PairModel(app: app) let t = Task { await pair.pair(world.pairingURL) } await world.run(500) await t.value - await world.run(200) - let copy = try #require(app.sealedCopy) - // Pairing sealed a copy in the background; the screen reads it again. - copy.reload() - #expect(copy.kept) - #expect(world.escrow.rows.values.first?.blob.isEmpty == false) - - let off = Task { await copy.set(false) } - await world.run(200) - await off.value - #expect(!copy.kept) - #expect(copy.stage == .idle) - #expect(app.sites.all().first?.escrow == false) - #expect(world.escrow.rows.values.first?.blob.isEmpty == true) - - let on = Task { await copy.set(true) } - await world.run(200) - await on.value - #expect(copy.kept) - #expect(app.sites.all().first?.escrow == true) - #expect(world.escrow.rows.values.first?.blob.isEmpty == false) + let site = try #require(app.site) + #expect(site.session.phase == .streaming) + + app.setVisible(false) + app.suspend() + await world.run(100) + #expect(!site.core.hasCarrier) + #expect(world.relay.current == nil) + #expect(site.carrier == .none) + // What was on screen stays, dated. + #expect(site.session.fields[Contract.FID.gridW] != nil) + #expect(!site.isLive) + + app.setVisible(true) + await world.run(1_000) + #expect(site.session.phase == .streaming) + #expect(site.isLive || site.srcState == .live) + #expect(world.relay.dialled.count == 2) + #expect(world.passkeys.assertions.isEmpty) } - @Test func aDeclinedPasskeyPutsTheMarkBack() async throws { - let world = ShellTests.World() + /// Widgets read what the app leaves in the App Group, never the relay. + @Test func widgetsGetThePricesAndTheCar() async throws { + let world = World() let app = world.app() let pair = PairModel(app: app) let t = Task { await pair.pair(world.pairingURL) } await world.run(500) await t.value - await world.run(200) - let copy = try #require(app.sealedCopy) - copy.reload() - world.passkeys.cancel = true - let off = Task { await copy.set(false) } - await world.run(200) - await off.value - #expect(copy.kept) - #expect(copy.stage == .idle) - #expect(copy.problem == nil) - #expect(app.sites.all().first?.escrow == true) + let home = try #require(app.home) + let siteId = try #require(home.site.siteId) + home.prices.activate() + home.charging.activate() + await world.run(6_000, step: 100) + + let reader = CacheReader(sources: [world.shared]) + #expect(reader.latestSiteID(PriceEntry.self) == siteId) + let prices = try #require(reader.read(PriceEntry.self, siteId: siteId)) + #expect(prices.value.prices.currency == "SEK") + #expect(!prices.value.prices.slots.isEmpty) + // It arrived over the relay, and reads back as the cache. + #expect(prices.record.carrier == .relay) + #expect(prices.carrier == .cache) + let car = try #require(reader.read(CarEntry.self, siteId: siteId)?.value.cars.first) + #expect(car.loadpointID == "garage") + #expect(car.socSource == world.box.house.carSocSource) + #expect(car.socReadAtMs != nil) + #expect(world.widgetWakes.count >= 2) + + // The same car five seconds later is not written again. + let wakes = world.widgetWakes.count + await world.run(6_000, step: 100) + #expect(world.widgetWakes.count == wakes) } } + +@MainActor +final class Counter { + var count = 0 +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift new file mode 100644 index 0000000..f9b3f6e --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +/// The registry is one file shared with the box and the web app. Every name +/// this app spells has to be the registry's. `Contract` is written by hand, +/// the way the web app writes its own, and this is what keeps it honest. +@Suite struct ContractTests { + let yaml: String + + init() throws { + yaml = try RepoFiles.read("protocol/registry.yaml") + } + + /// Lines of one top-level section, without comments. + func section(_ name: String) -> [Substring] { + var out = [Substring]() + var inside = false + for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("\(name):") { inside = true; continue } + if inside, let first = line.first, !first.isWhitespace, first != "#" { break } + if inside, !line.trimmingCharacters(in: .whitespaces).hasPrefix("#") { out.append(line) } + } + return out + } + + /// The value after `key: ` on every line of a section that has one. + func values(_ section: String, _ key: String) -> [String] { + self.section(section).compactMap { line in + guard let range = line.range(of: "\(key): ") else { return nil } + return String(line[range.upperBound...].prefix { $0 != "," && $0 != "}" && $0 != " " }) + } + } + + func list(_ section: String) -> [String] { + self.section(section).compactMap { line in + let t = line.trimmingCharacters(in: .whitespaces) + return t.hasPrefix("- ") ? String(t.dropFirst(2)) : nil + } + } + + @Test func namesMatchTheSharedRegistry() { + #expect(values("scopes", "name") == Contract.scopes) + #expect(values("ops", "name") == Contract.ops) + #expect(list("capabilities") == Contract.capabilities) + #expect(values("modes", "key") == Contract.modes.map(\.key)) + #expect(values("modes", "tier") == Contract.modes.map(\.tier)) + let errors = values("errors", "code") + values("client_errors", "code") + #expect(Set(errors) == Set(Contract.retryable.keys)) + for code in errors { + let line = yaml.split(separator: "\n").first { $0.contains("code: \(code),") }! + #expect(line.contains("retryable: \(Contract.isRetryable(code))"), "\(code)") + } + #expect(yaml.contains("source_states: [\(Contract.sourceStates.joined(separator: ", "))]")) + #expect(yaml.contains("carrier_states: [\(Contract.carrierStates.joined(separator: ", "))]")) + } + + /// FTWShared spells the same states as enums, for widgets that never + /// link FTWKit. + @Test func sharedStatesAreTheRegistrys() { + #expect([CarrierKind.webrtc, .relay, .cache, .none].map(\.rawValue) == Contract.carrierStates) + #expect([SourceState.live, .lagging, .stale, .down, .never].map(\.rawValue) == Contract.sourceStates) + #expect(Contract.sourceStates.allSatisfy { SourceState(rawValue: $0) != nil }) + #expect(Contract.carrierStates.allSatisfy { CarrierKind(rawValue: $0) != nil }) + } + + @Test func frozenFieldIDsAreTheRegistrys() { + typealias F = Contract.FID + let ids: [(Int, String)] = [ + (F.mode, "mode"), (F.gridW, "grid_w"), (F.pvW, "pv_w"), (F.batteryW, "battery_w"), + (F.batterySoc, "battery_soc"), (F.loadW, "load_w"), (F.srcGrid, "src_grid"), + (F.srcPV, "src_pv"), (F.srcBattery, "src_battery"), (F.evW, "ev_w"), + ] + let frozen = section("frozen_fields").filter { $0.contains("{ name:") } + #expect(frozen.count == ids.count) + for (id, name) in ids { + #expect(frozen.contains { $0.trimmingCharacters(in: .whitespaces).hasPrefix("\(id): { name: \(name),") }, "\(id) \(name)") + } + } + + /// The demo box is not the box, but it may not invent the box's names. + @Test func theDemoBoxSpeaksOnlyRegistryNames() { + let modes = Set(Contract.modes.map(\.key)) + #expect(SimulatedBox.modes.allSatisfy { modes.contains($0.key) }) + #expect(SimulatedBox.modes.allSatisfy { m in Contract.modes.first { $0.key == m.key }?.tier == m.tier }) + #expect(Set(SimulatedBox.caps).isSubset(of: Contract.capabilities)) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift b/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift index bdbee2d..5040eb3 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift @@ -34,50 +34,3 @@ final class FakePasskeys: PasskeyAuthenticator { return PasskeyOutcome(credentialID: credentialID, prfOutput: prf) } } - -/// The escrow service's rules, from escrow/src in the web app repository: -/// one length for every request, a signed write whose key is pinned on first -/// use, and a version that must be the immediate successor. -@MainActor -final class FakeEscrowService { - struct Row { - var version: UInt32 - var blob: Bytes - var writeKey: Bytes - } - - var rows: [String: Row] = [:] - var offline = false - private(set) var requests = 0 - - var transport: Escrow.Transport { - { [unowned self] body in try self.handle(body) } - } - - func handle(_ body: Bytes) throws -> (status: Int, body: Bytes) { - requests += 1 - if offline { throw URLError(.notConnectedToInternet) } - guard body.count == Escrow.requestBytes, - let json = try JSONSerialization.jsonObject(with: Data(body)) as? [String: Any], - let id = json["id"] as? String else { return (400, []) } - switch json["op"] as? String { - case "get": - guard let row = rows[id] else { return (404, []) } - let answer: [String: Any] = ["version": row.version, "blob": Data(row.blob).base64EncodedString()] - return (200, try JSONSerialization.data(withJSONObject: answer).byteArray) - case "put": - guard let version = (json["version"] as? NSNumber)?.uint32Value, - let blob = (json["blob"] as? String).flatMap({ Data(base64Encoded: $0) })?.byteArray, - blob.count == RecoveryBlob.maxBytes || blob.isEmpty, - let pub = (json["pub"] as? String).flatMap({ try? Base64url.decode($0) }), - let sig = (json["sig"] as? String).flatMap({ try? Base64url.decode($0) }) else { return (400, []) } - guard Primitives.ed25519Verify(publicKey: pub, signature: sig, message: Escrow.writeMessage(lookupID: id, version: version, blob: blob)) else { return (403, []) } - if let held = rows[id], held.writeKey != pub { return (403, []) } - guard version == (rows[id]?.version ?? 0) + 1 else { return (409, []) } - rows[id] = Row(version: version, blob: blob, writeKey: pub) - return (200, Array(#"{"version":\#(version)}"#.utf8)) - default: - return (400, []) - } - } -} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift index 4cd43f3..e4fff59 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift @@ -7,29 +7,6 @@ import Testing @Suite struct FormatTests { typealias F = Contract.FID - @Test func powerNeverShowsARawMinus() { - for w in [-1_000_000.0, -4200, -60, 0, 60, 4200] { - #expect(!PowerFormat.parts(w).text.hasPrefix("-")) - } - #expect(PowerFormat.parts(4200).direction == .into) - #expect(PowerFormat.parts(-4200).direction == .out) - #expect(PowerFormat.direction(PowerFormat.noiseW - 1) == .idle) - #expect(PowerFormat.direction(-PowerFormat.noiseW) == .out) - #expect(PowerFormat.parts(9900).text == "9.9") - #expect(PowerFormat.parts(11_400).text == "11") - #expect(PowerFormat.scale(5000) == "5 kW") - #expect(PowerFormat.scale(1500) == "1.5 kW") - #expect(PowerFormat.scale(-5000) == PowerFormat.scale(5000)) - #expect(PowerFormat.scale(.nan) == "") - #expect(PowerFormat.soc(875) == "88") - #expect(PowerFormat.soc(.nan) == "—") - #expect(PowerFormat.age(0) == "just now") - #expect(PowerFormat.age(30_000) == "30s ago") - #expect(PowerFormat.age(90_000) == "1 min ago") - #expect(PowerFormat.age(3_600_000) == "1 h ago") - #expect(PowerFormat.age(nil) == "unknown") - } - @Test func explanationsMatchTheWebApp() { func explain(_ f: [Int: Double], blocked: [String] = [], ceiling: Double? = nil) -> Explanation.Result { Explanation.explain(fields: f, dispatchBlockedBy: blocked, ceilingW: ceiling) @@ -50,31 +27,6 @@ import Testing #expect(!explain([F.gridW: -3000, F.pvW: -5000, F.batteryW: -100, F.loadW: 2000]).headline.contains("-")) } - @Test func planHeadlineNamesTheNextChange() { - let now = 1_750_000_000_000.0 - let slots = [ - PlanSlot(startMs: now - 60_000, durationMs: 900_000, batteryW: 3000, gridW: 3000, priceMinor: 30, reason: "cheap_import"), - PlanSlot(startMs: now + 840_000, durationMs: 900_000, batteryW: 3000, gridW: 3000, priceMinor: 30, reason: "cheap_import"), - PlanSlot(startMs: now + 1_740_000, durationMs: 900_000, batteryW: -2000, gridW: 0, priceMinor: 150, reason: "expensive_import"), - ] - let h = PlanText.headline(Plan(rev: 1, uptimeMs: 0, slots: slots, stale: false, ceilingW: nil), nowMs: now) - #expect(h.text == "The battery is charging at 3.0 kW — power is cheap. Then it covers the house at 2.0 kW in about half an hour.") - #expect(PlanText.headline(nil, nowMs: now).text == "No plan yet.") - #expect(PlanText.headline(Plan(rev: 1, uptimeMs: 0, slots: [], stale: true, ceilingW: nil), nowMs: now).text.hasPrefix("Your box couldn't plan ahead")) - } - - @Test func pricesInTheChartsUnit() { - #expect(PriceUnits.text(144, "SEK") == "144.0") - #expect(PriceUnits.unit("EUR").perKwh == "cent/kWh") - #expect(PriceUnits.text(400, "CZK") == "4.00") - #expect(PriceUnits.unit("XYZ").perKwh == "XYZ/kWh") - let day: Double = 1_750_000_000_000 - let slots = [PriceSlot(startMs: day, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1), PriceSlot(startMs: day + 7_200_000, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1)] - #expect(PriceUnits.hasHole(slots, fromMs: day)) - #expect(PriceUnits.hasHole(Array(slots.suffix(1)), fromMs: day)) - #expect(!PriceUnits.hasHole(Array(slots.prefix(1)), fromMs: day)) - } - @Test func savingsAreSignedMajorUnits() { #expect(Savings.compact(1240) == "+12.4") #expect(Savings.compact(-310) == "−3.10") @@ -109,6 +61,17 @@ import Testing #expect(EVText.days(0b0000101) == "Mon, Wed") } + /// A level from the car is as old as the car's report. Any other level + /// is the box's account as of its answer. + @Test func theCarsLevelCarriesWhenItWasRead() throws { + let fromCar = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"vehicle","vehicle_soc_age_s":120}"#.utf8))) + #expect(fromCar.socReadAtMs(answeredAtMs: 1_000_000) == 880_000) + let assumed = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_soc":0.2,"soc_source":"assumed"}"#.utf8))) + #expect(assumed.socReadAtMs(answeredAtMs: 1_000_000) == 1_000_000) + let unplugged = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":false,"current_soc":0.6}"#.utf8))) + #expect(unplugged.socReadAtMs(answeredAtMs: 1_000_000) == nil) + } + @Test func chargeCurrentFallsBackLikeTheBoxPage() throws { let bare = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true}"#.utf8))) #expect(EVText.current(bare) == EVText.Current(minA: 6, maxA: 16, wattsPerAmp: 690, phases: 3)) @@ -144,57 +107,4 @@ import Testing #expect(overlaid[F.loadW] == 1600) } - @Test func theBandMatchesTheWebAppsCases() { - func band(_ carrier: CarrierKind, transport: CarrierKind = .none, _ src: SourceState, age: Double?, _ phase: SessionPhase, wait: Double = 0, boot: Int? = nil, noCarrier: Bool = false) -> Freshness.Band { - Freshness.band(carrier: carrier, transport: transport, srcState: src, ageMs: age, phase: phase, waitMs: wait, bootPct: boot, noCarrier: noCarrier) - } - // A decision about the phone is not a connection fault. - let ended = band(.none, .stale, age: 7_200_000, .terminated) - #expect(ended.message == "Access ended") - // No carrier at all: no promise of a retry. - let lost = band(.none, .stale, age: 60_000, .failed, noCarrier: true) - #expect(lost.message == "Can't reach your box") - #expect(lost.tone == .lost) - #expect(lost.wait == nil) - // A carrier healing itself says so, with its elapsed time. - let healing = band(.none, .stale, age: 60_000, .failed, wait: 7_000) - #expect(healing.message == "Reconnecting to your box") - #expect(healing.wait == "7s") - #expect(healing.age == "1 min ago") - // Live only for live readings. - let quiet = band(.relay, .stale, age: 5_000, .streaming) - #expect(quiet.message == "Encrypted relay connected · readings") - #expect(quiet.tone == .stale) - #expect(!quiet.message.lowercased().contains("live")) - #expect(band(.relay, .live, age: 0, .streaming).message == "Live via encrypted relay") - #expect(band(.relay, .live, age: 0, .streaming).age == nil) - // A starting box shows its own progress. - let booting = band(.none, transport: .relay, .stale, age: 5_000, .booting, boot: 40) - #expect(booting.message == "Your box is starting") - #expect(booting.wait == "40%") - // After a restart the box cannot place the reading: a dash, never a guess. - #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).age == "—") - #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).message == "Securing encrypted relay") - } - - @Test func thePriceCardFindsTheCheapestTwoHoursAhead() { - let hour = 3_600_000.0 - let day = 1_750_000_000_000.0 - let totals: [Double] = [100, 120, 40, 30, 35, 200, -5, 90] - let slots = totals.enumerated().map { PriceSlot(startMs: day + Double($0.offset) * hour, durationMs: hour, spotMinor: $0.element, totalMinor: $0.element) } - let prices = Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false) - let s = PriceStrip.summary(prices, nowMs: day + 1.5 * hour) - #expect(s.current?.totalMinor == 120) - // The slot already over is not ahead. - #expect(s.bars.count == 7) - #expect(s.bars.first?.current == true) - #expect(s.bars.first(where: { $0.minor == -5 })?.tone == .negative) - #expect(s.bars.first(where: { $0.minor == 200 })?.tone == .dear) - #expect(s.bars.first(where: { $0.minor == 30 })?.tone == .cheap) - // 30 then 35 beats 40 then 30: two whole hours in a row, the lowest mean. - #expect(s.cheapest == PriceStrip.Block(meanMinor: 32.5, startMs: day + 3 * hour, endMs: day + 5 * hour)) - #expect(PriceStrip.text(144.4, "SEK") == "144") - #expect(PriceStrip.text(14.44, "SEK") == "14.4") - #expect(PriceStrip.text(nil, "SEK") == "—") - } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift index 000e8b1..81b95f5 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift @@ -7,16 +7,12 @@ import Testing struct IdentityVectors: Decodable { struct Handle: Decodable { let epoch: Int64; let handle: String } struct Rendezvous: Decodable { let secret: String; let handles: [Handle] } + /// The file also holds the escrow's vectors; this app has no escrow + /// yet and reads only what it uses. struct PRFVector: Decodable { let output: String let credentialId: String - let lookupId: String - let writeKey: String - let signedMessage: String - let signature: String } - struct Home: Decodable { let siteId: String; let label: String; let boxStaticKey: String; let rendezvousSecret: String } - struct Blob: Decodable { let escrowVersion: UInt32; let sealed: String; let deviceScalar: String; let homes: [Home] } struct VaultCopy: Decodable { let publicKey: String; let iv: String; let ct: String; let scalar: String } struct EnrollmentVector: Decodable { let url: String @@ -31,7 +27,6 @@ struct IdentityVectors: Decodable { let rendezvous: Rendezvous let prf: PRFVector - let recoveryBlob: Blob let vaultCopy: VaultCopy let enrollment: EnrollmentVector @@ -54,37 +49,6 @@ struct IdentityVectors: Decodable { #expect(Rendezvous.epoch(nowMs: Rendezvous.epochMs) == 1) } - @Test func passkeyDerivationsMatchTheWebApp() throws { - let v = try IdentityVectors.load() - let wrapping = PRF.wrappingKey(credentialID: v.prf.credentialId, prfOutput: Bytes(hex: v.prf.output)) - let keys = try #require(wrapping.escrow) - #expect(keys.lookupID == v.prf.lookupId) - #expect(keys.writeKey.hex == v.prf.writeKey) - // The web app's signature verifies under the key derived here. - #expect(Primitives.ed25519Verify(publicKey: keys.writeKey, signature: Bytes(hex: v.prf.signature), message: Bytes(hex: v.prf.signedMessage))) - // And the signature made here verifies under the web app's key. - let mine = try keys.sign(Bytes(hex: v.prf.signedMessage)) - #expect(Primitives.ed25519Verify(publicKey: Bytes(hex: v.prf.writeKey), signature: mine, message: Bytes(hex: v.prf.signedMessage))) - } - - @Test func aRecoveryCopyTheWebAppSealedOpensHere() throws { - let v = try IdentityVectors.load() - let keys = try #require(PRF.wrappingKey(credentialID: v.prf.credentialId, prfOutput: Bytes(hex: v.prf.output)).escrow) - let sealed = Bytes(hex: v.recoveryBlob.sealed) - #expect(sealed.count == RecoveryBlob.maxBytes) - let contents = try RecoveryBlob.open(key: keys.sealKey, sealed, escrowVersion: v.recoveryBlob.escrowVersion) - #expect(contents.deviceScalar.hex == v.recoveryBlob.deviceScalar) - #expect(contents.homes.map(\.siteId) == v.recoveryBlob.homes.map(\.siteId)) - #expect(contents.homes.map(\.label) == v.recoveryBlob.homes.map(\.label)) - #expect(contents.homes.map { $0.boxStaticKey.hex } == v.recoveryBlob.homes.map(\.boxStaticKey)) - #expect(contents.homes.map { $0.rendezvousSecret.hex } == v.recoveryBlob.homes.map(\.rendezvousSecret)) - - // The version is bound in: the same bytes under another number fail. - #expect(throws: RecoveryBlob.BlobError.self) { - _ = try RecoveryBlob.open(key: keys.sealKey, sealed, escrowVersion: v.recoveryBlob.escrowVersion + 1) - } - } - @Test func aVaultCopyTheWebAppSealedOpensUnderTheSamePasskey() throws { let v = try IdentityVectors.load() let wrapping = PRF.wrappingKey(credentialID: v.prf.credentialId, prfOutput: Bytes(hex: v.prf.output)) @@ -155,42 +119,6 @@ struct IdentityVectors: Decodable { } } -@Suite struct BoxCodeTests { - // Produced by the box's Go encoder, as the web app's suite pins them. - let vectors = [ - ("0000000000", "0000-0000"), - ("ffffffffff", "ZZZZ-ZZZZ"), - ("0000000001", "0000-0001"), - ("0123456789", "04HM-ASW9"), - ("deadbeef42", "VTPV-XVT2"), - ("8f1c00a57b", "HWE0-19BV"), - ("1084210842", "2222-2222"), - ] - - @Test func decodesWhatTheBoxDrew() throws { - for (hex, code) in vectors { - #expect(try BoxCode.decode(code).hex == hex, "\(code)") - } - } - - @Test func foldsWhatListenersMishear() throws { - #expect(try BoxCode.decode("IO1L-0000").hex == "0802100000") - #expect(try BoxCode.decode("l0i1-oooo").hex == "0802100000") - for typed in ["04hm-asw9", "04HMASW9", "04 HM AS W9", " 04hm asw9 ", "04HM\tASW9"] { - #expect(try BoxCode.decode(typed).hex == "0123456789") - } - #expect(BoxCode.fold("04hm-asw9") == "04HMASW9") - #expect(BoxCode.fold("04HMASW9ZZZZ") == "04HMASW9") - #expect(BoxCode.group("04HMASW9") == "04HM-ASW9") - } - - @Test func refusesWhatIsNotACode() { - #expect(throws: BoxCode.BoxCodeError.self) { _ = try BoxCode.decode("UUUU-UUUU") } - #expect(throws: BoxCode.BoxCodeError.self) { _ = try BoxCode.decode("04HM-ASW") } - #expect(throws: BoxCode.BoxCodeError.self) { _ = try BoxCode.decode("04HM!ASW9") } - } -} - @MainActor @Suite struct VaultTests { @Test func enrollingMakesAPasskeyCopyAndALocalOne() async throws { @@ -260,91 +188,3 @@ struct IdentityVectors: Decodable { #expect(await other.stepUp(passkeys) == .unavailable) } } - -@MainActor -@Suite struct EscrowTests { - func setUp() -> (Vault, SiteList, FakeEscrowService, Escrow, FakePasskeys) { - let store = MemoryStore() - let vault = Vault(store: store) - let sites = SiteList(store: store) - let service = FakeEscrowService() - return (vault, sites, service, Escrow(vault: vault, sites: sites, transport: service.transport), FakePasskeys()) - } - - @Test func requestsArePaddedToOneLength() throws { - let body = Escrow.padded([("op", .string("get")), ("id", .string("abc"))]) - #expect(body.count == 1024) - let json = try JSONSerialization.jsonObject(with: Data(body)) as? [String: Any] - #expect(json?["op"] as? String == "get") - } - - @Test func pairingHoldsASealedCopyAndANewPhoneGetsTheHomeBack() async throws { - let (vault, sites, service, escrow, passkeys) = setUp() - let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys, now: { 1_000 }) - let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) - let paired = try await pairing.pair(scanned: enrollment.url()) - #expect(await paired.sealed.value == .saved) - #expect(sites.get(paired.site.siteId)?.escrow == true) - #expect(service.rows.count == 1) - let devicePublic = try #require(vault.devicePublic) - - // A new phone: empty storage, the same synced passkey. - let store2 = MemoryStore() - let vault2 = Vault(store: store2) - let sites2 = SiteList(store: store2) - let escrow2 = Escrow(vault: vault2, sites: sites2, transport: service.transport) - let found = try await escrow2.recover(passkeys) - #expect(found.map(\.siteId) == [paired.site.siteId]) - #expect(found.first?.fingerprint == SiteList.fingerprint(enrollment.boxStaticPublic)) - try escrow2.adopt(try #require(found.first), nowMs: 2_000) - // The same identity the box already trusts, with no pairing code. - #expect(vault2.devicePublic == devicePublic) - #expect(sites2.get(paired.site.siteId)?.pairingCode == nil) - #expect(sites2.get(paired.site.siteId)?.rendezvousSecret?.byteArray == enrollment.rendezvousSecret) - #expect(try vault2.silentWrappingKey() != nil) - #expect(vault2.passkeyCredentialIDs == [passkeys.credentialID]) - } - - @Test func removingEmptiesTheCopyAndKeepsTheVersionGoing() async throws { - let (vault, sites, service, escrow, passkeys) = setUp() - let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys) - let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) - let paired = try await pairing.pair(scanned: enrollment.url()) - _ = await paired.sealed.value - #expect(await escrow.remove(passkeys) == .removed) - let row = try #require(service.rows.values.first) - #expect(row.blob.isEmpty) - #expect(row.version == 2) - // Nothing held now reads as nothing held. - let store2 = MemoryStore() - #expect(try await Escrow(vault: Vault(store: store2), sites: SiteList(store: store2), transport: service.transport).recover(passkeys).isEmpty) - } - - @Test func noOptInCostsNoPromptAndNoRequest() async { - let (_, _, service, escrow, passkeys) = setUp() - #expect(await escrow.remove(passkeys) == .nothingToRemove) - #expect(passkeys.assertions.isEmpty) - #expect(service.requests == 0) - } - - @Test func offlineLeavesThePairingIntactAndUnmarked() async throws { - let (vault, sites, service, escrow, passkeys) = setUp() - service.offline = true - let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys) - let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) - let paired = try await pairing.pair(scanned: enrollment.url()) - #expect(await paired.sealed.value == .unreachable) - #expect(sites.get(paired.site.siteId)?.escrow == false) - #expect(sites.get(paired.site.siteId) != nil) - } - - @Test func aBoxCodeArmsAKnownHomeOnly() async throws { - let (vault, sites, _, escrow, passkeys) = setUp() - let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys) - #expect(throws: BoxCode.BoxCodeError.self) { try pairing.redeemBoxCode(siteId: "nope", typed: "04HM-ASW9") } - let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) - let paired = try await pairing.pair(scanned: enrollment.url(), holdCopy: false) - try pairing.redeemBoxCode(siteId: paired.site.siteId, typed: "04hm asw9") - #expect(sites.get(paired.site.siteId)?.pairingCode?.byteArray.hex == "0123456789") - } -} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift index 2e4a256..91a4274 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift @@ -9,9 +9,6 @@ import Testing /// curl -s -X POST localhost:8080/api/app-link/pairing -d '{"role":"owner"}' /// FTW_LIVE_URL='https://app.ftw.energy/p#v2...' swift test --filter LiveBox /// -/// On Linux, whose Foundation has no WebSocket client, run a bridge that -/// carries frames to the relay unchanged and set FTW_LIVE_RELAY to it. -/// /// The passkey ceremony cannot run here, so the device key is wrapped under /// the local key alone. Everything after that is the shipped path: the real /// socket, the rotating handle, Noise IK with the pairing code in message 1, @@ -26,18 +23,7 @@ struct LiveBoxTests { let device = try vault.deviceKey(vault.localWrappingKey()) let scheduler = LiveScheduler() - // FTW_LIVE_RELAY points at a local bridge where Foundation has no - // WebSocket client of its own (Linux); elsewhere the real socket. - let relay: RelayCarrier - if let bridge = ProcessInfo.processInfo.environment["FTW_LIVE_RELAY"], let bridgeURL = URL(string: bridge) { - #if os(Linux) - relay = RelayCarrier(url: bridgeURL, secret: enrollment.rendezvousSecret, scheduler: scheduler, makeSocket: PlainWebSocket.factory()) - #else - relay = RelayCarrier(url: bridgeURL, secret: enrollment.rendezvousSecret, scheduler: scheduler) - #endif - } else { - relay = RelayCarrier(secret: enrollment.rendezvousSecret, scheduler: scheduler) - } + let relay = RelayCarrier(secret: enrollment.rendezvousSecret, scheduler: scheduler) let noise = NoiseCarrier( inner: relay, staticKey: device, @@ -71,12 +57,9 @@ struct LiveBoxTests { print("live: /api/status grid_w \(json["grid_w"]?.number ?? .nan), \(status.body.count) bytes") #expect(json["grid_w"]?.number != nil) - let plan = try await session.plan() - print("live: plan rev \(plan.rev), \(plan.slots.count) slots, stale \(plan.stale)") - - var tiles = 0 - let end = try await session.history(HistQuery(series: ["grid_w", "pv_w", "battery_w", "load_w"], res: .fiveMinutes, fromMs: Date().timeIntervalSince1970 * 1000 - 86_400_000, toMs: Date().timeIntervalSince1970 * 1000, have: [], maxPoints: 1500)) { _ in tiles += 1 } - print("live: history \(tiles) tiles, served \(end.resActual.rawValue)") + let now = Date().timeIntervalSince1970 * 1000 + let prices = try await session.prices(fromMs: now, toMs: now + 86_400_000) + print("live: prices \(prices.zone) \(prices.currency), \(prices.slots.count) slots, stale \(prices.stale)") // Reaching an actuating route through the passthrough is refused and // points at the command instead. diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift b/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift index 53bba50..40680ea 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift @@ -1,5 +1,6 @@ import Foundation @testable import FTWKit +import FTWDemo /// The box's end of Noise, around a `SimulatedBox`: what the Go box does in /// front of its protocol handler. diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift b/appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift deleted file mode 100644 index 7553a87..0000000 --- a/appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift +++ /dev/null @@ -1,181 +0,0 @@ -#if os(Linux) -import Foundation -import Glibc -@testable import FTWKit - -/// A minimal RFC 6455 client over a plain TCP socket, for the live test on -/// Linux only: Foundation there is built on a libcurl without WebSocket -/// support, where Apple platforms have URLSessionWebSocketTask. It talks to -/// a local bridge that carries the frames to the relay unchanged. -@MainActor -final class PlainWebSocket: WebSocketConnection { - private enum Event: Sendable { - case open - case text(String) - case binary([UInt8]) - case close(Int, String) - } - - private let fd: Int32 - private var closed = false - - static func factory() -> WebSocketFactory { - { url, events in PlainWebSocket(url: url, events: events) } - } - - init(url: URL, events: WebSocketEvents) { - fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) - let host = url.host ?? "127.0.0.1" - let port = UInt16(url.port ?? 80) - let path = url.path.isEmpty ? "/" : url.path - let (stream, continuation) = AsyncStream.makeStream() - let fd = self.fd - - // One consumer on the main actor, so events arrive in order. - Task { @MainActor [weak self] in - for await event in stream { - guard let self, !self.closed || { if case .close = event { return true }; return false }() else { continue } - switch event { - case .open: events.onOpen() - case .text(let t): events.onText(t) - case .binary(let b): events.onBinary(b) - case .close(let code, let reason): - self.closed = true - events.onClose(code, reason) - } - } - } - - Thread.detachNewThread { - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = port.bigEndian - addr.sin_addr.s_addr = inet_addr(host == "localhost" ? "127.0.0.1" : host) - let ok = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Glibc.connect(fd, $0, socklen_t(MemoryLayout.size)) } - } - guard ok == 0 else { - continuation.yield(.close(1006, "")) - continuation.finish() - return - } - let key = Data((0..<16).map { _ in UInt8.random(in: 0...255) }).base64EncodedString() - let request = "GET \(path) HTTP/1.1\r\nHost: \(host):\(port)\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: \(key)\r\nSec-WebSocket-Version: 13\r\n\r\n" - PlainWebSocket.write(fd, Array(request.utf8)) - - var buffer = [UInt8]() - func fill(_ n: Int) -> Bool { - var chunk = [UInt8](repeating: 0, count: 65_536) - while buffer.count < n { - let got = recv(fd, &chunk, chunk.count, 0) - if got <= 0 { return false } - buffer += chunk[0..= 2 ? Int(payload[0]) << 8 | Int(payload[1]) : 1005 - let reason = payload.count > 2 ? String(decoding: payload[2...], as: UTF8.self) : "" - continuation.yield(.close(code, reason)) - continuation.finish() - Glibc.close(fd) - return - case 0x9: - PlainWebSocket.write(fd, PlainWebSocket.frame(opcode: 0xA, payload)) - case 0x0, 0x1, 0x2: - if opcode != 0 { messageOpcode = opcode; message = [] } - message += payload - if fin { - continuation.yield(messageOpcode == 1 ? .text(String(decoding: message, as: UTF8.self)) : .binary(message)) - message = [] - } - default: - break - } - } - continuation.yield(.close(1006, "")) - continuation.finish() - } - } - - nonisolated static func frame(opcode: UInt8, _ payload: [UInt8]) -> [UInt8] { - var out: [UInt8] = [0x80 | opcode] - if payload.count < 126 { - out.append(0x80 | UInt8(payload.count)) - } else if payload.count < 65_536 { - out.append(0x80 | 126) - out.append(UInt8(payload.count >> 8)) - out.append(UInt8(payload.count & 0xff)) - } else { - out.append(0x80 | 127) - for shift in stride(from: 56, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: payload.count >> shift)) } - } - let mask = (0..<4).map { _ in UInt8.random(in: 0...255) } - out += mask - for (i, b) in payload.enumerated() { out.append(b ^ mask[i % 4]) } - return out - } - - nonisolated static func write(_ fd: Int32, _ bytes: [UInt8]) { - var sent = 0 - while sent < bytes.count { - let n = bytes[sent...].withUnsafeBytes { Glibc.send(fd, $0.baseAddress, $0.count, Int32(MSG_NOSIGNAL)) } - if n <= 0 { return } - sent += n - } - } - - func send(_ data: Bytes) { - guard !closed else { return } - PlainWebSocket.write(fd, Self.frame(opcode: 0x2, data)) - } - - func close() { - guard !closed else { return } - closed = true - PlainWebSocket.write(fd, Self.frame(opcode: 0x8, [0x03, 0xe8])) - shutdown(fd, Int32(SHUT_RDWR)) - } -} -#endif diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift index f0b3f5b..642dbe4 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing @testable import FTWKit +import FTWDemo @MainActor @Suite struct SessionTests { @@ -69,7 +70,7 @@ import Testing let rig = Rig(requireStepUp: true) await rig.run(200) for (req, code) in [ - (APIRequest(method: .post, path: "/api/restart"), "E_NEEDS_STEP_UP"), + (APIRequest(method: .delete, path: "/api/loadpoints/garage/schedule"), "E_NEEDS_STEP_UP"), (APIRequest(method: .post, path: "/api/mode"), "E_USE_CMD"), (APIRequest(method: .get, path: "/api/config"), "E_LOCAL_ONLY"), (APIRequest(method: .get, path: "/api/nothing/here"), "E_UNKNOWN_OP"), @@ -84,7 +85,7 @@ import Testing } } // The same request with the flag goes through. - let task = Task { try await rig.session.api(APIRequest(method: .post, path: "/api/restart", stepUp: true)) } + let task = Task { try await rig.session.api(APIRequest(method: .delete, path: "/api/loadpoints/garage/schedule", stepUp: true)) } await rig.run(100) #expect(try await task.value.status == 200) } @@ -99,7 +100,7 @@ import Testing #expect(try await b.value.status == 200) } - @Test func aModeCommandIsAckedAppliedAndReplans() async throws { + @Test func aModeCommandIsAckedAndApplied() async throws { let rig = Rig() await rig.run(200) let task = Task { try await rig.session.command(Contract.opSetMode, args: [("mode", .text("self_consumption"))]) } @@ -107,8 +108,6 @@ import Testing let result = try await task.value #expect(result.state == .applied) #expect(result.observedValue == 3) - // The plan pushed unasked lands on state. - #expect(rig.session.state.plan != nil) await rig.run(1_500, step: 100) #expect(rig.session.state.fields[Contract.FID.mode] == 3) } @@ -140,7 +139,7 @@ import Testing let rig = Rig() await rig.run(200) rig.box.mute = true - let task = Task { try await rig.session.plan() } + let task = Task { try await rig.session.prices(fromMs: rig.scheduler.nowMs, toMs: rig.scheduler.nowMs + 3_600_000) } await rig.run(50) rig.relay.closeCurrent(code: 1006, reason: "") await rig.run(20) @@ -227,27 +226,6 @@ import Testing #expect(rig.session.state.phase == .streaming) } - @Test func historyArrivesAsTilesAndAssembles() async throws { - let rig = Rig() - await rig.run(200) - let to = rig.scheduler.nowMs - let from = to - 86_400_000 - var chunks = [String: HistChunk]() - let task = Task { - try await rig.session.history(HistQuery(series: ["grid_w", "pv_w", "battery_w", "load_w"], res: .fiveMinutes, fromMs: from, toMs: to, have: [], maxPoints: 1500)) { chunk in - chunks[chunk.tileId] = chunk - } - } - await rig.run(300) - let end = try await task.value - #expect(end.resActual == .fiveMinutes) - let plan = HistoryGeometry.plan(.fiveMinutes, fromMs: from, toMs: to, maxPoints: 1500) - #expect(Set(chunks.keys) == Set(plan.tiles.map(\.tileId))) - let frame = HistoryGeometry.clip(HistoryGeometry.assemble(plan, names: ["grid_w", "pv_w", "battery_w", "load_w"], tiles: chunks), fromMs: from, toMs: to) - #expect(frame.points >= 287 && frame.points <= 289) - #expect(frame.value("load_w", at: 0) != nil) - } - @Test func timesGoOutAsWholeMilliseconds() async throws { // A real box drops a query whose times carry a fraction, and a // phone's clock has one. Found against a live box. @@ -259,16 +237,31 @@ import Testing #expect(try await task.value.slots.isEmpty == false) } - @Test func pricesAndThePlanAreAsked() async throws { + @Test func pricesAreAsked() async throws { let rig = Rig() await rig.run(200) - let plan = Task { try await rig.session.plan() } let prices = Task { try await rig.session.prices(fromMs: rig.scheduler.nowMs, toMs: rig.scheduler.nowMs + 86_400_000) } await rig.run(200) - #expect(try await plan.value.slots.count == 96) #expect(try await prices.value.currency == "SEK") } + /// The relay serves four streams a home. A fifth phone is turned away + /// with 4429 before it reaches the box, and that is not a fault. + @Test func aFullRelayIsBusyNotBroken() async throws { + let rig = Rig() + await rig.run(200) + rig.relay.closeCurrent(code: RelayCarrier.closeBusy, reason: "streams") + await rig.run(20) + #expect(rig.session.state.busy) + #expect(rig.session.state.phase == .failed) + // The readings stay, and the next stream clears it. + #expect(!rig.session.state.fields.isEmpty) + rig.endpoint.dropSession() + await rig.run(1_000, step: 20) + #expect(rig.session.state.phase == .streaming) + #expect(!rig.session.state.busy) + } + @Test func revocationTerminatesAndSaysWhy() async throws { let rig = Rig() await rig.run(200) @@ -300,70 +293,3 @@ import Testing #expect(rig.session.state.fields[Contract.FID.gridW] != 1_234) } } - -@Suite struct HistoryGeometryTests { - @Test func wideWindowsClampToTheHourlyStore() { - let now = 1_750_000_000_000.0 - let year = HistoryGeometry.plan(.fiveMinutes, fromMs: now - 365 * 86_400_000, toMs: now, maxPoints: 1500) - #expect(year.res == .hour) - #expect(year.tiles.count * year.tiles[0].points <= 1500 * 2) - let day = HistoryGeometry.plan(.fiveMinutes, fromMs: now - 86_400_000, toMs: now, maxPoints: 1500) - #expect(day.res == .fiveMinutes) - #expect(day.stride == 1) - } - - @Test func packingRoundTripsAndKeepsTheMissingMarker() { - let columns: [[Int32]] = [[1, -2, missingSample], [Int32.max, 0, 7]] - #expect(HistoryGeometry.unpack(HistoryGeometry.pack(columns), seriesCount: 2) == columns) - } - - @Test func etagIsFNV1a() { - #expect(HistoryGeometry.etag([]) == "811c9dc5") - #expect(HistoryGeometry.etag(Array("a".utf8)) == "e40c292c") - } -} - -@Suite struct ContractTests { - /// The registry is one file shared with the box and the web app. Every - /// name this app spells has to be the registry's. - @Test func namesMatchTheSharedRegistry() throws { - let yaml = try RepoFiles.read("protocol/registry.yaml") - func names(section: String, key: String) -> [String] { - var out = [String]() - var inside = false - for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) { - if line.hasPrefix("\(section):") { inside = true; continue } - if inside, let first = line.first, !first.isWhitespace, first != "#" { break } - guard inside, let range = line.range(of: "\(key): ") else { continue } - let rest = line[range.upperBound...] - out.append(String(rest.prefix { $0 != "," && $0 != "}" && $0 != " " })) - } - return out - } - func list(section: String) -> [String] { - var out = [String]() - var inside = false - for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) { - if line.hasPrefix("\(section):") { inside = true; continue } - if inside, let first = line.first, !first.isWhitespace, first != "#" { break } - let t = line.trimmingCharacters(in: .whitespaces) - if inside, t.hasPrefix("- ") { out.append(String(t.dropFirst(2))) } - } - return out - } - - #expect(names(section: "scopes", key: "name") == Contract.scopes) - #expect(names(section: "ops", key: "name") == Contract.ops) - #expect(list(section: "capabilities") == Contract.capabilities) - let errors = names(section: "errors", key: "code") + names(section: "client_errors", key: "code") - #expect(Set(errors) == Set(Contract.retryable.keys)) - for code in errors { - let line = yaml.split(separator: "\n").first { $0.contains("code: \(code),") }! - #expect(line.contains("retryable: \(Contract.isRetryable(code))"), "\(code)") - } - #expect(yaml.contains("source_states: [\(Contract.sourceStates.joined(separator: ", "))]")) - #expect(yaml.contains("carrier_states: [\(Contract.carrierStates.joined(separator: ", "))]")) - #expect(yaml.contains("1: { name: mode,")) - #expect(yaml.contains("10: { name: ev_w,")) - } -} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift new file mode 100644 index 0000000..27cad83 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import FTWShared + +@Suite struct CacheTests { + let files = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-cache-\(UUID().uuidString)")) + + func prices(_ total: Double = 42) -> PriceEntry { + PriceEntry(fromMs: 0, prices: Prices(zone: "SE3", currency: "SEK", slots: [PriceSlot(startMs: 0, durationMs: 3_600_000, spotMinor: total, totalMinor: total)], stale: false)) + } + + @Test func aRecordReadsBackAsTheCacheNeverAsLive() throws { + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1_000, value: prices())) + let read = try #require(CacheReader(sources: [files]).read(PriceEntry.self, siteId: "home")) + #expect(read.value == prices()) + #expect(read.record.carrier == .relay) + #expect(read.carrier == .cache) + #expect(read.srcState == .live) + #expect(read.origin == .app) + #expect(read.ageMs(nowMs: 61_000) == 60_000) + } + + @Test func theFilesAreTheNamesTheAppAndWidgetsAgreeOn() throws { + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1, value: prices())) + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1, value: CarEntry(cars: []))) + let names = try FileManager.default.contentsOfDirectory(atPath: files.directory.path).sorted() + #expect(names == ["car-home", "prices-home"]) + } + + @Test func aRecordOfAnotherShapeOrHomeIsNotRead() throws { + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1, value: prices())) + #expect(files.read(PriceEntry.self, siteId: "other") == nil) + #expect(files.read(CarEntry.self, siteId: "home") == nil) + + // A newer app wrote a version this one does not know. + var json = try JSONSerialization.jsonObject(with: Data(contentsOf: files.url(PriceEntry.name, "home"))) as! [String: Any] + json["version"] = PriceEntry.version + 1 + try JSONSerialization.data(withJSONObject: json).write(to: files.url(PriceEntry.name, "home")) + #expect(files.read(PriceEntry.self, siteId: "home") == nil) + } + + @Test func theNewestHomeIsTheOneTheAppHadOpen() throws { + try files.write(CacheRecord(siteId: "cabin", carrier: .relay, srcState: .live, readAtMs: 1, value: prices())) + let past = Date(timeIntervalSinceNow: -3_600) + try FileManager.default.setAttributes([.modificationDate: past], ofItemAtPath: files.url(PriceEntry.name, "cabin").path) + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 2, value: prices())) + #expect(CacheReader(sources: [files]).latestSiteID(PriceEntry.self) == "home") + #expect(files.siteIDs(PriceEntry.self) == ["home", "cabin"]) + #expect(files.siteIDs(CarEntry.self).isEmpty) + } + + /// A second source, such as a sealed copy from a cloud mirror, feeds the + /// same records. The newest reading wins whichever source holds it. + @Test func anotherSourceFeedsTheSameRecords() throws { + struct Mirror: CacheSource { + let record: CacheRecord + var origin: CacheOrigin { .app } + func read(_ type: Value.Type, siteId: String) -> CacheRecord? { + record.siteId == siteId ? record as? CacheRecord : nil + } + func siteIDs(_ type: Value.Type) -> [String] { Value.self == PriceEntry.self ? [record.siteId] : [] } + } + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1_000, value: prices(10))) + let newer = Mirror(record: CacheRecord(siteId: "home", carrier: .relay, srcState: .stale, readAtMs: 2_000, value: prices(20))) + let older = Mirror(record: CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 500, value: prices(30))) + #expect(CacheReader(sources: [files, newer]).read(PriceEntry.self, siteId: "home")?.value == prices(20)) + #expect(CacheReader(sources: [files, older]).read(PriceEntry.self, siteId: "home")?.value == prices(10)) + } + + @Test func signingOutLeavesWidgetsNothing() throws { + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1, value: prices())) + files.removeAll() + #expect(files.read(PriceEntry.self, siteId: "home") == nil) + #expect(files.siteIDs(PriceEntry.self).isEmpty) + } + + @Test func theSameCarAtAnotherMomentIsTheSameReading() { + let car = CarEntry.Car(loadpointID: "garage", pluggedIn: true, socPct: 60, socSource: "inferred", socReadAtMs: 1, powerW: 7_200, targetSocPct: 80) + var later = car + later.socReadAtMs = 2 + #expect(CarEntry(cars: [car]).sameReading(CarEntry(cars: [later]))) + later.socPct = 61 + #expect(!CarEntry(cars: [car]).sameReading(CarEntry(cars: [later]))) + } + + /// No widget can open a file the app could not have written. + @Test func theAppGroupComesFromTheBundleOrNotAtAll() { + #expect(AppGroupFiles.shared(bundle: Bundle(for: BundleMarker.self)) == nil) + } +} + +private final class BundleMarker {} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/SharedFormatTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/SharedFormatTests.swift new file mode 100644 index 0000000..b9591da --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/SharedFormatTests.swift @@ -0,0 +1,109 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The formatters a widget needs, pinned to the web app's own cases. These +/// read English: the tests run in the development language. +@Suite struct SharedFormatTests { + @Test func powerNeverShowsARawMinus() { + for w in [-1_000_000.0, -4200, -60, 0, 60, 4200] { + #expect(!PowerFormat.parts(w).text.hasPrefix("-")) + } + #expect(PowerFormat.parts(4200).direction == .into) + #expect(PowerFormat.parts(-4200).direction == .out) + #expect(PowerFormat.direction(PowerFormat.noiseW - 1) == .idle) + #expect(PowerFormat.direction(-PowerFormat.noiseW) == .out) + #expect(PowerFormat.parts(9900).text == "9.9") + #expect(PowerFormat.parts(11_400).text == "11") + #expect(PowerFormat.scale(5000) == "5 kW") + #expect(PowerFormat.scale(1500) == "1.5 kW") + #expect(PowerFormat.scale(-5000) == PowerFormat.scale(5000)) + #expect(PowerFormat.scale(.nan) == "") + #expect(PowerFormat.soc(875) == "88") + #expect(PowerFormat.soc(.nan) == "—") + #expect(PowerFormat.age(0) == "just now") + #expect(PowerFormat.age(30_000) == "30s ago") + #expect(PowerFormat.age(90_000) == "1 min ago") + #expect(PowerFormat.age(3_600_000) == "1 h ago") + #expect(PowerFormat.age(nil) == "unknown") + } + + @Test func pricesInTheChartsUnit() { + #expect(PriceUnits.text(144, "SEK") == "144.0") + #expect(PriceUnits.unit("EUR").perKwh == "cent/kWh") + #expect(PriceUnits.text(400, "CZK") == "4.00") + #expect(PriceUnits.unit("XYZ").perKwh == "XYZ/kWh") + let day: Double = 1_750_000_000_000 + let slots = [PriceSlot(startMs: day, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1), PriceSlot(startMs: day + 7_200_000, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1)] + #expect(PriceUnits.hasHole(slots, fromMs: day)) + #expect(PriceUnits.hasHole(Array(slots.suffix(1)), fromMs: day)) + #expect(!PriceUnits.hasHole(Array(slots.prefix(1)), fromMs: day)) + } + + @Test func theBandMatchesTheWebAppsCases() { + func band(_ carrier: CarrierKind, transport: CarrierKind = .none, _ src: SourceState, age: Double?, _ phase: SessionPhase, wait: Double = 0, boot: Int? = nil, noCarrier: Bool = false) -> Freshness.Band { + Freshness.band(carrier: carrier, transport: transport, srcState: src, ageMs: age, phase: phase, waitMs: wait, bootPct: boot, noCarrier: noCarrier) + } + // A decision about the phone is not a connection fault. + let ended = band(.none, .stale, age: 7_200_000, .terminated) + #expect(ended.message == "Access ended") + // No carrier at all: no promise of a retry. + let lost = band(.none, .stale, age: 60_000, .failed, noCarrier: true) + #expect(lost.message == "Can't reach your box") + #expect(lost.tone == .lost) + #expect(lost.wait == nil) + // A carrier healing itself says so, with its elapsed time. + let healing = band(.none, .stale, age: 60_000, .failed, wait: 7_000) + #expect(healing.message == "Reconnecting to your box") + #expect(healing.wait == "7s") + #expect(healing.age == "1 min ago") + // Live only for live readings. + let quiet = band(.relay, .stale, age: 5_000, .streaming) + #expect(quiet.message == "Encrypted relay connected · readings") + #expect(quiet.tone == .stale) + #expect(!quiet.message.lowercased().contains("live")) + #expect(band(.relay, .live, age: 0, .streaming).message == "Live via encrypted relay") + #expect(band(.relay, .live, age: 0, .streaming).age == nil) + // A starting box shows its own progress. + let booting = band(.none, transport: .relay, .stale, age: 5_000, .booting, boot: 40) + #expect(booting.message == "Your box is starting") + #expect(booting.wait == "40%") + // After a restart the box cannot place the reading: a dash, never a guess. + #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).age == "—") + #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).message == "Securing encrypted relay") + } + + /// A relay that turned this phone away is busy, not broken, and has no + /// countdown: waiting longer does not help. + @Test func aFullRelayIsBusy() { + let busy = Freshness.band(carrier: .none, transport: .relay, srcState: .stale, ageMs: 60_000, phase: .failed, waitMs: 9_000, bootPct: nil, noCarrier: false, busy: true) + #expect(busy.tone == .busy) + #expect(busy.message == "FTW is busy") + #expect(busy.wait == nil) + #expect(busy.age == "1 min ago") + // Once a carrier streams again, busy no longer applies. + let live = Freshness.band(carrier: .relay, transport: .relay, srcState: .live, ageMs: 0, phase: .streaming, waitMs: 0, bootPct: nil, noCarrier: false, busy: true) + #expect(live.tone == .live) + } + + @Test func thePriceCardFindsTheCheapestTwoHoursAhead() { + let hour = 3_600_000.0 + let day = 1_750_000_000_000.0 + let totals: [Double] = [100, 120, 40, 30, 35, 200, -5, 90] + let slots = totals.enumerated().map { PriceSlot(startMs: day + Double($0.offset) * hour, durationMs: hour, spotMinor: $0.element, totalMinor: $0.element) } + let prices = Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false) + let s = PriceStrip.summary(prices, nowMs: day + 1.5 * hour) + #expect(s.current?.totalMinor == 120) + // The slot already over is not ahead. + #expect(s.bars.count == 7) + #expect(s.bars.first?.current == true) + #expect(s.bars.first(where: { $0.minor == -5 })?.tone == .negative) + #expect(s.bars.first(where: { $0.minor == 200 })?.tone == .dear) + #expect(s.bars.first(where: { $0.minor == 30 })?.tone == .cheap) + // 30 then 35 beats 40 then 30: two whole hours in a row, the lowest mean. + #expect(s.cheapest == PriceStrip.Block(meanMinor: 32.5, startMs: day + 3 * hour, endMs: day + 5 * hour)) + #expect(PriceStrip.text(144.4, "SEK") == "144") + #expect(PriceStrip.text(14.44, "SEK") == "14.4") + #expect(PriceStrip.text(nil, "SEK") == "—") + } +} diff --git a/appleApp/FTWWidgets-Info.plist b/appleApp/FTWWidgets-Info.plist new file mode 100644 index 0000000..f62f91d --- /dev/null +++ b/appleApp/FTWWidgets-Info.plist @@ -0,0 +1,18 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + FTWAppGroup + $(FTW_APP_GROUP) + CFBundleLocalizations + + en + sv + + + diff --git a/appleApp/FTWWidgets.entitlements b/appleApp/FTWWidgets.entitlements new file mode 100644 index 0000000..04c6e8a --- /dev/null +++ b/appleApp/FTWWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(FTW_APP_GROUP) + + + diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift new file mode 100644 index 0000000..586246d --- /dev/null +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -0,0 +1,113 @@ +import FTWShared +import SwiftUI +import WidgetKit + +@main +struct FTWWidgets: WidgetBundle { + var body: some Widget { + PriceWidget() + } +} + +/// The price now and the cheapest two hours ahead, from what the app left in +/// the App Group. A widget opens no connection and holds no key. +struct PriceWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "energy.ftw.prices", provider: PriceTimeline()) { moment in + PriceWidgetView(moment: moment) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Electricity price") + .description("The price now and the cheapest two hours ahead.") + .supportedFamilies([.systemSmall]) + } +} + +struct PriceMoment: TimelineEntry { + let date: Date + /// Nil when the app has left nothing: not paired, or signed out. + let read: CacheRead? +} + +struct PriceTimeline: TimelineProvider { + func placeholder(in context: Context) -> PriceMoment { + PriceMoment(date: .now, read: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (PriceMoment) -> Void) { + completion(moments(now: .now)[0]) + } + + /// The app reloads every timeline after it writes, so nothing here asks + /// to be woken. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + completion(Timeline(entries: moments(now: .now), policy: .never)) + } + + /// One moment now and one at every price boundary ahead. The last falls + /// where the prices end, so the widget says they ran out rather than + /// showing the last hour again. + func moments(now: Date) -> [PriceMoment] { + guard let reader = CacheReader.shared(), + let site = reader.latestSiteID(PriceEntry.self), + let read = reader.read(PriceEntry.self, siteId: site) else { + return [PriceMoment(date: now, read: nil)] + } + let nowMs = now.timeIntervalSince1970 * 1000 + let ahead = Set(read.value.prices.slots.map { $0.startMs + $0.durationMs }.filter { $0 > nowMs }).sorted() + return [PriceMoment(date: now, read: read)] + ahead.map { PriceMoment(date: Date(timeIntervalSince1970: $0 / 1000), read: read) } + } +} + +struct PriceWidgetView: View { + let moment: PriceMoment + + var body: some View { + if let read = moment.read { + prices(read) + } else { + VStack(alignment: .leading, spacing: 4) { + Text("FTW").font(.headline) + Text("Open FTW to see prices.").font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + + private func prices(_ read: CacheRead) -> some View { + let prices = read.value.prices + let summary = PriceStrip.summary(prices, nowMs: moment.date.timeIntervalSince1970 * 1000) + return VStack(alignment: .leading, spacing: 2) { + Text("Price now").font(.caption).foregroundStyle(.secondary) + if let current = summary.current { + Text(PriceStrip.text(current.totalMinor, prices.currency)) + .font(.system(.largeTitle, design: .rounded).weight(.semibold).monospacedDigit()) + .minimumScaleFactor(0.6) + .lineLimit(1) + Text(PriceUnits.unit(prices.currency).perKwh).font(.caption2).foregroundStyle(.secondary) + Spacer(minLength: 2) + if let cheapest = summary.cheapest { + Text("Cheapest \(time(cheapest.startMs))–\(time(cheapest.endMs))") + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } else { + Spacer(minLength: 2) + Text(endText(prices)).font(.caption).foregroundStyle(.secondary) + } + // Read back from the phone, never live: say when it was fetched. + Text("Updated \(time(read.record.readAtMs))").font(.caption2).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func endText(_ prices: Prices) -> String { + guard let last = prices.slots.map({ $0.startMs + $0.durationMs }).max() else { return "No prices · open FTW" } + return "No prices after \(time(last)) · open FTW" + } + + private func time(_ ms: Double) -> String { + Date(timeIntervalSince1970: ms / 1000).formatted(date: .omitted, time: .shortened) + } +} diff --git a/appleApp/scripts/demo-screenshots.sh b/appleApp/scripts/demo-screenshots.sh index 3ebc1b3..719a31e 100755 --- a/appleApp/scripts/demo-screenshots.sh +++ b/appleApp/scripts/demo-screenshots.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Photographs every screen of the demo in an iOS Simulator, for review. -# Needs a Debug build in build/ios, as the Apple workflow makes: +# Boots the newest iPhone simulator, so run it where no one else is using +# one. Needs a Debug build in build/ios, as the Apple workflow makes: # xcodebuild build -project FTW.xcodeproj -scheme FTW \ # -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios set -euo pipefail @@ -31,7 +32,5 @@ shoot() { } shoot pair dark -for tab in now plan history box; do - shoot "$tab" dark -FTWDemo YES -FTWTab "$tab" -done +shoot now dark -FTWDemo YES -FTWTab now shoot now-light light -FTWDemo YES -FTWTab now From 5a14ff0960d99fa0ca982c82fb2b7c1d5e213d25 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 17:13:19 +0200 Subject: [PATCH 03/25] Sign with Sourceful's team, open pairing links, pair from a picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DEVELOPMENT_TEAM L66M368PZ6 in FTW.xcconfig, for the app and the widget, with automatic signing. Simulator builds still sign ad hoc and need no Apple account. The App IDs, App Group and Associated Domains still have to be registered for the team; the README lists the steps. - A pairing QR scanned with the iOS camera arrives as a universal link to https://app.ftw.energy/p#…. The app takes it through onOpenURL and onContinueUserActivity, the same offer path as any pairing link. - In the simulator, which has no camera, a picture of the QR from Photos pairs. PairingImage reads the link with CoreImage, with tests. - FTW Cloud is now an opt-in, readable state cache. The cache comments and CLAUDE.md say so; M1 does not depend on it. - Passkeys no longer uses the presentation anchor iOS 26 deprecated. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 7 +++-- README.md | 29 ++++++++++++------ appleApp/FTW.xcconfig | 11 +++++-- appleApp/FTW/FTWApp.swift | 6 ++++ appleApp/FTW/Platform/Passkeys.swift | 10 +++++-- appleApp/FTW/UI/PairView.swift | 30 +++++++++++++++++++ .../FTWKit/Identity/PairingImage.swift | 20 +++++++++++++ .../Sources/FTWKit/State/PairModel.swift | 6 ++++ appleApp/FTWKit/Sources/FTWShared/Cache.swift | 8 ++--- .../Tests/FTWKitTests/IdentityTests.swift | 24 +++++++++++++++ .../Tests/FTWSharedTests/CacheTests.swift | 2 +- 11 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift diff --git a/CLAUDE.md b/CLAUDE.md index 6e4f206..15f07f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,8 +34,9 @@ His decisions: - Charge now runs with no time limit. Do not add Plan, History, Box admin, escrow, LAN, push or the store listing -in M1. A cloud service that caches the box's state encrypted is designed -separately; nothing in M1 may depend on it. +in M1. FTW Cloud (an opt-in state cache, push and statistics, readable by +Sourceful rather than end-to-end encrypted) is designed separately; nothing +in M1 may depend on it. **Android (`androidApp/`, `shared/`).** Pair + Now, shipped on `main` as of 2026-08-22. Do not add Energy / History / Plan / EV, commands, escrow, LAN, @@ -96,7 +97,7 @@ On iPhone, one Swift package in `appleApp/FTWKit` holds three modules: - `FTWShared` holds plain `Sendable` values for the app's extensions: the App Group cache (`prices-`, `car-`), the price and freshness formatters, and their Swedish and English words. Nothing on the main - actor, no keys. A cloud copy of the box's state would be one more + actor, no keys. A copy from FTW Cloud would be one more `CacheSource` here, not a new model. - `FTWDemo` is the simulated box. The app links it for the demo and App Review; FTWKit never does. diff --git a/README.md b/README.md index 6e8f443..a7e5175 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,10 @@ iOS 26 is the minimum. iPhone only. **In the app** -- Pair: camera QR, a link from outside shown before it is trusted, and the - live demo against a simulated box. +- Pair: camera QR, a link from outside shown before it is trusted (also a + pairing QR scanned with the iOS camera, as a universal link), and the live + demo against a simulated box. In the simulator, which has no camera, a + picture of the QR from Photos takes the same path. - Now: one sentence, the energy flow, the price card with the cheapest two hours, today's totals and the fuse. The freshness band above it keeps carrier and source state apart. @@ -69,11 +71,12 @@ iOS 26 is the minimum. iPhone only. | Check | Result | |---|---| -| `swift test` in `appleApp/FTWKit` (Xcode 27, Swift 6.4) | 92 tests green, 1 live test skipped | +| `swift test` in `appleApp/FTWKit` (Xcode 27, Swift 6.4) | 94 tests green, 1 live test skipped | | Cross-implementation vectors from the web app and the box | Noise, frames, rendezvous handles and the vault copy match | | `ContractTests` against `protocol/registry.yaml` | Scopes, ops, capabilities, modes, errors, states and frozen field ids match | | App and widget for the iOS Simulator, signed ad hoc | Builds; both carry the App Group entitlement | | Demo in the iOS Simulator (iPhone 17, iOS 26.5) | Launches and streams | +| A picture of a pairing QR, picked from Photos in that simulator | Read and paired; the home then dials the relay | **Next in M1** @@ -81,13 +84,21 @@ The car view (level presets 20/40/60/80 and ±5 through `loadpoint.soc.set`, the goal through the schedule PUT, Charge now, Pause and Back to the plan), the four price widget families, then tests and screenshots. -**Needs an owner decision or a device** +**Manual steps before a phone build** -- An Apple team. `DEVELOPMENT_TEAM` is unset, so nothing runs on a phone yet. - Set it once in `appleApp/FTW.xcconfig`. -- An `apple-app-site-association` file on `app.ftw.energy` that names - `.energy.ftw.app` under `webcredentials` and `applinks`. Passkeys - need it. +The team is `L66M368PZ6`, set once in `appleApp/FTW.xcconfig` for the app and +the widget, with automatic signing. Nothing for signing is in the repository. +Someone in that team still has to: + +- Register the App IDs `energy.ftw.app` and `energy.ftw.app.widgets`, the + App Group `group.energy.ftw.app` on both, and Associated Domains on the + app. Signing in to Xcode with the team and building for a phone does this + through automatic signing; the developer portal works too. +- Publish `apple-app-site-association` on `app.ftw.energy` with + `L66M368PZ6.energy.ftw.app` under `webcredentials` and `/p` under + `applinks` (in ftw-webapp). Passkeys and the camera QR link need it. + +Simulator builds sign ad hoc and need no Apple account. **Not in M1 (do not start these next)** diff --git a/appleApp/FTW.xcconfig b/appleApp/FTW.xcconfig index 5099e34..8a90e57 100644 --- a/appleApp/FTW.xcconfig +++ b/appleApp/FTW.xcconfig @@ -12,8 +12,13 @@ CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 26.0 -// No Apple team yet. Simulator builds sign ad hoc so the entitlements, and -// with them the App Group, travel with the app. Set DEVELOPMENT_TEAM here -// once the team exists; device builds need it. +// Sourceful's Apple team, for the app and the widget alike. Signing is +// automatic: Xcode, signed in to that team, makes the profiles. Nothing for +// signing is committed here: no certificate, profile or key. +DEVELOPMENT_TEAM = L66M368PZ6 CODE_SIGN_STYLE = Automatic + +// Simulator builds sign ad hoc and need no Apple account, for CI and for +// anyone without one. The entitlements, and with them the App Group, still +// travel with the app. CODE_SIGN_IDENTITY[sdk=iphonesimulator*] = - diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index 612fcd4..b7fc0de 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -38,7 +38,13 @@ struct FTWApp: App { var body: some Scene { WindowGroup { RootView(app: app) + // A pairing QR scanned with the iOS camera arrives as a + // universal link to https://app.ftw.energy/p#…, and takes the + // same path as any other pairing link. .onOpenURL { app.offer($0.absoluteString) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { app.offer(url.absoluteString) } + } .onChange(of: scenePhase, initial: true) { _, phase in // Inactive is a glance at Control Center or the app // switcher: slow the stream, keep the session. Only the diff --git a/appleApp/FTW/Platform/Passkeys.swift b/appleApp/FTW/Platform/Passkeys.swift index 202d830..beb876c 100644 --- a/appleApp/FTW/Platform/Passkeys.swift +++ b/appleApp/FTW/Platform/Passkeys.swift @@ -124,7 +124,13 @@ extension Ceremony: ASAuthorizationControllerDelegate { extension Ceremony: ASAuthorizationControllerPresentationContextProviding { func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { - let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows) - return windows.first(where: \.isKeyWindow) ?? windows.first ?? ASPresentationAnchor() + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let windows = scenes.flatMap(\.windows) + if let window = windows.first(where: \.isKeyWindow) ?? windows.first { return window } + guard let scene = scenes.first else { + // A ceremony starts from a button on screen, so a scene exists. + preconditionFailure("passkey ceremony with no window scene") + } + return ASPresentationAnchor(windowScene: scene) } } diff --git a/appleApp/FTW/UI/PairView.swift b/appleApp/FTW/UI/PairView.swift index 149d621..8942377 100644 --- a/appleApp/FTW/UI/PairView.swift +++ b/appleApp/FTW/UI/PairView.swift @@ -1,11 +1,17 @@ import FTWKit import SwiftUI +#if targetEnvironment(simulator) +import PhotosUI +#endif /// Pairing, the first thing anyone sees. Scan, then Face ID. Everything that /// can fail does so before the passkey prompt. struct PairView: View { let app: AppModel @State private var model: PairModel + #if targetEnvironment(simulator) + @State private var picture: PhotosPickerItem? + #endif init(app: AppModel) { self.app = app @@ -126,6 +132,13 @@ struct PairView: View { .font(.callout) Hint("Next, your phone asks for Face ID or Touch ID to protect your FTW key. There is no FTW account or password. If this phone loses its key, a new Settings QR is the way back.") Button("Scan the pairing QR") { model.stage = .scanning }.buttonStyle(.primary) + #if targetEnvironment(simulator) + // The simulator has no camera. A screenshot of the QR in Photos + // takes the same path as a scan. + PhotosPicker("Choose a picture of the QR", selection: $picture, matching: .images) + .buttonStyle(.quiet) + .onChange(of: picture) { _, item in read(item) } + #endif DisclosureGroup("Can't see Show pairing code?") { Hint("Turn on Let the FTW app connect to this box, save, and restart the box first.") .frame(maxWidth: .infinity, alignment: .leading) @@ -135,6 +148,23 @@ struct PairView: View { } } +#if targetEnvironment(simulator) +extension PairView { + private func read(_ item: PhotosPickerItem?) { + guard let item else { return } + Task { + let data = try? await item.loadTransferable(type: Data.self) + picture = nil + if let link = data.flatMap(PairingImage.link(in:)) { + await model.pair(link) + } else { + model.noCodeFound() + } + } + } +} +#endif + struct Title: View { let text: String init(_ text: String) { self.text = text } diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift new file mode 100644 index 0000000..997df3d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift @@ -0,0 +1,20 @@ +import CoreImage +import Foundation + +/// A pairing link read off a picture of the QR. The simulator has no camera, +/// so this is how pairing is tried there. The link then takes the same path +/// as one the camera read. +public enum PairingImage { + /// The first QR in the picture that `Enrollment` accepts, or nil. + public static func link(in imageData: Data) -> String? { + guard let image = CIImage(data: imageData), + let detector = CIDetector( + ofType: CIDetectorTypeQRCode, + context: CIContext(options: [.useSoftwareRenderer: true]), + options: [CIDetectorAccuracy: CIDetectorAccuracyHigh] + ) else { return nil } + return detector.features(in: image) + .compactMap { ($0 as? CIQRCodeFeature)?.messageString } + .first { (try? Enrollment.parse(scanned: $0)) != nil } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift index f17b706..24e5b21 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift @@ -73,6 +73,12 @@ public final class PairModel { app.paired(known.siteId) } + /// A picture was read and held no pairing code. + public func noCodeFound() { + stage = .intro + message = "There is no FTW pairing code in that picture. Try a closer screenshot of the QR." + } + public func tryDemo() { stage = .demoing app.startDemo() diff --git a/appleApp/FTWKit/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift index 88f835b..4966a37 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Cache.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -45,13 +45,13 @@ public struct CacheRecord: Codable, Sendable, Equatable { public enum CacheOrigin: String, Sendable { /// Written by the app, from its own session with the box. case app - // A sealed copy of the box's state from a cloud mirror would be a second - // case here, opened by its own `CacheSource` into the same records. + // FTW Cloud, the opt-in state cache, would be a second case here, read by + // its own `CacheSource` into the same records. } /// Somewhere records can come from. The App Group files are the only source -/// today. A cloud copy becomes one more source that decrypts into the same -/// `CacheRecord`, so nothing that reads the cache has to change. +/// today. A copy from FTW Cloud becomes one more source that reads into the +/// same `CacheRecord`, so nothing that reads the cache has to change. public protocol CacheSource: Sendable { var origin: CacheOrigin { get } func read(_ type: Value.Type, siteId: String) -> CacheRecord? diff --git a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift index 81b95f5..e4ae48f 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift @@ -1,3 +1,4 @@ +import CoreImage import Foundation import Testing @testable import FTWKit @@ -71,6 +72,29 @@ struct IdentityVectors: Decodable { } } +@Suite struct PairingImageTests { + /// A QR as a PNG, the way a screenshot of the box's Settings page holds it. + func png(_ text: String) throws -> Data { + let filter = try #require(CIFilter(name: "CIQRCodeGenerator")) + filter.setValue(Data(text.utf8), forKey: "inputMessage") + filter.setValue("M", forKey: "inputCorrectionLevel") + let code = try #require(filter.outputImage).transformed(by: CGAffineTransform(scaleX: 8, y: 8)) + // A quiet zone, as on a screen. + let framed = code.composited(over: CIImage(color: .white).cropped(to: code.extent.insetBy(dx: -40, dy: -40))) + return try #require(CIContext().pngRepresentation(of: framed, format: .RGBA8, colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!)) + } + + @Test func aPictureOfThePairingQRGivesTheLink() throws { + let url = Enrollment(boxStaticPublic: Bytes(repeating: 7, count: 32), pairingCode: Bytes(repeating: 1, count: 16), lanHint: "", rendezvousSecret: Bytes(repeating: 9, count: 32)).url() + #expect(PairingImage.link(in: try png(url)) == url) + } + + @Test func anyOtherPictureGivesNothing() throws { + #expect(PairingImage.link(in: try png("https://example.com/p#v2.nope")) == nil) + #expect(PairingImage.link(in: Data("not a picture".utf8)) == nil) + } +} + @Suite struct EnrollmentTests { let good = Enrollment(boxStaticPublic: Bytes(repeating: 1, count: 32), pairingCode: Bytes(repeating: 2, count: 16), lanHint: "10.0.0.2:8080", rendezvousSecret: Bytes(repeating: 3, count: 32)) diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift index 27cad83..4166398 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift @@ -49,7 +49,7 @@ import Testing #expect(files.siteIDs(CarEntry.self).isEmpty) } - /// A second source, such as a sealed copy from a cloud mirror, feeds the + /// A second source, such as a copy from FTW Cloud, feeds the /// same records. The newest reading wins whichever source holds it. @Test func anotherSourceFeedsTheSameRecords() throws { struct Mirror: CacheSource { From f2eb4bcdba0f6b531e669dee1e7abf13cf811bb6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 17:56:36 +0200 Subject: [PATCH 04/25] Price widgets: the price now, the cheapest two hours next, and a grey end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four families read the App Group cache and nothing else: small (also the StandBy widget), medium with a twelve-hour curve, and the lock screen's rectangle and inline line. Prices show on the lock screen. - PriceGlance (FTWShared) builds the timeline: one entry now and one at every price boundary the box sent, at the box's resolution. Where the prices stop, the widget turns grey and says "No prices after Tue 24:00 · open FTW". It never stretches or repeats old prices. - The total says what it holds. The wire carries spot and total but not the settings between them, so a line through the two tells a grid fee from VAT. - Freshness stays two fields: every entry is carrier .cache with the srcState the app saw when it fetched. - SiteModel logs each write with os.Logger and gains refreshSharedPrices. The app asks for a BGAppRefreshTask just after 13:05, then every half hour until the box has tomorrow. No silent push, no server. - Swedish and English live in FTWShared; a test checks that every widget sentence has Swedish. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW-Info.plist | 8 + appleApp/FTW/FTWApp.swift | 50 ++- .../Sources/FTWKit/State/SiteModel.swift | 70 +++++ appleApp/FTWKit/Sources/FTWShared/Cache.swift | 5 + .../Sources/FTWShared/PriceGlance.swift | 296 ++++++++++++++++++ .../Sources/FTWShared/PriceRefresh.swift | 35 +++ .../Resources/sv.lproj/Localizable.strings | 17 + appleApp/FTWKit/Sources/FTWShared/Words.swift | 10 +- .../Tests/FTWKitTests/WidgetPricesTests.swift | 60 ++++ .../FTWSharedTests/PriceGlanceTests.swift | 266 ++++++++++++++++ .../FTWSharedTests/PriceRefreshTests.swift | 40 +++ appleApp/FTWWidgets/PriceViews.swift | 227 ++++++++++++++ appleApp/FTWWidgets/PriceWidget.swift | 120 +++---- 13 files changed, 1123 insertions(+), 81 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift create mode 100644 appleApp/FTWWidgets/PriceViews.swift diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist index 03bab63..0c887b4 100644 --- a/appleApp/FTW-Info.plist +++ b/appleApp/FTW-Info.plist @@ -4,6 +4,14 @@ FTWAppGroup $(FTW_APP_GROUP) + BGTaskSchedulerPermittedIdentifiers + + energy.ftw.app.prices + + UIBackgroundModes + + fetch + CFBundleLocalizations en diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index b7fc0de..f35afc0 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -1,5 +1,7 @@ +import BackgroundTasks import FTWDemo import FTWKit +import os import SwiftUI import WidgetKit @@ -50,9 +52,55 @@ struct FTWApp: App { // switcher: slow the stream, keep the session. Only the // background closes it. app.setVisible(phase == .active) - if phase == .background { app.suspend() } + if phase == .background { + app.suspend() + PricesInBackground.schedule(app) + } } } + .backgroundTask(.appRefresh(PricesInBackground.id)) { [app] in + await PricesInBackground.run(app) + } + } +} + +/// Tomorrow's prices for the widgets without opening the app. iOS wakes the +/// app shortly after 13:05 if it allows it at all; the app opens a session, +/// asks for prices, leaves them in the App Group and closes again. No silent +/// push and no server: when iOS does not wake the app, the widgets wait for +/// the next time it is opened. +@MainActor +enum PricesInBackground { + /// Listed under BGTaskSchedulerPermittedIdentifiers in FTW-Info.plist. + static let id = "energy.ftw.app.prices" + static let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + + static func schedule(_ app: AppModel) { + guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + let nowMs = Date.now.timeIntervalSince1970 * 1000 + let at = Date(timeIntervalSince1970: PriceRefresh.next(afterMs: nowMs, pricesEndMs: site.sharedPricesEndMs) / 1000) + let request = BGAppRefreshTaskRequest(identifier: id) + request.earliestBeginDate = at + do { + try BGTaskScheduler.shared.submit(request) + log.info("background prices asked for after \(at.formatted(date: .abbreviated, time: .shortened), privacy: .public)") + } catch { + // The simulator, Background App Refresh turned off, or Low Power + // Mode. The widgets then wait for the app to be opened. + log.notice("background prices not scheduled: \(error.localizedDescription, privacy: .public)") + } + } + + static func run(_ app: AppModel) async { + defer { schedule(app) } + guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + log.info("background prices: woken") + // A new carrier if the last one was closed, or a fresh stream. + await app.refresh() + let prices = await site.refreshSharedPrices() + log.info("background prices: \(prices == nil ? "nothing written" : "\(prices!.slots.count) slots written", privacy: .public)") + // Someone may have opened the app meanwhile; their session stays. + if UIApplication.shared.applicationState == .background { app.suspend() } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 62eab3e..78489ba 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1,6 +1,9 @@ import Foundation import FTWShared import Observation +import os + +private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") /// The bridge between the session and the screens. /// @@ -406,10 +409,77 @@ public final class SiteModel { guard !destroyed else { return } do { try shared.write(record) + log.info("wrote \(Value.name, privacy: .public) for widgets, source \(record.srcState.rawValue, privacy: .public)") sharedWritten?() } catch { // A widget showing yesterday's record with its date is better // than no app; the next read tries again. + log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)") + } + } + + /// Where the prices the widgets hold run out. Nil when they hold none. + public var sharedPricesEndMs: Double? { + guard let siteId, let record = shared?.read(PriceEntry.self, siteId: siteId) else { return nil } + return record.value.prices.slots.map { $0.startMs + $0.durationMs }.max() + } + + /// The background refresh: wait for the stream, ask for today's and + /// tomorrow's prices as the price card does, and leave them for the + /// widgets. Returns what was written, or nil when nothing was: no + /// stream within `waitMs`, a box without prices, or a failed ask. + /// + /// The caller builds the carrier and closes it after. + @discardableResult + public func refreshSharedPrices(calendar: Calendar = .current, waitMs: Double = 20_000) async -> Prices? { + guard siteId != nil, shared != nil, !destroyed else { return nil } + guard await streaming(within: waitMs) else { + log.notice("background prices: no stream within \(Int(waitMs / 1000), privacy: .public) s") + return nil + } + guard session.caps.contains(Contract.capPriceSpot) else { + log.notice("background prices: this box offers no prices") + return nil + } + let from = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)).timeIntervalSince1970 * 1000 + do { + let wire = try await prices(fromMs: from, toMs: from + PriceModel.horizonMs) + sharePrices(wire, fromMs: from) + return wire + } catch { + log.error("background prices: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + /// True once the session streams, false if it has not within `ms`. + private func streaming(within ms: Double) async -> Bool { + if session.phase == .streaming { return true } + let wait = StreamWait() + let ok = await withCheckedContinuation { cont in + wait.continuation = cont + wait.token = observe { [weak self, wait] in + if self?.session.phase == .streaming { wait.settle(true) } + } + wait.timer = scheduler.after(ms) { [wait] in wait.settle(false) } } + if let token = wait.token { unobserve(token) } + return ok + } +} + +/// One wait for the stream, settled once. The timer settles it even if the +/// model is gone, so the background task always ends. +@MainActor +private final class StreamWait { + var continuation: CheckedContinuation? + var token: Int? + var timer: Cancellable? + + func settle(_ ok: Bool) { + guard let cont = continuation else { return } + continuation = nil + timer?.cancel() + cont.resume(returning: ok) } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift index 4966a37..61221b1 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Cache.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -64,6 +64,11 @@ public struct CacheRead: Sendable { public let record: CacheRecord public let origin: CacheOrigin + public init(record: CacheRecord, origin: CacheOrigin) { + self.record = record + self.origin = origin + } + public var value: Value { record.value } /// Always `.cache`. A file on this phone is not a connection to the box. public var carrier: CarrierKind { .cache } diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift new file mode 100644 index 0000000..d5e6c15 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift @@ -0,0 +1,296 @@ +import Foundation + +/// What a price widget shows at one moment, read from the cached prices and +/// nothing else. +/// +/// A widget cannot ask the box anything, so its timeline is worked out in +/// advance: one moment now and one at every price boundary the box sent, +/// at whatever resolution the box sent it. Where the prices stop, the last +/// moment says so. It never stretches the last price over hours the market +/// has not priced, and never starts the day over with yesterday's. +public enum PriceGlance { + /// How far ahead the medium widget's curve reaches. + public static let curveMs: Double = 12 * 3_600_000 + /// The stretch "cheapest" looks for, as on the app's price card. + public static let blockHours: Double = 2 + + /// What the box's total holds besides the market price. The wire has + /// spot and total but not the settings between them, so this is read off + /// the two figures: Core charges (spot + grid fee) × (1 + VAT). + public enum Included: Equatable, Sendable { + case gridFeeAndVAT, vat, gridFee + /// The total is the market price and nothing else. + case spotOnly + /// Something is added, but the market barely moved, so a fee cannot + /// be told from a percentage. + case surcharges + } + + public enum State: Equatable, Sendable { + /// The app has left nothing: not paired, or signed out. + case nothing + /// A price covers this moment. + case price + /// No price covers this moment, and the next one starts at `nextMs`. + case gap(nextMs: Double) + /// No price covers this moment or anything after it. + case ended(lastMs: Double) + } + + public struct Moment: Equatable, Sendable { + public let atMs: Double + public let state: State + /// `.cache` whenever there is a record: a file on this phone is not a + /// connection to the box. `.none` when there is nothing. + public let carrier: CarrierKind + /// As it was when the app fetched the prices. Never better. + public let srcState: SourceState + /// Wall clock on the phone when the app fetched the prices. + public let readAtMs: Double? + public let currency: String + public let current: PriceSlot? + public let cheapest: PriceStrip.Block? + /// Slots from the current one to `curveMs` ahead. + public let curve: [PriceStrip.Bar] + public let meanMinor: Double? + public let included: Included + + /// "42.3", the price now. Nil when no price covers this moment. + public let priceText: String? + /// "öre/kWh" + public let unitText: String + /// "öre", for one short line. + public let unitLabel: String + /// "incl. grid fee and VAT" + public let includedText: String + /// "Cheapest 02–04" + public let cheapestText: String? + /// Why there is no price: "No prices after 24:00 · open FTW". + public let emptyText: String? + /// "Fetched 13:05" + public let fetchedText: String? + + /// Whether the widget should go grey: no price for this moment. + public var isEmpty: Bool { state != .price } + + /// One line for the lock screen's inline slot. + public var inlineText: String { + guard let priceText else { return emptyText ?? "" } + let price = "\(priceText) \(unitLabel)" + return cheapestText.map { "\(price) · \($0)" } ?? price + } + } + + /// The timeline: one moment now and one at every boundary ahead, the + /// last where the prices end. + public static func moments(_ read: CacheRead?, nowMs: Double, calendar: Calendar = .current) -> [Moment] { + guard let read else { return [moment(nil, atMs: nowMs, calendar: calendar)] } + let slots = usable(read.value.prices) + var edges = Set() + for s in slots { + edges.insert(s.startMs) + edges.insert(s.startMs + s.durationMs) + } + let ahead = edges.filter { $0 > nowMs }.sorted() + return ([nowMs] + ahead).map { moment(read, atMs: $0, calendar: calendar) } + } + + /// The widget at one moment. + public static func moment(_ read: CacheRead?, atMs t: Double, calendar: Calendar = .current) -> Moment { + guard let read else { + return Moment( + atMs: t, state: .nothing, carrier: .none, srcState: .never, readAtMs: nil, + currency: "", current: nil, cheapest: nil, curve: [], meanMinor: nil, included: .spotOnly, + priceText: nil, unitText: "", unitLabel: "", includedText: "", cheapestText: nil, + emptyText: GlanceWord.openToSee.text, fetchedText: nil + ) + } + let prices = read.value.prices + let slots = usable(prices) + let unit = PriceUnits.unit(prices.currency) + let current = slots.first { t >= $0.startMs && t < $0.startMs + $0.durationMs } + let included = included(slots) + + let state: State + if current != nil { + state = .price + } else if let next = slots.first(where: { $0.startMs > t }) { + state = .gap(nextMs: next.startMs) + } else { + state = .ended(lastMs: slots.last.map { $0.startMs + $0.durationMs } ?? read.value.fromMs) + } + + var cheapest: PriceStrip.Block? + var curve: [PriceStrip.Bar] = [] + var mean: Double? + if current != nil { + let summary = PriceStrip.summary(prices, nowMs: t) + cheapest = PriceStrip.bestBlock(slots.filter { $0.startMs + $0.durationMs > t }, hours: blockHours, cheapest: true) + curve = summary.bars.filter { $0.startMs < t + curveMs } + mean = summary.meanMinor + } + + let clock = Clock(calendar: calendar) + var emptyText: String? + switch state { + case .nothing, .price: emptyText = nil + case .gap(let next): emptyText = GlanceWord.noPriceUntil.format(clock.at(next, asEnd: false, from: t)) + case .ended(let last): + // A box that sent no slots at all has no last hour to name. + emptyText = slots.isEmpty ? GlanceWord.openToSee.text : GlanceWord.noPricesAfter.format(clock.at(last, asEnd: true, from: t)) + } + + return Moment( + atMs: t, + state: state, + carrier: read.carrier, + srcState: read.srcState, + readAtMs: read.record.readAtMs, + currency: prices.currency, + current: current, + cheapest: cheapest, + curve: curve, + meanMinor: mean, + included: included, + priceText: current.map { PriceStrip.text($0.totalMinor, prices.currency) }, + unitText: unit.perKwh, + unitLabel: unit.label, + includedText: text(included), + cheapestText: cheapest.map { clock.cheapest($0, from: t) }, + emptyText: emptyText, + fetchedText: GlanceWord.fetched.format(clock.at(read.record.readAtMs, asEnd: false, from: t)) + ) + } + + /// What the total holds, from a straight line through (spot, total). + /// Rounding to whole minor units blurs each point by half a unit, so the + /// line needs some spread in spot before its slope means anything. + public static func included(_ slots: [PriceSlot]) -> Included { + guard !slots.isEmpty, slots.contains(where: { abs($0.totalMinor - $0.spotMinor) >= 1 }) else { return .spotOnly } + let spots = slots.map(\.spotMinor) + guard let lo = spots.min(), let hi = spots.max(), hi - lo >= 4 else { return .surcharges } + let n = Double(slots.count) + let mx = spots.reduce(0, +) / n + let my = slots.map(\.totalMinor).reduce(0, +) / n + var sxx = 0.0, sxy = 0.0 + for s in slots { + sxx += (s.spotMinor - mx) * (s.spotMinor - mx) + sxy += (s.spotMinor - mx) * (s.totalMinor - my) + } + let slope = sxy / sxx + guard slope.isFinite, slope > 0 else { return .surcharges } + let fee = (my - slope * mx) / slope + switch (fee >= 1, slope > 1.03) { + case (true, true): return .gridFeeAndVAT + case (false, true): return .vat + case (true, false): return .gridFee + case (false, false): return .surcharges + } + } + + public static func text(_ included: Included) -> String { + switch included { + case .gridFeeAndVAT: return GlanceWord.inclGridFeeAndVAT.text + case .vat: return GlanceWord.inclVAT.text + case .gridFee: return GlanceWord.inclGridFee.text + case .spotOnly: return GlanceWord.spotOnly.text + case .surcharges: return GlanceWord.inclSurcharges.text + } + } + + /// The widget's name and line in the gallery. + public static var title: String { GlanceWord.title.text } + public static var summary: String { GlanceWord.summary.text } + public static var priceNow: String { GlanceWord.priceNow.text } + + static func usable(_ prices: Prices) -> [PriceSlot] { + prices.slots.filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } + } +} + +/// Every sentence a price widget shows. The English is the key, and the +/// Swedish lives in `Resources/sv.lproj`; a test checks each one has it. +enum GlanceWord: String, CaseIterable { + case title = "Electricity price" + case summary = "The price now and when it is cheapest next, from what FTW last fetched." + case priceNow = "Price now" + case cheapest = "Cheapest %@" + case cheapestNow = "Cheapest now until %@" + case tomorrow = "tomorrow %@" + case noPricesAfter = "No prices after %@ · open FTW" + case noPriceUntil = "No price until %@ · open FTW" + case openToSee = "Open FTW to see prices" + case fetched = "Fetched %@" + case inclGridFeeAndVAT = "incl. grid fee and VAT" + case inclVAT = "incl. VAT" + case inclGridFee = "incl. grid fee" + case spotOnly = "spot price only" + case inclSurcharges = "incl. surcharges" + + var text: String { Words.text(rawValue) } + + func format(_ arg: String) -> String { + String(format: text, arg) + } +} + +/// Times on the household's clock, 24-hour, as the box's own page writes +/// them. An end at midnight reads 24:00 of the day it ends, not 00:00 of the +/// next. A time on another day than the moment names that day, because a +/// widget can sit unread for days. +struct Clock { + let calendar: Calendar + + struct Wall { + let day: Date + let hour: Int + let minute: Int + + var hm: String { String(format: "%02d:%02d", hour, minute) } + } + + func wall(_ ms: Double, asEnd: Bool) -> Wall { + let date = Date(timeIntervalSince1970: ms / 1000) + let c = calendar.dateComponents([.hour, .minute], from: date) + let day = calendar.startOfDay(for: date) + if asEnd, c.hour == 0, c.minute == 0, let before = calendar.date(byAdding: .day, value: -1, to: day) { + return Wall(day: before, hour: 24, minute: 0) + } + return Wall(day: day, hour: c.hour ?? 0, minute: c.minute ?? 0) + } + + func day(_ ms: Double) -> Date { + calendar.startOfDay(for: Date(timeIntervalSince1970: ms / 1000)) + } + + /// "13:05", or "Tue 24:00" when that is not the moment's day. + func at(_ ms: Double, asEnd: Bool, from t: Double) -> String { + let w = wall(ms, asEnd: asEnd) + guard w.day != day(t) else { return w.hm } + var named = calendar + named.locale = Words.locale + let weekday = named.component(.weekday, from: w.day) + return "\(named.shortWeekdaySymbols[weekday - 1]) \(w.hm)" + } + + /// "Cheapest 02–04", "Cheapest now until 04", "Cheapest tomorrow 15–17". + /// + /// Tomorrow is named only when the clock alone would read as today: at + /// 20:00, "02–04" can only be tonight, but at 14:00 "15–17" looks like + /// this afternoon. + func cheapest(_ block: PriceStrip.Block, from t: Double) -> String { + let a = wall(block.startMs, asEnd: false) + let b = wall(block.endMs, asEnd: true) + let whole = a.minute == 0 && b.minute == 0 + func short(_ w: Wall) -> String { whole ? String(format: "%02d", w.hour) : w.hm } + if block.startMs <= t { + return GlanceWord.cheapestNow.format(short(b)) + } + var range = "\(short(a))–\(short(b))" + let now = wall(t, asEnd: false) + if a.day > now.day, (a.hour, a.minute) > (now.hour, now.minute) { + range = GlanceWord.tomorrow.format(range) + } + return GlanceWord.cheapest.format(range) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift b/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift new file mode 100644 index 0000000..12dd383 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift @@ -0,0 +1,35 @@ +import Foundation + +/// When the app asks iOS to wake it for tomorrow's prices. +/// +/// The day-ahead market publishes tomorrow around 12:45, and the box asks +/// it once an hour, so the box holds tomorrow some time between then and +/// about 13:45. The app asks to run just after 13:05, and again every half +/// hour until the box has tomorrow. iOS decides when, or whether, it runs: +/// this is the earliest, never a promise. +public enum PriceRefresh { + public static let hour = 13 + public static let minute = 5 + /// How soon to ask again when the box did not have tomorrow yet. + public static let retryMs: Double = 30 * 60_000 + /// No retries after this hour; the next try is tomorrow's. + public static let lastRetryHour = 20 + + /// The earliest the next refresh should run, given when the prices the + /// widgets hold run out. + public static func next(afterMs nowMs: Double, pricesEndMs: Double?, calendar: Calendar = .current) -> Double { + let now = Date(timeIntervalSince1970: nowMs / 1000) + let today = calendar.startOfDay(for: now) + func at(_ day: Int, _ h: Int, _ m: Int) -> Double { + let d = calendar.date(byAdding: .day, value: day, to: today) ?? today + let t = calendar.date(bySettingHour: h, minute: m, second: 0, of: d) ?? d + return t.timeIntervalSince1970 * 1000 + } + let publish = at(0, hour, minute) + if nowMs < publish { return publish } + let endOfTomorrow = at(2, 0, 0) + if let end = pricesEndMs, end >= endOfTomorrow { return at(1, hour, minute) } + let retry = nowMs + retryMs + return retry <= at(0, lastRetryHour, 0) ? retry : at(1, hour, minute) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 8d2de29..3f30126 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -26,3 +26,20 @@ "%ld min ago" = "för %ld min sedan"; "%ld h ago" = "för %ld h sedan"; "%ld d ago" = "för %ld d sedan"; + +/* Price widgets */ +"Electricity price" = "Elpris"; +"The price now and when it is cheapest next, from what FTW last fetched." = "Priset nu och när det blir billigast härnäst, från det FTW senast hämtade."; +"Price now" = "Pris nu"; +"Cheapest %@" = "Billigast %@"; +"Cheapest now until %@" = "Billigast nu till %@"; +"tomorrow %@" = "i morgon %@"; +"No prices after %@ · open FTW" = "Inga priser efter %@ · öppna FTW"; +"No price until %@ · open FTW" = "Inget pris förrän %@ · öppna FTW"; +"Open FTW to see prices" = "Öppna FTW för att se priser"; +"Fetched %@" = "Hämtat %@"; +"incl. grid fee and VAT" = "inkl. nätavgift och moms"; +"incl. VAT" = "inkl. moms"; +"incl. grid fee" = "inkl. nätavgift"; +"spot price only" = "endast spotpris"; +"incl. surcharges" = "inkl. påslag"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Words.swift b/appleApp/FTWKit/Sources/FTWShared/Words.swift index 4e011c6..2cb7302 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Words.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Words.swift @@ -7,11 +7,19 @@ import Foundation /// the widget list both languages in `CFBundleLocalizations`; without that, /// iOS shows a package in the app's development language only. enum Words { + static var bundle: Bundle { .module } + static func text(_ key: String) -> String { - Bundle.module.localizedString(forKey: key, value: key, table: nil) + bundle.localizedString(forKey: key, value: key, table: nil) } static func format(_ key: String, _ args: CVarArg...) -> String { String(format: text(key), arguments: args) } + + /// The language the words resolve to, for the names the system spells, + /// such as weekdays, so a Swedish sentence never names an English day. + static var locale: Locale { + Locale(identifier: Bundle.module.preferredLocalizations.first ?? "en") + } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift new file mode 100644 index 0000000..4466e09 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift @@ -0,0 +1,60 @@ +import Foundation +import FTWDemo +import FTWShared +import Testing +@testable import FTWKit + +/// The background refresh that leaves tomorrow's prices for the widgets. +@MainActor +@Suite struct WidgetPricesTests { + let scheduler = ManualScheduler() + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)")) + + func site(wakes: Counter) -> SiteModel { + let site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: nil, shared: shared) + site.sharedWritten = { wakes.count += 1 } + site.start() + return site + } + + func run(_ ms: Double, step: Double = 20) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + + @Test func aBackgroundRefreshLeavesPricesAndWakesTheWidgets() async throws { + let wakes = Counter() + let site = site(wakes: wakes) + site.connect(SimulatedBox.loopback(scheduler)) + let asked = scheduler.nowMs + let task = Task { await site.refreshSharedPrices() } + await run(2_000) + let written = try #require(await task.value) + #expect(!written.slots.isEmpty) + + let read = try #require(CacheReader(sources: [shared]).read(PriceEntry.self, siteId: "home")) + #expect(read.value.prices == written) + #expect(read.carrier == .cache) + #expect(read.record.readAtMs >= asked && read.record.readAtMs <= scheduler.nowMs) + #expect(wakes.count == 1) + #expect(site.sharedPricesEndMs == written.slots.map { $0.startMs + $0.durationMs }.max()) + } + + /// No stream, no write: the widgets keep what they had, and the task + /// ends on time rather than holding the wake. + @Test func withoutAStreamItGivesUpAndWritesNothing() async { + let wakes = Counter() + let site = site(wakes: wakes) + let task = Task { await site.refreshSharedPrices(waitMs: 5_000) } + await run(5_500, step: 250) + #expect(await task.value == nil) + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(wakes.count == 0) + #expect(site.sharedPricesEndMs == nil) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift new file mode 100644 index 0000000..0e03bf1 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift @@ -0,0 +1,266 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The price widgets' timeline, worked out from the cache alone. Times are +/// Stockholm's; 12 October 2026 is a Monday. These read English: the tests +/// run in the development language. +@Suite struct PriceGlanceTests { + static let hourMs: Double = 3_600_000 + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Stockholm")! + return c + }() + + /// Wall clock in Stockholm, as ms. + func at(_ day: Int, _ hour: Int, _ minute: Int = 0, month: Int = 10) -> Double { + calendar.date(from: DateComponents(year: 2026, month: month, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + /// Slots back to back from `fromMs`, each `dtMs` long, priced by index. + func slots(from fromMs: Double, count: Int, dtMs: Double = hourMs, total: (Int) -> Double = { _ in 50 }) -> [PriceSlot] { + (0.. CacheRead { + let prices = Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false) + let record = CacheRecord(siteId: "home", carrier: carrier, srcState: srcState, readAtMs: readAtMs, value: PriceEntry(fromMs: slots.first?.startMs ?? 0, prices: prices)) + return CacheRead(record: record, origin: .app) + } + + // MARK: The timeline + + @Test func oneMomentNowAndOneAtEveryHourTheBoxPriced() { + let fetched = at(12, 13, 5) + let r = read(slots(from: at(12, 0), count: 48), readAtMs: fetched) + let moments = PriceGlance.moments(r, nowMs: at(12, 13, 30), calendar: calendar) + #expect(moments.first?.atMs == at(12, 13, 30)) + #expect(moments.count == 1 + 35) + let steps = zip(moments.dropFirst(), moments.dropFirst(2)).map { $1.atMs - $0.atMs } + #expect(steps.allSatisfy { $0 == Self.hourMs }) + #expect(moments.last?.atMs == at(14, 0)) + } + + /// Quarter-hour prices give a moment every quarter hour, and a box that + /// mixes resolutions is followed slot by slot. + @Test func theTimelineFollowsTheResolutionTheBoxSent() { + let quarter = Self.hourMs / 4 + let today = slots(from: at(12, 0), count: 24) + let tomorrow = slots(from: at(13, 0), count: 96, dtMs: quarter) + let moments = PriceGlance.moments(read(today + tomorrow, readAtMs: at(12, 13, 5)), nowMs: at(12, 22, 30), calendar: calendar) + let times = moments.map(\.atMs) + #expect(times.prefix(3) == [at(12, 22, 30), at(12, 23), at(13, 0)]) + #expect(times.contains(at(13, 0, 15))) + let tail = zip(times.dropFirst(2), times.dropFirst(3)).map { $1 - $0 } + #expect(tail.allSatisfy { $0 == quarter }) + #expect(moments.last?.state == .ended(lastMs: at(14, 0))) + } + + @Test func eachMomentShowsTheSlotItFallsIn() { + let r = read(slots(from: at(12, 0), count: 24) { Double(10 + $0) }, readAtMs: at(12, 6)) + for m in PriceGlance.moments(r, nowMs: at(12, 9, 40), calendar: calendar) where m.state == .price { + let hour = calendar.component(.hour, from: Date(timeIntervalSince1970: m.atMs / 1000)) + #expect(m.current?.totalMinor == Double(10 + hour)) + #expect(m.priceText == PriceStrip.text(Double(10 + hour), "SEK")) + } + } + + // MARK: When the prices run out + + @Test func theLastMomentSaysWhenThePricesRanOut() throws { + let r = read(slots(from: at(12, 0), count: 18), readAtMs: at(12, 8)) + let last = try #require(PriceGlance.moments(r, nowMs: at(12, 9), calendar: calendar).last) + #expect(last.atMs == at(12, 18)) + #expect(last.state == .ended(lastMs: at(12, 18))) + #expect(last.isEmpty) + #expect(last.priceText == nil) + #expect(last.cheapestText == nil) + #expect(last.curve.isEmpty) + #expect(last.emptyText == "No prices after 18:00 · open FTW") + #expect(last.inlineText == "No prices after 18:00 · open FTW") + } + + /// Nothing after the end is priced: not the last hour stretched, not + /// yesterday's day started over. + @Test func noMomentAfterTheEndShowsAPrice() { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5)) + for h in 0..<72 { + let m = PriceGlance.moment(r, atMs: at(14, 0) + Double(h) * Self.hourMs + 1, calendar: calendar) + #expect(m.state == .ended(lastMs: at(14, 0))) + #expect(m.current == nil && m.priceText == nil && m.curve.isEmpty) + } + } + + /// The proof case: fetched Monday just after the market published, and + /// not opened since. Thirty-six hours on, the widget is grey and says + /// which midnight the prices ended at. + @Test func thirtySixHoursLaterTheWidgetIsGrey() throws { + let fetched = at(12, 13, 5) + let r = read(slots(from: at(12, 0), count: 48), readAtMs: fetched) + let later = fetched + 36 * Self.hourMs + let moments = PriceGlance.moments(r, nowMs: later, calendar: calendar) + #expect(moments.count == 1) + let m = try #require(moments.first) + #expect(m.state == .ended(lastMs: at(14, 0))) + #expect(m.emptyText == "No prices after Tue 24:00 · open FTW") + #expect(m.fetchedText == "Fetched Mon 13:05") + #expect(m.carrier == .cache) + } + + /// An end at midnight is 24:00 of the day it closes, also on the day the + /// clocks go back and the day has 25 hours. + @Test func midnightReadsAsTheEndOfTheDayAcrossTheClockChange() throws { + let day = at(25, 0) + let next = at(26, 0) + #expect(next - day == 25 * Self.hourMs) + let r = read(slots(from: day, count: 25), readAtMs: day) + let last = try #require(PriceGlance.moments(r, nowMs: at(25, 20), calendar: calendar).last) + #expect(last.atMs == next) + #expect(last.emptyText == "No prices after Sun 24:00 · open FTW") + #expect(PriceGlance.moment(r, atMs: at(25, 22, 30), calendar: calendar).cheapestText == "Cheapest now until 24") + } + + /// One failed fetch at midday leaves a hole. The widget says so for as + /// long as it lasts and picks the prices up again after it. + @Test func aHoleIsSaidNotFilled() { + let morning = slots(from: at(12, 0), count: 6) + let afternoon = slots(from: at(12, 12), count: 12) + let moments = PriceGlance.moments(read(morning + afternoon, readAtMs: at(12, 5)), nowMs: at(12, 5, 30), calendar: calendar) + let gap = moments.first { $0.atMs == at(12, 6) } + #expect(gap?.state == .gap(nextMs: at(12, 12))) + #expect(gap?.emptyText == "No price until 12:00 · open FTW") + #expect(gap?.priceText == nil) + #expect(!moments.contains { $0.atMs > at(12, 6) && $0.atMs < at(12, 12) }) + #expect(moments.first { $0.atMs == at(12, 12) }?.state == .price) + } + + @Test func beforeTheFirstPriceTheWidgetWaitsForIt() { + let r = read(slots(from: at(12, 6), count: 18), readAtMs: at(12, 5)) + let m = PriceGlance.moment(r, atMs: at(12, 5), calendar: calendar) + #expect(m.state == .gap(nextMs: at(12, 6))) + #expect(m.emptyText == "No price until 06:00 · open FTW") + } + + // MARK: Cheapest next + + /// A day that is dear until midnight and cheapest from 02 to 04. + func night(dtMs: Double = hourMs, cheapFrom: Double, cheapTo: Double) -> [PriceSlot] { + let start = at(12, 0) + let count = Int((at(14, 0) - start) / dtMs) + return slots(from: start, count: count, dtMs: dtMs) { i in + let t = start + Double(i) * dtMs + return t >= cheapFrom && t < cheapTo ? 5 : 80 + Double(i % 7) + } + } + + @Test func cheapestNextNamesTheTwoHoursAhead() { + let r = read(night(cheapFrom: at(13, 2), cheapTo: at(13, 4)), readAtMs: at(12, 13, 5)) + let evening = PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar) + #expect(evening.cheapest?.startMs == at(13, 2)) + #expect(evening.cheapestText == "Cheapest 02–04") + #expect(evening.inlineText == "\(evening.priceText!) öre · Cheapest 02–04") + // Inside the block it is now, and says until when. + #expect(PriceGlance.moment(r, atMs: at(13, 2, 30), calendar: calendar).cheapestText == "Cheapest now until 04") + } + + @Test func cheapestFollowsQuarterHours() { + let quarter = Self.hourMs / 4 + let r = read(night(dtMs: quarter, cheapFrom: at(13, 2, 15), cheapTo: at(13, 4, 15)), readAtMs: at(12, 13, 5)) + #expect(PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar).cheapestText == "Cheapest 02:15–04:15") + } + + /// At 14:00, "15–17" would read as this afternoon. Tomorrow's is named. + @Test func tomorrowIsNamedOnlyWhenTheClockWouldMislead() { + let r = read(night(cheapFrom: at(13, 15), cheapTo: at(13, 17)), readAtMs: at(12, 13, 5)) + #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestText == "Cheapest tomorrow 15–17") + // The same block seen the next morning is today's. + #expect(PriceGlance.moment(r, atMs: at(13, 9), calendar: calendar).cheapestText == "Cheapest 15–17") + } + + @Test func noCheapestWhenLessThanTwoHoursArePriced() { + let r = read(slots(from: at(12, 0), count: 24), readAtMs: at(12, 8)) + #expect(PriceGlance.moment(r, atMs: at(12, 23, 30), calendar: calendar).cheapestText == nil) + #expect(PriceGlance.moment(r, atMs: at(12, 22, 30), calendar: calendar).cheapestText == "Cheapest now until 24") + } + + @Test func theCurveReachesTwelveHoursAhead() throws { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5)) + let m = PriceGlance.moment(r, atMs: at(12, 13, 30), calendar: calendar) + #expect(m.curve.first?.startMs == at(12, 13)) + #expect(m.curve.first?.current == true) + #expect(m.curve.count == 13) + #expect(try #require(m.curve.last).startMs < at(12, 13, 30) + PriceGlance.curveMs) + } + + // MARK: What the total includes + + /// Core's formula, rounded once on the wire as Core rounds it. + func core(spots: [Double], fee: Double, vat: Double) -> [PriceSlot] { + spots.enumerated().map { i, spot in + PriceSlot(startMs: Double(i) * Self.hourMs, durationMs: Self.hourMs, spotMinor: spot.rounded(), totalMinor: ((spot + fee) * (1 + vat / 100)).rounded()) + } + } + + @Test func theTotalSaysWhatItIncludes() { + let day = (0..<24).map { 30 + 70 * sin(Double($0) / 4) + Double($0) * 0.37 } + #expect(PriceGlance.included(core(spots: day, fee: 45, vat: 25)) == .gridFeeAndVAT) + #expect(PriceGlance.included(core(spots: day, fee: 0, vat: 25)) == .vat) + #expect(PriceGlance.included(core(spots: day, fee: 45, vat: 0)) == .gridFee) + #expect(PriceGlance.included(core(spots: day, fee: 0, vat: 0)) == .spotOnly) + // Cents move little over a day; the line still holds. + let cents = (0..<96).map { 6 + 4 * sin(Double($0) / 12) } + #expect(PriceGlance.included(core(spots: cents, fee: 3, vat: 24)) == .gridFeeAndVAT) + #expect(PriceGlance.included(core(spots: cents, fee: 0, vat: 24)) == .vat) + // A flat market cannot tell a fee from a percentage. + #expect(PriceGlance.included(core(spots: Array(repeating: 50, count: 24), fee: 45, vat: 25)) == .surcharges) + #expect(PriceGlance.text(.gridFeeAndVAT) == "incl. grid fee and VAT") + #expect(PriceGlance.text(.spotOnly) == "spot price only") + } + + // MARK: Freshness + + /// A record read back is the cache, whatever carried it and however + /// healthy the box was then. No moment claims live. + @Test func aCachedPriceNeverLooksLive() { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5), carrier: .relay, srcState: .live) + let moments = PriceGlance.moments(r, nowMs: at(12, 13, 30), calendar: calendar) + for m in moments { + #expect(m.carrier == .cache) + #expect(m.srcState == .live) + #expect(m.readAtMs == at(12, 13, 5)) + let words = [m.priceText, m.cheapestText, m.emptyText, m.fetchedText, m.includedText, m.inlineText].compactMap { $0 } + #expect(!words.contains { $0.lowercased().contains("live") }) + } + #expect(moments.first?.fetchedText == "Fetched 13:05") + // An answer the box itself called short is kept as such. + let short = read(slots(from: at(12, 0), count: 24), readAtMs: at(12, 8), srcState: .stale) + #expect(PriceGlance.moment(short, atMs: at(12, 9), calendar: calendar).srcState == .stale) + } + + @Test func withNothingCachedTheWidgetAsksForTheApp() { + let moments = PriceGlance.moments(nil, nowMs: at(12, 9), calendar: calendar) + #expect(moments.count == 1) + #expect(moments[0].state == .nothing) + #expect(moments[0].carrier == .none) + #expect(moments[0].srcState == .never) + #expect(moments[0].emptyText == "Open FTW to see prices") + } + + // MARK: Words + + /// Every sentence a widget shows has a Swedish line with the same + /// placeholders. + @Test func everyWidgetWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Localizable", withExtension: "strings", subdirectory: nil, localization: "sv")) + let table = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in GlanceWord.allCases { + let sv = try #require(table[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(sv.components(separatedBy: "%@").count == word.rawValue.components(separatedBy: "%@").count) + #expect(sv != word.rawValue) + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift new file mode 100644 index 0000000..f4d3f59 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import FTWShared + +/// When the app asks iOS to wake it for tomorrow's prices. +@Suite struct PriceRefreshTests { + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Stockholm")! + return c + }() + + func at(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Double { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + @Test func beforeTheMarketPublishesItWaitsForFivePastOne() { + #expect(PriceRefresh.next(afterMs: at(12, 8), pricesEndMs: at(13, 0), calendar: calendar) == at(12, 13, 5)) + } + + @Test func withTomorrowInHandTheNextIsTomorrows() { + #expect(PriceRefresh.next(afterMs: at(12, 13, 20), pricesEndMs: at(14, 0), calendar: calendar) == at(13, 13, 5)) + } + + /// The box asks the market once an hour, so at 13:05 it may not have + /// tomorrow yet. Ask again in half an hour, until the evening. + @Test func withoutTomorrowItAsksAgainSoon() { + #expect(PriceRefresh.next(afterMs: at(12, 13, 10), pricesEndMs: at(13, 0), calendar: calendar) == at(12, 13, 40)) + #expect(PriceRefresh.next(afterMs: at(12, 13, 10), pricesEndMs: nil, calendar: calendar) == at(12, 13, 40)) + #expect(PriceRefresh.next(afterMs: at(12, 19, 50), pricesEndMs: at(13, 0), calendar: calendar) == at(13, 13, 5)) + } + + /// 13:05 on the household's clock, also across the night the clocks go + /// back. + @Test func fivePastOneIsWallClockAcrossTheClockChange() { + let next = PriceRefresh.next(afterMs: at(24, 14), pricesEndMs: at(26, 0), calendar: calendar) + #expect(next == at(25, 13, 5)) + #expect(next - at(24, 13, 5) == 25 * 3_600_000) + } +} diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift new file mode 100644 index 0000000..89f0e49 --- /dev/null +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -0,0 +1,227 @@ +import Charts +import FTWShared +import SwiftUI +import WidgetKit + +/// One moment of the price widget, in whichever family it sits. Every +/// sentence comes from `PriceGlance`; this file only lays them out. +struct PriceWidgetView: View { + let moment: PriceGlance.Moment + @Environment(\.widgetFamily) private var family + + var body: some View { + content + .containerBackground(for: .widget) { background } + } + + /// Grey when there is no price for this moment. The lock screen draws + /// its own, so the accessory families bring none. + private var background: Color { + switch family { + case .accessoryRectangular, .accessoryInline, .accessoryCircular: return .clear + default: return moment.isEmpty ? Tone.empty : Tone.surface + } + } + + @ViewBuilder private var content: some View { + switch family { + case .systemMedium: MediumPrice(moment: moment) + case .accessoryRectangular: RectangularPrice(moment: moment) + case .accessoryInline: Text(moment.inlineText) + default: SmallPrice(moment: moment) + } + } +} + +/// The home screen's small widget, and StandBy's. +struct SmallPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let price = moment.priceText { + Text(PriceGlance.priceNow).font(.caption).foregroundStyle(.secondary) + PriceFigure(price: price, size: 38) + Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.8) + Spacer(minLength: 4) + Cheapest(moment: moment) + Fetched(moment: moment) + } else { + Empty(moment: moment) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .combine) + } +} + +/// The figures on the left and the next twelve hours on the right. +struct MediumPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + if let price = moment.priceText { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(PriceGlance.priceNow).font(.caption).foregroundStyle(.secondary) + PriceFigure(price: price, size: 34) + Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(2).minimumScaleFactor(0.8) + Spacer(minLength: 4) + Cheapest(moment: moment) + Fetched(moment: moment) + } + .frame(width: 128, alignment: .leading) + .accessibilityElement(children: .combine) + Curve(moment: moment) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + VStack(alignment: .leading, spacing: 2) { Empty(moment: moment) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +/// The lock screen's rectangle: price, cheapest, what the price holds. +struct RectangularPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let price = moment.priceText { + Text("\(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + .widgetAccentable() + if let cheapest = moment.cheapestText { + Text(cheapest).font(.caption) + } + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary) + } else { + Text(moment.emptyText ?? "").font(.caption).foregroundStyle(.secondary).lineLimit(3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +// MARK: Parts + +struct PriceFigure: View { + let price: String + let size: CGFloat + + var body: some View { + Text(price) + .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .minimumScaleFactor(0.5) + .lineLimit(1) + .widgetAccentable() + } +} + +struct Cheapest: View { + let moment: PriceGlance.Moment + + var body: some View { + if let text = moment.cheapestText { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(Tone.cheap) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } +} + +/// When the app fetched these prices. A widget only ever shows the cache. +struct Fetched: View { + let moment: PriceGlance.Moment + + var body: some View { + if let text = moment.fetchedText { + Text(text).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + } + } +} + +/// No price for this moment: say why and send the reader to the app. +struct Empty: View { + let moment: PriceGlance.Moment + + var body: some View { + Text(PriceGlance.title).font(.caption).foregroundStyle(.secondary) + Spacer(minLength: 4) + Text(moment.emptyText ?? "") + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + Fetched(moment: moment) + } +} + +/// The next twelve hours as bars from zero, at the resolution the box sent: +/// green below the average ahead, red above, the current slot solid. +struct Curve: View { + let moment: PriceGlance.Moment + + var body: some View { + Chart { + ForEach(moment.curve) { bar in + BarMark( + xStart: .value("From", date(bar.startMs)), + xEnd: .value("To", date(bar.endMs)), + y: .value("Price", PriceUnits.display(bar.minor, moment.currency)) + ) + .foregroundStyle(Tone.of(bar.tone)) + .opacity(bar.current ? 1 : 0.55) + } + if let mean = moment.meanMinor { + RuleMark(y: .value("Average", PriceUnits.display(mean, moment.currency))) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3])) + .foregroundStyle(.secondary) + } + } + .chartXAxis { + AxisMarks(values: .stride(by: .hour, count: 3)) { _ in + AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .omitted))) + } + } + .chartYAxis(.hidden) + .accessibilityHidden(true) + } + + private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } +} + +/// The app's colour roles (FTW/UI/Theme.swift, from the web app's tokens), +/// the few a price widget needs. +enum Tone { + static let surface = pair(0xFAFAF8, 0x161616) + static let empty = pair(0xDCDCD6, 0x2A2A2A) + static let cheap = pair(0x009639, 0x5FD37F) + static let dear = pair(0xCC243D, 0xFF6E74) + static let negative = pair(0xD48500, 0xFFB000) + static let flat = pair(0x686862, 0x858585) + + static func of(_ tone: PriceStrip.Tone) -> Color { + switch tone { + case .cheap: return cheap + case .dear: return dear + case .flat: return flat + case .negative: return negative + } + } + + private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { + Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) + } +} + +private extension UIColor { + convenience init(rgb: UInt32) { + self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) + } +} diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift index 586246d..2e42dc2 100644 --- a/appleApp/FTWWidgets/PriceWidget.swift +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -1,7 +1,10 @@ import FTWShared +import os import SwiftUI import WidgetKit +private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + @main struct FTWWidgets: WidgetBundle { var body: some Widget { @@ -9,105 +12,64 @@ struct FTWWidgets: WidgetBundle { } } -/// The price now and the cheapest two hours ahead, from what the app left in -/// the App Group. A widget opens no connection and holds no key. +/// The price now, when it is cheapest next, and a short curve, from what the +/// app left in the App Group. A widget opens no connection and holds no key. +/// Prices are not secret, so the lock screen shows them as they are. struct PriceWidget: Widget { var body: some WidgetConfiguration { - StaticConfiguration(kind: "energy.ftw.prices", provider: PriceTimeline()) { moment in - PriceWidgetView(moment: moment) - .containerBackground(.fill.tertiary, for: .widget) + StaticConfiguration(kind: "energy.ftw.prices", provider: PriceTimeline()) { entry in + PriceWidgetView(moment: entry.moment) } - .configurationDisplayName("Electricity price") - .description("The price now and the cheapest two hours ahead.") - .supportedFamilies([.systemSmall]) + .configurationDisplayName(PriceGlance.title) + .description(PriceGlance.summary) + // systemSmall is also the StandBy widget. + .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline]) } } -struct PriceMoment: TimelineEntry { +struct PriceWidgetEntry: TimelineEntry { let date: Date - /// Nil when the app has left nothing: not paired, or signed out. - let read: CacheRead? + let moment: PriceGlance.Moment } struct PriceTimeline: TimelineProvider { - func placeholder(in context: Context) -> PriceMoment { - PriceMoment(date: .now, read: nil) + func placeholder(in context: Context) -> PriceWidgetEntry { + // Drawn redacted while the real entry loads, so only the shape shows. + let now = Date.now.timeIntervalSince1970 * 1000 + return PriceWidgetEntry(date: .now, moment: PriceGlance.moment(Self.sample(nowMs: now), atMs: now)) } - func getSnapshot(in context: Context, completion: @escaping (PriceMoment) -> Void) { - completion(moments(now: .now)[0]) + func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> Void) { + completion(entries(now: .now)[0]) } /// The app reloads every timeline after it writes, so nothing here asks - /// to be woken. - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - completion(Timeline(entries: moments(now: .now), policy: .never)) - } - - /// One moment now and one at every price boundary ahead. The last falls - /// where the prices end, so the widget says they ran out rather than - /// showing the last hour again. - func moments(now: Date) -> [PriceMoment] { - guard let reader = CacheReader.shared(), - let site = reader.latestSiteID(PriceEntry.self), - let read = reader.read(PriceEntry.self, siteId: site) else { - return [PriceMoment(date: now, read: nil)] + /// to be woken. The last entry falls where the prices end and stays. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entries = entries(now: .now) + if let last = entries.last?.moment { + log.info("price timeline: \(entries.count, privacy: .public) entries, last \(String(describing: last.state), privacy: .public)") } - let nowMs = now.timeIntervalSince1970 * 1000 - let ahead = Set(read.value.prices.slots.map { $0.startMs + $0.durationMs }.filter { $0 > nowMs }).sorted() - return [PriceMoment(date: now, read: read)] + ahead.map { PriceMoment(date: Date(timeIntervalSince1970: $0 / 1000), read: read) } + completion(Timeline(entries: entries, policy: .never)) } -} - -struct PriceWidgetView: View { - let moment: PriceMoment - var body: some View { - if let read = moment.read { - prices(read) - } else { - VStack(alignment: .leading, spacing: 4) { - Text("FTW").font(.headline) - Text("Open FTW to see prices.").font(.caption).foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } + private func entries(now: Date) -> [PriceWidgetEntry] { + let reader = CacheReader.shared() + let read = reader.flatMap { r in r.latestSiteID(PriceEntry.self).flatMap { r.read(PriceEntry.self, siteId: $0) } } + if reader == nil { log.error("price widget: no App Group, nothing to read") } + return PriceGlance.moments(read, nowMs: now.timeIntervalSince1970 * 1000) + .map { PriceWidgetEntry(date: Date(timeIntervalSince1970: $0.atMs / 1000), moment: $0) } } - private func prices(_ read: CacheRead) -> some View { - let prices = read.value.prices - let summary = PriceStrip.summary(prices, nowMs: moment.date.timeIntervalSince1970 * 1000) - return VStack(alignment: .leading, spacing: 2) { - Text("Price now").font(.caption).foregroundStyle(.secondary) - if let current = summary.current { - Text(PriceStrip.text(current.totalMinor, prices.currency)) - .font(.system(.largeTitle, design: .rounded).weight(.semibold).monospacedDigit()) - .minimumScaleFactor(0.6) - .lineLimit(1) - Text(PriceUnits.unit(prices.currency).perKwh).font(.caption2).foregroundStyle(.secondary) - Spacer(minLength: 2) - if let cheapest = summary.cheapest { - Text("Cheapest \(time(cheapest.startMs))–\(time(cheapest.endMs))") - .font(.caption2.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - } else { - Spacer(minLength: 2) - Text(endText(prices)).font(.caption).foregroundStyle(.secondary) - } - // Read back from the phone, never live: say when it was fetched. - Text("Updated \(time(read.record.readAtMs))").font(.caption2).foregroundStyle(.secondary) + /// A day's shape for the redacted placeholder. Never shown as a price. + static func sample(nowMs: Double) -> CacheRead { + let hour: Double = 3_600_000 + let start = (nowMs / hour).rounded(.down) * hour + let slots = (0..<12).map { i in + PriceSlot(startMs: start + Double(i) * hour, durationMs: hour, spotMinor: 40, totalMinor: 60 + Double((i * 37) % 50)) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private func endText(_ prices: Prices) -> String { - guard let last = prices.slots.map({ $0.startMs + $0.durationMs }).max() else { return "No prices · open FTW" } - return "No prices after \(time(last)) · open FTW" - } - - private func time(_ ms: Double) -> String { - Date(timeIntervalSince1970: ms / 1000).formatted(date: .omitted, time: .shortened) + let prices = Prices(zone: "", currency: "SEK", slots: slots, stale: false) + let record = CacheRecord(siteId: "", carrier: .none, srcState: .never, readAtMs: nowMs, value: PriceEntry(fromMs: start, prices: prices)) + return CacheRead(record: record, origin: .app) } } From 53ca882645a69fb10379824771366cddc8838972 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:01:20 +0200 Subject: [PATCH 05/25] Follow car commands to their outcome and read the box's clock The car view's rules, in FTWKit so they are tested: - Where the level comes from: from the car, from the car but older than five minutes ("last reported HH:MM"), set from this phone, FTW's estimate, or FTW's assumption of 20 % at plug-in. - A receipt for every command: sent, received (cmd.ack), done (cmd.result), or why not. Session.command gains onAck for this. - The ready time on the household clock, worked out the way Core does, so 07:00 stays 07:00 across 25 October and a box that kept the UTC minute shows the 06:00 it really uses. Goals go out as both time_of_day_min and time_of_day_min_utc. - Whether the car makes its goal, from the plan's own shortfall. - "Is FTW in control?" from Core's control feedback, never green from readings that are not live. - English and Swedish words for all of it. LoadpointsModel drops boost, solar-only and battery size from the daily path. Charge now holds the charger's full current with no time limit and no level gate. The demo box plugs in and out, has a car that reports its level, a plan with a shortfall, and a charger that takes a moment. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 140 ++- .../Sources/FTWDemo/SimulatedHouse.swift | 98 +- .../FTWKit/Sources/FTWKit/Format/EV.swift | 982 +++++++++++++++--- .../Sources/FTWKit/Protocol/Session.swift | 10 +- .../FTWKit/Sources/FTWKit/State/BoxAPI.swift | 36 +- .../FTWKit/State/LoadpointsModel.swift | 395 ++++--- .../Sources/FTWKit/State/NowModels.swift | 82 ++ .../Tests/FTWKitTests/AppModelTests.swift | 29 - .../FTWKit/Tests/FTWKitTests/CarTests.swift | 494 +++++++++ .../Tests/FTWKitTests/FormatTests.swift | 45 +- 10 files changed, 1880 insertions(+), 431 deletions(-) create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 1b3ecb5..2aa7a1f 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -27,6 +27,12 @@ public final class SimulatedBox { public var mute = false /// Ack commands but never report a result. public var neverConfirm = false + /// How long the box takes from ack to result: the demo waits as a + /// charger write does, so the receipt has a moment to show. + public var resultDelayMs: Double = 0 + /// The box the demo is showing, for the demo's own controls (plug the + /// car in or out). Nil outside the demo. + public private(set) static weak var current: SimulatedBox? public private(set) var receivedCommands: [String] = [] public private(set) var stepUpsSeen = 0 @@ -242,7 +248,14 @@ public final class SimulatedBox { } sendControl(Envelope(t: "cmd.ack", b: .map([("cmdId", .text(cmdId)), ("leaseId", .text("lease-\(cmdId.prefix(8))")), ("expiresAtMs", .number(uptimeMs + 60_000))]))) if neverConfirm { return } + if resultDelayMs > 0 { + scheduler.after(resultDelayMs) { [weak self] in self?.act(cmdId, op, args) } + } else { + act(cmdId, op, args) + } + } + private func act(_ cmdId: String, _ op: String, _ args: CBOR) { switch op { case Contract.opSetMode: guard let mode = args["mode"]?.string, Self.modes.contains(where: { $0.key == mode }) else { @@ -278,6 +291,7 @@ public final class SimulatedBox { } house.carSocFraction = max(0, min(1, args["soc"]?.double ?? house.carSocFraction)) house.carSocSource = "inferred" + house.carReportedAtMs = house.carReportedAtMs.map { min($0, scheduler.nowMs - 1) } result(cmdId, "applied", observed: house.carSocFraction) case Contract.opLoadpointSurplusOnlySet: house.surplusOnly = args["surplus_only"]?.bool ?? false @@ -402,14 +416,21 @@ public final class SimulatedBox { case ("GET", "/api/loadpoints"): return (200, ["enabled": true, "loadpoints": [loadpointJSON()]]) case ("GET", "/api/mpc/plan"): - return (200, ["plan": ["actions": []], "meta": ["replanning": false, "outdated": false]]) + var plan: JSON = ["actions": []] + if let p = house.carPlan(now) { plan = plan.setting("loadpoint_shortfall_wh", ["garage": .number(p.shortfallWh)]) } + return (200, ["plan": plan, "meta": ["replanning": false, "outdated": false]]) case ("POST", "/api/loadpoints/{id}/vehicle"): if let wh = body?["capacity_wh"]?.number, wh >= 1_000 { house.carCapacityWh = wh } return (200, ["ok": true]) case ("PUT", "/api/loadpoints/{id}/schedule"): + // The household clock when sent, else the UTC minute read on + // today's date: what Core does. + let today = Date(timeIntervalSince1970: now / 1000) + let clock = body?["time_of_day_min"]?.number.map { Int($0) } + ?? EVText.clockFromUTC(Int(body?["time_of_day_min_utc"]?.number ?? 300), at: today, zone: house.timeZone) house.schedule = ( body?["soc"]?.number ?? 0.8, - Int(body?["time_of_day_min_utc"]?.number ?? 300), + clock, body?["recurring"]?.bool ?? false, Int(body?["days"]?.number ?? 0), body?["surplus_unlock_bat_soc"]?.number ?? 0 @@ -425,6 +446,11 @@ public final class SimulatedBox { func loadpointJSON() -> JSON { let h = house + let now = scheduler.nowMs + // A car that reports counts as the car's word for five minutes, as + // in Core; after that its report is shown, dated, and FTW estimates. + var socSource = h.carSocSource + if let at = h.carReportedAtMs, socSource == "vehicle", now - at > 5 * 60_000 { socSource = "inferred" } var lp: JSON = [ "id": "garage", "driver_name": "easee", @@ -433,7 +459,7 @@ public final class SimulatedBox { "capacity_source": "user", "soc_retention": "session", "current_soc": .number(h.carSocFraction), - "soc_source": .string(h.carSocSource), + "soc_source": .string(h.carPluggedIn ? socSource : ""), "current_power_w": .number(h.evWatts), "delivered_wh_session": .number(h.sessionWh), "min_charge_w": 4_140, @@ -456,25 +482,50 @@ public final class SimulatedBox { "stop_reason": .string(h.lastBoostStop ?? ""), ], ] + if let at = h.carReportedAtMs, h.carPluggedIn { + let age = max(0, (now - at) / 1000).rounded() + lp = lp.setting("vehicle_driver", "demo_car") + lp = lp.setting("vehicle_soc", .number(h.carSocFraction)) + lp = lp.setting("vehicle_soc_age_s", .number(age)) + lp = lp.setting("vehicle_stale", .bool(age > 15 * 60)) + } if let manual = h.manualHoldW { + let since = h.manualSinceMs ?? now + let state: String + if manual == 0 { + state = "paused" + } else if h.evWatts > 100 { + state = "charging" + } else if !h.holdActing { + state = now - since < h.chargerRampMs / 2 ? "sent" : "accepted" + } else { + state = "not_drawing" + } lp = lp.setting("manual", [ "active": true, - "state": manual == 0 ? "paused" : (h.evWatts > 100 ? "charging" : "sent"), + "state": .string(state), "requested_w": .number(manual), "requested_a": .number((manual / 690).rounded()), + "started_at_ms": .number(since), ]) } if let s = h.schedule { lp = lp.setting("schedule", [ - "soc": .number(s.socFraction), "time_of_day_min_utc": .number(Double(s.minuteUTC)), + "soc": .number(s.socFraction), + // Today's UTC minute of the household clock, as Core answers. + "time_of_day_min_utc": .number(Double(EVText.minuteUTC(clock: s.clockMin, at: Date(timeIntervalSince1970: now / 1000), zone: h.timeZone))), "recurring": .bool(s.recurring), "days": .number(Double(s.days)), "surplus_unlock_bat_soc": .number(s.surplusUnlock), ]) - // The next cheap night, as one window. - let midnight = scheduler.nowMs - h.hourOfDay(scheduler.nowMs) * 3_600_000 - let start = midnight + (h.hourOfDay(scheduler.nowMs) >= 5 ? 25 : 1) * 3_600_000 - let needWh = max(0, (s.socFraction - h.carSocFraction) * h.carCapacityWh) - lp = lp.setting("plan_windows", needWh > 0 ? [["start_ms": .number(start), "end_ms": .number(start + needWh / 11_000 * 3_600_000), "wh": .number(needWh.rounded())]] : []) lp = lp.setting("target_soc", .number(s.socFraction)) + if let deadline = h.deadlineMs(now) { + lp = lp.setting("target_time", .string(ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: deadline / 1000)))) + } + } + if let p = h.carPlan(now), p.endMs > p.startMs, p.endMs > now { + lp = lp.setting("plan_windows", [["start_ms": .number(p.startMs.rounded()), "end_ms": .number(p.endMs.rounded()), "wh": .number(p.wh)]]) + lp = lp.setting("plan_next_start_ms", .number(p.startMs.rounded())) + lp = lp.setting("plan_next_end_ms", .number(p.endMs.rounded())) + lp = lp.setting("plan_total_wh", .number(p.wh)) } else { lp = lp.setting("plan_windows", []) } @@ -572,8 +623,73 @@ public final class LoopbackCarrier: Carrier { extension SimulatedBox { /// The demo as `AppModel` takes it: a fresh simulated home behind a - /// loopback carrier. + /// loopback carrier, a car just plugged in, and a box and charger that + /// take a moment to answer, as real ones do. public static func loopback(_ scheduler: Scheduler) -> Carrier { - LoopbackCarrier(box: SimulatedBox(scheduler: scheduler), scheduler: scheduler) + let box = SimulatedBox(scheduler: scheduler) + box.resultDelayMs = 900 + box.house.chargerRampMs = 4_000 + #if DEBUG + // For screenshots: `-FTWDemoCar unplugged` and the like. + if let name = UserDefaults.standard.string(forKey: "FTWDemoCar"), let car = DemoCar(rawValue: name) { + car.apply(to: box.house, nowMs: scheduler.nowMs) + } + #endif + current = box + return LoopbackCarrier(box: box, scheduler: scheduler) + } + + /// Plug the demo car in or out. + public func setCarPluggedIn(_ on: Bool) { + if on { house.plugIn() } else { house.unplug() } + } +} + +/// The car situations the demo can open in, so each screen of the car view +/// can be shown without a box. +public enum DemoCar: String, CaseIterable, Sendable { + /// Plugged in, unread: FTW assumes 20 %. The default. + case assumed + case unplugged + /// A level someone gave FTW, charging since. + case estimated + /// A car that reports its own level, just now. + case car + /// A car that last reported 40 minutes ago. + case carOld + /// A goal the night is too short for. + case late + /// No goal yet. + case noGoal + /// Charge now running. + case charging + + @MainActor + public func apply(to h: SimulatedHouse, nowMs: Double) { + switch self { + case .assumed: break + case .unplugged: h.unplug() + case .estimated: + h.carSocFraction = 0.46 + h.carSocSource = "inferred" + case .car: + h.carReportedAtMs = nowMs - 40_000 + h.carSocFraction = 0.64 + h.carSocSource = "vehicle" + case .carOld: + h.carReportedAtMs = nowMs - 40 * 60_000 + h.carSocFraction = 0.58 + h.carSocSource = "vehicle" + case .late: + h.carSocFraction = 0.35 + h.carSocSource = "inferred" + h.schedule = (0.8, 3 * 60, true, 0, 0) + case .noGoal: + h.schedule = nil + case .charging: + h.carSocFraction = 0.46 + h.carSocSource = "inferred" + h.manualHoldW = 11_000 + } } } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift index fa576c4..8f62cc7 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift @@ -22,19 +22,88 @@ public final class SimulatedHouse { public var modeKey = "planner_passive_arbitrage" - // The car. + // The car. It arrives plugged in and unread, so the box assumes 20 %, + // which is the moment the car view is for. public var carPluggedIn = true - public var carSocFraction: Double = 0.41 - public var carSocSource = "inferred" + public var carSocFraction: Double = 0.2 + public var carSocSource = "assumed" public var carCapacityWh: Double = 64_000 + /// A car that reports its own level: when it last did. Nil for a car + /// FTW cannot read. + public var carReportedAtMs: Double? public private(set) var evW: Double = 0 - public var manualHoldW: Double? + public var manualHoldW: Double? { + didSet { if manualHoldW != oldValue { manualSinceMs = manualHoldW == nil ? nil : lastStepMs } } + } + /// When the current hold began. The demo charger takes a few seconds to + /// act on it, as a real one does. + public private(set) var manualSinceMs: Double? + public var chargerRampMs: Double = 0 public var surplusOnly = false public var boostUntilMs: Double? public var boostReserve: Double = 0.3 public var lastBoostStop: String? - public var schedule: (socFraction: Double, minuteUTC: Int, recurring: Bool, days: Int, surplusUnlock: Double)? = (0.8, 5 * 60, true, 0b0011111, 0) - public private(set) var sessionWh: Double = 5_300 + /// The goal on the household clock, as Core keeps it. + public var schedule: (socFraction: Double, clockMin: Int, recurring: Bool, days: Int, surplusUnlock: Double)? = (0.8, 7 * 60, true, 0b0011111, 0) + public private(set) var sessionWh: Double = 0 + private var lastStepMs: Double = 0 + + /// The cable goes in: a new session, nothing known about the car. + public func plugIn() { + carPluggedIn = true + carSocFraction = 0.2 + carSocSource = carReportedAtMs == nil ? "assumed" : "vehicle" + sessionWh = 0 + manualHoldW = nil + } + + /// The cable comes out. An unplug releases a hold, as in Core. + public func unplug() { + carPluggedIn = false + manualHoldW = nil + boostUntilMs = nil + evW = 0 + } + + /// Whether the charger has acted on the hold yet. + public var holdActing: Bool { + guard let since = manualSinceMs else { return false } + return lastStepMs - since >= chargerRampMs + } + + /// The next time the goal's clock comes round on an allowed day. + public func deadlineMs(_ nowMs: Double) -> Double? { + guard let s = schedule else { return nil } + var cal = Calendar(identifier: .gregorian) + cal.timeZone = timeZone + let now = Date(timeIntervalSince1970: nowMs / 1000) + let today = cal.startOfDay(for: now) + for i in 0..<15 { + guard let day = cal.date(byAdding: .day, value: i, to: today), + let t = cal.date(bySettingHour: s.clockMin / 60, minute: s.clockMin % 60, second: 0, of: day) else { continue } + let iso = (cal.component(.weekday, from: day) + 5) % 7 + if s.days != 0, s.days & (1 << iso) == 0 { continue } + if t > now { return t.timeIntervalSince1970 * 1000 } + } + return nil + } + + public static let chargerW: Double = 11_000 + public static let chargeEfficiency = 0.92 + + /// The plan for the car: the night's cheap hours from 01:00, as many as + /// the goal needs, never past the deadline. `shortfallWh` is what the + /// battery still lacks at the deadline, the way Core's plan says it. + public func carPlan(_ nowMs: Double) -> (startMs: Double, endMs: Double, wh: Double, shortfallWh: Double)? { + guard carPluggedIn, manualHoldW == nil, !surplusOnly, let s = schedule, let deadline = deadlineMs(nowMs) else { return nil } + let needWh = max(0, (s.socFraction - carSocFraction) * carCapacityWh) + guard needWh > 1 else { return (nowMs, nowMs, 0, 0) } + let night = deadline - (hourOfDay(deadline) - 1) * 3_600_000 + let start = max(nowMs, night < deadline ? night : nowMs) + let hours = max(0, min((deadline - start) / 3_600_000, needWh / (Self.chargerW * Self.chargeEfficiency))) + let gained = hours * Self.chargerW * Self.chargeEfficiency + return (start, start + hours * 3_600_000, (hours * Self.chargerW).rounded(), max(0, needWh - gained).rounded()) + } // Energy since local midnight, integrated. public private(set) var todayImportWh: Double = 6_100 @@ -117,24 +186,27 @@ public final class SimulatedHouse { boostUntilMs = nil lastBoostStop = "expired" } + lastStepMs = nowMs if !carPluggedIn || carSocFraction >= 0.999 { evW = 0 } else if let hold = manualHoldW { - evW = hold + evW = holdActing ? hold : 0 } else if boostUntilMs != nil { evW = 7_400 } else if surplusOnly { let spare = max(0, -pvW - baseLoadW - 200) evW = spare >= 1_400 ? min(spare, 11_000).rounded() : 0 - } else if let schedule, carSocFraction < schedule.socFraction, isChargeHour(nowMs) { - evW = 11_000 + } else if let plan = carPlan(nowMs), nowMs >= plan.startMs, nowMs < plan.endMs { + evW = Self.chargerW } else { evW = 0 } if evW > 0 { let wh = evW * dtMs / 3_600_000 sessionWh += wh - carSocFraction = min(1, carSocFraction + wh * 0.92 / carCapacityWh) + carSocFraction = min(1, carSocFraction + wh * Self.chargeEfficiency / carCapacityWh) + // A car that reports keeps reporting while it charges. + if carReportedAtMs != nil, carSocSource == "vehicle" { carReportedAtMs = nowMs } } // The battery, by mode. @@ -175,12 +247,6 @@ public final class SimulatedHouse { if batteryW > 0 { todayChargedWh += batteryW * h } else { todayDischargedWh += -batteryW * h } } - /// The cheap night hours before the ready time. - func isChargeHour(_ ms: Double) -> Bool { - let h = hourOfDay(ms) - return h >= 1 && h < 5 - } - /// A day of the energy ledger, made up from the same model, for days /// before today. public func ledger(dayOffset: Int, nowMs: Double) -> (load: Double, pv: Double, imp: Double, exp: Double, charged: Double, discharged: Double) { diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 5ce6416..24df9ed 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -8,11 +8,17 @@ import FTWShared public struct Loadpoint: Equatable, Sendable, Identifiable { public struct Schedule: Equatable, Sendable { public var socPct: Double + /// The ready time as the box sends it: the UTC minute of day of that + /// time on today's date. public var timeOfDayMinUTC: Int + /// The ready time on the household clock, from a box that sends it. + public var clockMin: Int? public var surplusUnlockPct: Double public var recurring: Bool /// 7-bit weekday mask, bit 0 = Monday. Zero means every day. public var days: Int + /// The goal is the car's own limit rather than a percentage. + public var finishAtVehicleLimit: Bool } public struct Manual: Equatable, Sendable { @@ -46,13 +52,19 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { public var socPct: Double? public var socSource: String /// How old the car's own report of its level was when the box answered. - /// Sent only while the level comes from the car. public var vehicleSocAgeS: Double? + /// The car's own report, kept apart from the box's estimate. + public var vehicleSocPct: Double? + public var vehicleDriver: String + public var vehicleStale: Bool public var vehicleCapacityWh: Double? public var capacitySource: String public var socRetention: String public var chargingDeclined: Bool public var targetSocPct: Double? + /// The deadline the box plans for, on the phone's clock. + public var targetTimeMs: Double? + public var goalComplete: Bool public var sessionWh: Double public var minChargeW: Double? public var maxChargeW: Double? @@ -70,6 +82,7 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { public var gridDeferred: Bool public var planStartMs: Double? public var planEndMs: Double? + public var planTotalWh: Double? public var planPending: Bool public var planOutdated: Bool /// Whether this box reports its plan with the charger, in one read. @@ -102,6 +115,19 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { return (f > 1 ? f : f * 100).rounded() } + /// An RFC 3339 time from Go. Its zero time means none. + static func ms(_ v: JSON?) -> Double? { + guard let s = v?.string, !s.isEmpty else { return nil } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = f.date(from: s) ?? { + f.formatOptions = [.withInternetDateTime] + return f.date(from: s) + }() + guard let date, date.timeIntervalSince1970 > 946_684_800 else { return nil } + return date.timeIntervalSince1970 * 1000 + } + public init(_ w: JSON) { let num: (String) -> Double? = { w[$0]?.number } id = w["id"]?.string ?? "" @@ -117,11 +143,20 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { } socSource = w["soc_source"]?.string ?? "" vehicleSocAgeS = num("vehicle_soc_age_s") + vehicleDriver = w["vehicle_driver"]?.string ?? "" + vehicleStale = w["vehicle_stale"]?.bool == true + if let v = num("vehicle_soc"), v >= 0, v <= 1, !vehicleDriver.isEmpty { + vehicleSocPct = (v * 100).rounded() + } else { + vehicleSocPct = nil + } vehicleCapacityWh = num("vehicle_capacity_wh") capacitySource = w["capacity_source"]?.string ?? "" socRetention = w["soc_retention"]?.string ?? "" chargingDeclined = w["charging_declined"]?.bool == true targetSocPct = Loadpoint.pct(w["target_soc"]) ?? num("target_soc_pct") + targetTimeMs = Loadpoint.ms(w["target_time"]) + goalComplete = w["goal_complete"]?.bool == true sessionWh = max(0, (num("delivered_wh_session") ?? 0).rounded()) minChargeW = num("min_charge_w") maxChargeW = num("max_charge_w") @@ -151,6 +186,7 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { gridDeferred = w["grid_deferred"]?.bool == true planStartMs = num("plan_next_start_ms") planEndMs = num("plan_next_end_ms") + planTotalWh = num("plan_total_wh") planPending = w["plan_pending"]?.bool == true planOutdated = w["plan_outdated"]?.bool == true inlinePlan = (w["plan_pending"]?.bool != nil && w["plan_outdated"]?.bool != nil) || w["plan_windows"]?.array != nil @@ -173,19 +209,347 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { } else { boostStopReason = nil } - if let s = w["schedule"], s.object != nil, let minute = s["time_of_day_min_utc"]?.number, - let socPct = Loadpoint.pct(s["soc"]) ?? s["soc_pct"]?.number, socPct > 0 { - schedule = Schedule( - socPct: socPct, - timeOfDayMinUTC: Int(minute), - surplusUnlockPct: (s["surplus_unlock_bat_soc"]?.number ?? 0) * 100, - recurring: s["recurring"]?.bool == true, - days: Int(s["days"]?.number ?? 0) & 0x7f - ) + if let s = w["schedule"], s.object != nil, let minute = s["time_of_day_min_utc"]?.number { + let limit = s["finish_at_vehicle_limit"]?.bool == true + let socPct = Loadpoint.pct(s["soc"]) ?? s["soc_pct"]?.number + if limit || (socPct ?? 0) > 0 { + let clock = s["time_of_day_min"]?.number.map { Int($0) } + schedule = Schedule( + socPct: socPct ?? 100, + timeOfDayMinUTC: Int(minute), + clockMin: clock.flatMap { (0..<1440).contains($0) ? $0 : nil }, + surplusUnlockPct: (s["surplus_unlock_bat_soc"]?.number ?? 0) * 100, + recurring: s["recurring"]?.bool == true, + days: Int(s["days"]?.number ?? 0) & 0x7f, + finishAtVehicleLimit: limit + ) + } } } } +// MARK: - Words + +/// The app's own words in English or Swedish. The English sentence is the +/// key, so a missing translation shows English rather than a code. +/// +/// FTWShared keeps its Swedish in a strings file; FTWKit has no resources, +/// so its Swedish lives in the table below. Tests pin a language with +/// `AppWords.$language.withValue(.sv) { … }`. +public enum AppWords { + public enum Language: String, Sendable { + case en, sv + + /// The phone's choice among the two the app speaks. + public static var preferred: Language { + Bundle.preferredLocalizations(from: ["en", "sv"]).first == "sv" ? .sv : .en + } + } + + @TaskLocal public static var language: Language = .preferred + + public static func text(_ en: String) -> String { + language == .sv ? (sv[en] ?? en) : en + } + + /// `%@` in order, or `%1$@` by position, replaced with ready text. + public static func format(_ en: String, _ args: String...) -> String { + var out = text(en) + for (i, a) in args.enumerated() { out = out.replacingOccurrences(of: "%\(i + 1)$@", with: a) } + for a in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: a) + } + return out + } + + /// A decimal in the language's own way: "7.2" or "7,2". + public static func decimal(_ text: String) -> String { + language == .sv ? text.replacingOccurrences(of: ".", with: ",") : text + } + + /// "80 %", kept on one line. + public static func percent(_ v: Double) -> String { "\(Int(v.rounded()))\u{00A0}%" } + + /// "7.2 kW" or "7,2 kW". + public static func power(_ watts: Double) -> String { + let p = PowerFormat.parts(watts) + return "\(decimal(p.text))\u{00A0}\(p.unit)" + } + + /// "14 kWh", one decimal below ten. + public static func energy(_ wh: Double) -> String { + let kwh = max(0, wh) / 1000 + return "\(decimal(kwh >= 10 ? String(Int(kwh.rounded())) : PowerFormat.fixed(kwh, 1)))\u{00A0}kWh" + } + + /// "07:00" in a zone, the 24-hour clock both languages use for FTW. + public static func clock(_ ms: Double, zone: TimeZone) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let c = cal.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) + return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + } + + public static func clock(minute: Int) -> String { + String(format: "%02d:%02d", (minute / 60) % 24, minute % 60) + } + + /// "today 07:00", "tomorrow 07:00", "Tue 07:00", else "12 Oct 07:00". + public static func day(_ ms: Double, nowMs: Double, zone: TimeZone) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let at = Date(timeIntervalSince1970: ms / 1000) + let now = Date(timeIntervalSince1970: nowMs / 1000) + let days = cal.dateComponents([.day], from: cal.startOfDay(for: now), to: cal.startOfDay(for: at)).day ?? 0 + let time = clock(ms, zone: zone) + switch days { + case 0: return format("today %@", time) + case 1: return format("tomorrow %@", time) + case 2..<7: return "\(text(EVText.dayLabels[(cal.component(.weekday, from: at) + 5) % 7])) \(time)" + default: + let c = cal.dateComponents([.day, .month], from: at) + return "\(c.day ?? 1) \(text(monthLabels[((c.month ?? 1) + 11) % 12])) \(time)" + } + } + + static let monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + static let sv: [String: String] = [ + // Clock and calendar + "today %@": "i dag %@", + "tomorrow %@": "i morgon %@", + "Mon": "mån", "Tue": "tis", "Wed": "ons", "Thu": "tor", "Fri": "fre", "Sat": "lör", "Sun": "sön", + "Jan": "jan", "Feb": "feb", "Mar": "mar", "Apr": "apr", "May": "maj", "Jun": "jun", + "Jul": "jul", "Aug": "aug", "Sep": "sep", "Oct": "okt", "Nov": "nov", "Dec": "dec", + "every day": "varje dag", + "weekdays": "vardagar", + "weekends": "helger", + "once": "en gång", + "on weekdays": "vardagar", + "on weekends": "helger", + "on %@": "%@", + + // The car view + "Your car": "Bilen", + "Car": "Bilen", + "Plugged in": "Inkopplad", + "Not plugged in": "Inte inkopplad", + "How full is the car?": "Hur full är bilen?", + "Correct the level": "Rätta nivån", + "Plug in the car to tell FTW its level.": "Koppla in bilen för att ange nivån.", + "FTW takes a level only while the car is plugged in.": "FTW tar emot nivån bara när bilen är inkopplad.", + "Level unknown": "Nivån är okänd", + "From the car": "Från bilen", + "From the car, last reported %@": "Från bilen, senast rapporterad %@", + "You set %1$@ at %2$@": "Du angav %1$@ kl. %2$@", + "You set %1$@ at %2$@. FTW adds what has charged since.": "Du angav %1$@ kl. %2$@. FTW räknar in det som laddats sedan.", + "FTW's estimate from energy delivered": "FTW:s uppskattning från laddad energi", + "FTW assumes %@. Nobody has told it the real level.": "FTW antar %@. Ingen har angett den riktiga nivån.", + "FTW assumes": "FTW antar", + "from the car": "från bilen", + "reported %@": "rapporterad %@", + "you set": "du angav", + "estimated": "uppskattad", + "not confirmed": "inte bekräftad", + "The car stopped charging. Its level is not confirmed.": "Bilen slutade ladda. Nivån är inte bekräftad.", + "Only correct it if the car shows something else.": "Rätta bara om bilen visar något annat.", + + "Goal": "Mål", + "Ready by": "Klar till", + "%1$@ by %2$@ %3$@": "%1$@ till %2$@ %3$@", + "Charge to the car's limit by %1$@ %2$@": "Ladda till bilens gräns till %1$@ %2$@", + "Next: %@": "Nästa gång: %@", + "Next: %@, home time": "Nästa gång: %@, hemmatid", + "No goal yet. Without one, FTW only charges from spare solar.": "Inget mål än. Utan mål laddar FTW bara med solöverskott.", + "Use %@": "Använd %@", + "Change": "Ändra", + "Save": "Spara", + "Cancel": "Avbryt", + "Remove goal": "Ta bort målet", + "Charge to": "Ladda till", + "Repeat": "Upprepa", + "Saving the goal…": "Sparar målet…", + "Goal saved. FTW is updating the plan.": "Målet är sparat. FTW räknar om planen.", + "Goal removed.": "Målet är borttaget.", + "Choose at least one day.": "Välj minst en dag.", + + "Updating the plan…": "Planen räknas om…", + "Charging times are unavailable. Your goal is saved.": "Laddtiderna saknas just nu. Målet är sparat.", + "Waiting for tomorrow's prices. Spare solar can charge meanwhile.": "Väntar på morgondagens priser. Solöverskott kan ladda under tiden.", + "Charging %1$@–%2$@, about %3$@.": "Laddar %1$@–%2$@, cirka %3$@.", + "Charging %1$@–%2$@, about %3$@, and %4$@ more times.": "Laddar %1$@–%2$@, cirka %3$@, och %4$@ gånger till.", + "No charging planned yet.": "Ingen laddning planerad än.", + "On track: %1$@ by %2$@.": "Hinner bli klar: %1$@ till %2$@.", + "Will not make it: about %1$@ by %2$@, not %3$@.": "Hinner inte: cirka %1$@ till %2$@, inte %3$@.", + "Goal reached.": "Målet är nått.", + "Counted from FTW's guess of %@.": "Räknat från FTW:s antagande, %@.", + "Counted from the level you gave.": "Räknat från nivån du angav.", + + "Charge now": "Ladda nu", + "Pause": "Pausa", + "Back to the plan": "Tillbaka till planen", + "Charge now runs until you go back to the plan or unplug.": "Ladda nu kör tills du går tillbaka till planen eller kopplar ur.", + "Sent": "Skickat", + "Received": "Mottaget", + "Done": "Utfört", + + // What the charger does + "Charger status is out of date.": "Laddarens status är inaktuell.", + "Waiting for the charger's first report.": "Väntar på laddarens första rapport.", + "Paused by you": "Pausad av dig", + "Charging now at %@": "Laddar nu med %@", + "Charging at %@": "Laddar med %@", + "Charge now sent to the charger": "Ladda nu är skickat till laddaren", + "The charger is starting…": "Laddaren startar…", + "The car is not taking power. Check the car's charge limit.": "Bilen tar ingen ström. Kolla bilens laddgräns.", + "The charger has not responded. Check the charger.": "Laddaren har inte svarat. Kolla laddaren.", + "The main fuse limits charging right now.": "Huvudsäkringen begränsar laddningen just nu.", + "The charger limits the current.": "Laddaren begränsar strömmen.", + "Pause sent. Waiting for the charger to stop.": "Paus skickad. Väntar på att laddaren stannar.", + "The car stopped asking for power.": "Bilen vill inte ta mer ström.", + "Paused for safety: the electricity meter is not reporting.": "Pausad för säkerhets skull: elmätaren rapporterar inte.", + "Paused by the main fuse. It resumes on its own.": "Pausad av huvudsäkringen. Den fortsätter av sig själv.", + "Plugged in · charges at %@": "Inkopplad · laddar kl. %@", + "Plugged in, not charging now": "Inkopplad, laddar inte nu", + "%@ this session": "%@ den här laddningen", + + // A command's outcome + "Sending %@ to FTW…": "Skickar %@ till FTW…", + "FTW received %@.": "FTW har tagit emot %@.", + "FTW now plans from %@.": "FTW planerar nu från %@.", + "FTW kept %@. A newer level came first.": "FTW behöll %@. Ett nyare värde kom före.", + "FTW received %@ but has not confirmed it yet.": "FTW har tagit emot %@ men inte bekräftat det än.", + "Sending Charge now…": "Skickar Ladda nu…", + "Sending Pause…": "Skickar Pausa…", + "Sending Back to the plan…": "Skickar Tillbaka till planen…", + "FTW received it.": "FTW har tagit emot det.", + "FTW received it but has not confirmed the result yet.": "FTW har tagit emot det men inte bekräftat resultatet än.", + "FTW started charging. Waiting for the car to draw power.": "FTW har startat laddningen. Väntar på att bilen tar ström.", + "%@. The plan waits until you go back to it.": "%@. Planen väntar tills du går tillbaka till den.", + "Paused. The car will not charge until you choose.": "Pausad. Bilen laddar inte förrän du väljer.", + "Back to the plan. FTW decides when to charge.": "Tillbaka till planen. FTW bestämmer när bilen laddar.", + "Something else changed charging first. Try again.": "Något annat ändrade laddningen först. Försök igen.", + + // Why not + "Plug the car in first. FTW takes a level only while the car is plugged in.": "Koppla in bilen först. FTW tar emot nivån bara när bilen är inkopplad.", + "FTW cannot reach the charger right now. Try again shortly.": "FTW når inte laddaren just nu. Försök igen om en stund.", + "Something changed at home while this was sending. Try again.": "Något ändrades hemma medan det skickades. Försök igen.", + "Only the home's owner can control charging.": "Bara hemmets ägare kan styra laddningen.", + "That took too long to reach FTW. Try again.": "Det tog för lång tid att nå FTW. Försök igen.", + "FTW is starting. Give it a minute.": "FTW startar. Vänta en minut.", + "Your box cannot do this yet. It may need an update.": "Din box kan inte det här än. Den kan behöva uppdateras.", + "That did not go through. Try again.": "Det gick inte igenom. Försök igen.", + "Did not reach FTW. Try again.": "Nådde inte FTW. Försök igen.", + "Not connected to FTW right now. Try again when the connection is back.": "Inte ansluten till FTW just nu. Försök igen när anslutningen är tillbaka.", + "FTW received it but could not save it for a restart.": "FTW har tagit emot det men kunde inte spara det inför en omstart.", + + // The box's API, said for people + "Your box doesn't have that yet — it may be running older software.": "Din box har inte det här än. Den kan köra äldre programvara.", + "Your box is busy with something else. This will fill in shortly.": "Din box är upptagen med något annat. Det här fylls i snart.", + "Your box can't answer that right now. Still trying.": "Din box kan inte svara just nu. Försöker igen.", + "Only the owner of this home can change that.": "Bara hemmets ägare kan ändra det.", + "Your access to this home was withdrawn by its owner.": "Ägaren har dragit tillbaka din åtkomst till hemmet.", + "That has to be done from the controls on the home screen.": "Det görs med knapparna i appen.", + "That one is only available on your box's own page, from home.": "Det går bara att göra på boxens egen sida, hemifrån.", + "That's more than we can send over your connection — narrow the range.": "Det är mer än anslutningen klarar. Välj ett mindre intervall.", + "Your box needs more proof than this phone can give. Do it on your box.": "Din box kräver mer bevis än telefonen kan ge. Gör det på boxen.", + "That didn't work. Nothing on your box has changed.": "Det fungerade inte. Inget på boxen har ändrats.", + "Someone has to own this home, so the last owner cannot be removed.": "Någon måste äga hemmet, så den sista ägaren kan inte tas bort.", + "That's no longer on your box.": "Det finns inte längre på boxen.", + "Your box refused that from this phone.": "Din box nekade det från den här telefonen.", + "Something changed on your box first. Nothing here was applied.": "Något ändrades på boxen först. Inget härifrån har genomförts.", + "Your box can't answer that yet. Still trying.": "Din box kan inte svara på det än. Försöker igen.", + "Something went wrong on your box. Nothing has changed.": "Något gick fel på boxen. Inget har ändrats.", + "Your box couldn't do that.": "Din box kunde inte göra det.", + "Your box didn't answer. Still trying.": "Din box svarade inte. Försöker igen.", + "Your box sent something this app couldn't read.": "Din box skickade något som appen inte kunde läsa.", + "Choose a valid ready time.": "Välj en giltig klartid.", + "Choose a charge target from 10 to 100 %.": "Välj ett laddmål mellan 10 och 100 %.", + "Not up to date — your box is out of reach": "Inte aktuellt – boxen går inte att nå", + + // Is FTW in control? + "Is FTW in control?": "Styr FTW?", + "Yes, FTW is in control": "Ja, FTW styr", + "Every device follows FTW.": "Alla enheter följer FTW.", + "Not live right now": "Inte live just nu", + "Last known %@.": "Senast känt %@.", + "Waiting for your box.": "Väntar på din box.", + "FTW is not in control right now": "FTW styr inte just nu", + "A meter stopped reporting. Your home runs on grid power as usual.": "En mätare har slutat rapportera. Hemmet går på nätet som vanligt.", + "Checking…": "Kontrollerar…", + "Asking your box how its devices respond.": "Frågar boxen hur enheterna svarar.", + "Connected and live": "Ansluten och live", + "This box does not report whether devices follow it.": "Den här boxen rapporterar inte om enheterna följer den.", + "FTW controls no device right now": "FTW styr ingen enhet just nu", + "%@ needs attention now": "%@ behöver åtgärd nu", + "%@ devices need attention now": "%@ enheter behöver åtgärd nu", + "%@ needs a look": "%@ behöver en titt", + "%@ devices need a look": "%@ enheter behöver en titt", + "The battery": "Batteriet", + "The charger": "Laddaren", + "The solar inverter": "Solväxelriktaren", + "A device": "En enhet", + "%1$@: %2$@": "%1$@: %2$@", + + // Core's control reasons and statuses + "Following FTW": "Följer FTW", "Waiting": "Väntar", "Limited": "Begränsad", "Not following": "Följer inte", + "No contact": "Ingen kontakt", "Not controlled by FTW": "Styrs inte av FTW", + "Adjusting": "Justerar", "No command yet": "Inget kommando än", "No car connected": "Ingen bil inkopplad", + "Waiting for the plan": "Väntar på planen", "Waiting for spare solar": "Väntar på solöverskott", "Starting the charger": "Startar laddaren", + "Car is full": "Bilen är full", "Car reached its limit": "Bilen nådde sin gräns", "Car not taking power": "Bilen tar ingen ström", + "Battery full": "Batteriet är fullt", "Battery nearly full": "Batteriet är nästan fullt", "Battery nearly empty": "Batteriet är nästan tomt", + "Limited by FTW": "Begränsad av FTW", "Limited by the main fuse": "Begränsad av huvudsäkringen", "Waiting after a fuse limit": "Väntar efter säkringsgräns", + "Limited by FTW's charger setting": "Begränsad av FTW:s laddarinställning", "Charger limit": "Laddarens gräns", "Charger offers less current": "Laddaren ger mindre ström", + "Device fault": "Fel på enheten", "No fresh readings": "Inga färska mätvärden", "Lost control": "Tappade styrningen", + "Commands failing": "Kommandon misslyckas", "Command not confirmed": "Kommandot är inte bekräftat", "Cannot hand back control": "Kan inte lämna tillbaka styrningen", + "No power reading": "Ingen effektmätning", "Monitoring only": "Bara övervakning", "Disabled": "Avstängd", "Own control": "Egen styrning", + "Paused for safety": "Pausad för säkerhets skull", "Checking": "Kontrollerar", + + // Now + "Now": "Nu", + "Market now": "Elpris nu", + "Electricity price": "Elpris", + "incl. fees and VAT": "inkl. avgifter och moms", + "Cheapest 2 h": "Billigast 2 h", + "No 2 h window published": "Inget 2 h-fönster publicerat", + "No prices published ahead yet.": "Inga priser framåt är publicerade än.", + "Dotted line: average ahead, %@": "Streckad linje: snitt framåt, %@", + "Some hours are missing their price.": "Några timmar saknar pris.", + "Tomorrow's rates aren't published yet.": "Morgondagens priser är inte publicerade än.", + "Price to import": "Pris att köpa", + "now": "nu", + "HOME": "HEMMET", + "%@ self-powered today": "%@ egen el i dag", + "Home using %@": "Hemmet använder %@", + "GRID": "ELNÄT", "SOLAR": "SOL", "BATTERY": "BATTERI", "EV CHARGER": "LADDARE", + "balanced": "i balans", "importing": "köper", "exporting": "säljer", "no data": "ingen data", + "idle": "vilar", "charging": "laddar", "discharging": "laddar ur", "observe only": "bara övervakning", + "Device details are out of date.": "Enhetsdetaljerna är inaktuella.", + "Device details are out of date. Showing live totals.": "Enhetsdetaljerna är inaktuella. Visar live-summor.", + + // The shell + "Live demo · simulated home": "Livedemo · simulerat hem", + "Starting the demo": "Startar demon", + "Exit demo": "Avsluta demon", + "Demo": "Demo", + "Plug in the car": "Koppla in bilen", + "Unplug the car": "Koppla ur bilen", + "The demo plugs in a car FTW cannot read, so FTW assumes 20 %.": "Demon kopplar in en bil som FTW inte kan läsa av, så FTW antar 20 %.", + "Your box is starting": "Din box startar", + "This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.": "Det kan ta några minuter efter en uppdatering. Inget är fel. Hemmet visas här när boxen är klar.", + "Access ended": "Åtkomsten är avslutad", + "This session ended.": "Sessionen är avslutad.", + "Your box won't let this phone in?": "Släpper boxen inte in telefonen?", + "Nothing from your box yet": "Inget från din box än", + "This phone is paired to it and keeps trying on its own. Your home appears here as soon as the box answers.": "Telefonen är parkopplad och försöker själv. Hemmet visas här så fort boxen svarar.", + "Get this phone back in": "Släpp in telefonen igen", + "This app is older than your box. Some things are hidden until it updates.": "Appen är äldre än din box. En del döljs tills appen uppdateras.", + ] +} + +// MARK: - Sentences + public enum EVText { public static let dayLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] public static let manualSaveErrorText = "This choice is active now, but could not be saved for restart. FTW is retrying." @@ -193,65 +557,245 @@ public enum EVText { /// The weekday mask as a person says it. public static func days(_ mask: Int) -> String { let m = mask & 0x7f - if m == 0 || m == 0x7f { return "every day" } - if m == 0b0011111 { return "weekdays" } - if m == 0b1100000 { return "weekends" } - return dayLabels.enumerated().filter { m & (1 << $0.offset) != 0 }.map(\.element).joined(separator: ", ") - } - - // MARK: Clock conversions - - /// UTC minutes of the day on the local clock, today. - public static func localTime(minuteUTC: Int, at: Date = Date(), calendar: Calendar = .current) -> (hour: Int, minute: Int) { - var utc = Calendar(identifier: .gregorian) - utc.timeZone = TimeZone(identifier: "UTC")! - var comps = utc.dateComponents([.year, .month, .day], from: at) - comps.hour = minuteUTC / 60 - comps.minute = minuteUTC % 60 - let date = utc.date(from: comps) ?? at - let local = calendar.dateComponents([.hour, .minute], from: date) - return (local.hour ?? 0, local.minute ?? 0) - } - - /// A local hour and minute back to UTC minutes of the day, exact for - /// today's offset: the box's own page does the same. - public static func minuteUTC(hour: Int, minute: Int, at: Date = Date(), calendar: Calendar = .current) -> Int? { - guard (0...23).contains(hour), (0...59).contains(minute) else { return nil } - var comps = calendar.dateComponents([.year, .month, .day], from: at) - comps.hour = hour - comps.minute = minute - guard let date = calendar.date(from: comps) else { return nil } - var utc = Calendar(identifier: .gregorian) - utc.timeZone = TimeZone(identifier: "UTC")! - let u = utc.dateComponents([.hour, .minute], from: date) + if m == 0 || m == 0x7f { return AppWords.text("every day") } + if m == 0b0011111 { return AppWords.text("weekdays") } + if m == 0b1100000 { return AppWords.text("weekends") } + return dayLabels.enumerated().filter { m & (1 << $0.offset) != 0 }.map { AppWords.text($0.element) }.joined(separator: ", ") + } + + /// The days after a time: "on weekdays", "every day", "once". + static func dayPhrase(_ mask: Int, recurring: Bool) -> String { + guard recurring else { return AppWords.text("once") } + let m = mask & 0x7f + if m == 0 || m == 0x7f { return AppWords.text("every day") } + if m == 0b0011111 { return AppWords.text("on weekdays") } + if m == 0b1100000 { return AppWords.text("on weekends") } + return AppWords.format("on %@", days(m)) + } + + // MARK: The household clock + + /// The zone the box keeps its clock in, when it names one; else the + /// phone's. "Local" is what a box without TZ sends, and UTC is what a + /// container without a zone runs in: neither is anyone's home. + public static func householdZone(boxTZ: String?, phone: TimeZone = .current) -> TimeZone { + guard let id = boxTZ, !["", "Local", "UTC", "Etc/UTC", "GMT", "Etc/GMT"].contains(id), let zone = TimeZone(identifier: id) else { + return phone + } + return zone + } + + static func calendar(_ zone: TimeZone) -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + return cal + } + + /// The first instant on `day`'s date at which the clock shows `clock` + /// minutes or later: Core's `wallClock`. A skipped time gives the end of + /// the gap, a repeated one its first pass. + static func wallClock(_ day: Date, clock: Int, zone: TimeZone) -> Date { + let cal = calendar(zone) + let start = cal.startOfDay(for: day) + return cal.date(bySettingHour: clock / 60, minute: clock % 60, second: 0, of: start, matchingPolicy: .nextTime, repeatedTimePolicy: .first, direction: .forward) ?? start + } + + /// A UTC minute of day as the box reads it: the time on `at`'s date in + /// `zone` that has that UTC minute. Core's `clockFromUTC`. + public static func clockFromUTC(_ minuteUTC: Int, at: Date, zone: TimeZone) -> Int { + let m = ((minuteUTC % 1440) + 1440) % 1440 + let start = wallClock(at, clock: 0, zone: zone) + let utc = calendar(TimeZone(identifier: "UTC")!) + var c = utc.dateComponents([.year, .month, .day], from: start) + c.hour = m / 60 + c.minute = m % 60 + var t = utc.date(from: c) ?? start + if t < start { t = t.addingTimeInterval(86_400) } + let l = calendar(zone).dateComponents([.hour, .minute], from: t) + return (l.hour ?? 0) * 60 + (l.minute ?? 0) + } + + /// The household clock as the box's API carries it: the UTC minute of + /// that time on `at`'s date. Core's `utcMinuteToday`. + public static func minuteUTC(clock: Int, at: Date, zone: TimeZone) -> Int { + let u = calendar(TimeZone(identifier: "UTC")!).dateComponents([.hour, .minute], from: wallClock(at, clock: clock, zone: zone)) return (u.hour ?? 0) * 60 + (u.minute ?? 0) } - public static func clock(_ ms: Double, calendar: Calendar = .current) -> String { - let c = calendar.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) - return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + /// The ready time the box uses, on the household clock. + public static func goalClock(_ s: Loadpoint.Schedule, at: Date, zone: TimeZone) -> Int { + s.clockMin ?? clockFromUTC(s.timeOfDayMinUTC, at: at, zone: zone) + } + + /// The next time the household clock shows the ready time on an allowed + /// day, strictly after now: Core's `NextDeadlineUTC`. + public static func nextDeadlineMs(_ s: Loadpoint.Schedule, nowMs: Double, zone: TimeZone) -> Double { + let now = Date(timeIntervalSince1970: nowMs / 1000) + let clock = goalClock(s, at: now, zone: zone) + let cal = calendar(zone) + let today = cal.startOfDay(for: now) + for i in 0..<15 { + guard let day = cal.date(byAdding: .day, value: i, to: today) else { break } + let iso = (cal.component(.weekday, from: day) + 5) % 7 + if s.days != 0, s.days & (1 << iso) == 0 { continue } + let t = wallClock(day, clock: clock, zone: zone) + if t > now { return t.timeIntervalSince1970 * 1000 } + } + return nowMs + } + + /// "80 % by 07:00 on weekdays", on the household clock. + public static func goal(_ s: Loadpoint.Schedule, at: Date, zone: TimeZone) -> String { + let time = AppWords.clock(minute: goalClock(s, at: at, zone: zone)) + let when = dayPhrase(s.days, recurring: s.recurring) + if s.finishAtVehicleLimit { return AppWords.format("Charge to the car's limit by %1$@ %2$@", time, when) } + return AppWords.format("%1$@ by %2$@ %3$@", AppWords.percent(s.socPct), time, when) + } + + /// The deadline the box plans for: its own, or worked out the way it + /// works it out. Shown on the household clock, said so when the phone is + /// somewhere else. + public static func nextGoal(_ lp: Loadpoint, nowMs: Double, zone: TimeZone, phone: TimeZone = .current) -> String? { + guard let s = lp.schedule else { return nil } + let at = lp.targetTimeMs.flatMap { $0 > nowMs ? $0 : nil } ?? nextDeadlineMs(s, nowMs: nowMs, zone: zone) + let day = AppWords.day(at, nowMs: nowMs, zone: zone) + let away = zone.secondsFromGMT(for: Date(timeIntervalSince1970: nowMs / 1000)) != phone.secondsFromGMT(for: Date(timeIntervalSince1970: nowMs / 1000)) + return AppWords.format(away ? "Next: %@, home time" : "Next: %@", day) + } + + /// The goal VISION names, offered when there is none. + public static let suggestedGoal = (socPct: 80.0, clock: 7 * 60, days: 0b0011111) + + public static func suggestedGoalText() -> String { + AppWords.format("%1$@ by %2$@ %3$@", AppWords.percent(suggestedGoal.socPct), AppWords.clock(minute: suggestedGoal.clock), dayPhrase(suggestedGoal.days, recurring: true)) + } + + // MARK: The car's level + + /// Where the level on screen comes from, said as such. + public struct Level: Equatable, Sendable { + public enum Origin: Equatable, Sendable { + /// The car reported it within the last five minutes. + case car + /// The car reported it, longer ago than that. + case carEarlier + /// Someone set it in this app for this plug-in. + case you + /// The box's estimate from energy delivered. + case estimated + /// The box assumes it; nobody has said. + case assumed + /// The car stopped charging; the level is a guess pinned to the goal. + case unconfirmed + } + + public var pct: Double + public var origin: Origin + /// When the car reported it, or when it was set here. + public var atMs: Double? + /// What was set here, once the estimate has moved on from it. + public var setPct: Double? + } + + /// A level set in this app, kept for as long as the car stays plugged in. + public struct LevelSet: Equatable, Sendable { + public var pct: Double + public var atMs: Double + + public init(pct: Double, atMs: Double) { + self.pct = pct + self.atMs = atMs + } + } + + /// Core plans from a car's report for five minutes (VehicleMaxAge). + public static let carLevelMaxAgeMs: Double = 5 * 60_000 + + /// What the box assumes at plug-in when nothing is known. + public static let assumedPct: Double = 20 + + /// The level and where it comes from. Nil when no car is plugged in: + /// the box takes no level then, so none is offered. + public static func level(_ lp: Loadpoint, answeredAtMs: Double, nowMs: Double, set: LevelSet? = nil) -> Level? { + guard lp.pluggedIn else { return nil } + var car: (pct: Double, atMs: Double)? + if let pct = lp.vehicleSocPct { + car = (pct, answeredAtMs - max(0, lp.vehicleSocAgeS ?? 0) * 1000) + } else if lp.socSource == "vehicle", let pct = lp.socPct { + car = (pct, lp.socReadAtMs(answeredAtMs: answeredAtMs) ?? answeredAtMs) + } + if let car, set.map({ $0.atMs <= car.atMs }) ?? true { + let old = lp.vehicleStale || nowMs - car.atMs > carLevelMaxAgeMs + return Level(pct: car.pct, origin: old ? .carEarlier : .car, atMs: car.atMs, setPct: nil) + } + switch lp.socSource { + case "assumed": + return Level(pct: lp.socPct ?? assumedPct, origin: .assumed, atMs: nil, setPct: nil) + case "completed": + guard let pct = lp.socPct else { return nil } + return Level(pct: pct, origin: .unconfirmed, atMs: nil, setPct: nil) + default: + if let set { + let pct = lp.socPct ?? set.pct + return Level(pct: pct, origin: .you, atMs: set.atMs, setPct: abs(pct - set.pct) >= 1 ? set.pct : nil) + } + guard let pct = lp.socPct else { return nil } + return Level(pct: pct, origin: .estimated, atMs: nil, setPct: nil) + } } - // MARK: Sentences + /// The sentence under the level: who said it, and when if it matters. + public static func origin(_ l: Level, zone: TimeZone) -> String { + switch l.origin { + case .car: return AppWords.text("From the car") + case .carEarlier: return AppWords.format("From the car, last reported %@", l.atMs.map { AppWords.clock($0, zone: zone) } ?? "—") + case .you: + let at = l.atMs.map { AppWords.clock($0, zone: zone) } ?? "—" + if let set = l.setPct { return AppWords.format("You set %1$@ at %2$@. FTW adds what has charged since.", AppWords.percent(set), at) } + return AppWords.format("You set %1$@ at %2$@", AppWords.percent(l.pct), at) + case .estimated: return AppWords.text("FTW's estimate from energy delivered") + case .assumed: return AppWords.format("FTW assumes %@. Nobody has told it the real level.", AppWords.percent(l.pct)) + case .unconfirmed: return AppWords.text("The car stopped charging. Its level is not confirmed.") + } + } - /// What the charger is doing now. Power leads when there is any. - public static func status(_ lp: Loadpoint, canControl: Bool = true) -> String { - if lp.manualRestoreUnconfirmed { - return (canControl ? "Confirm how to continue charging." : "An owner needs to confirm how charging should continue.") + " FTW could not confirm the charger or connection." + /// Two or three words for a row: "FTW assumes", "from the car". + public static func originShort(_ l: Level, zone: TimeZone) -> String { + switch l.origin { + case .car: return AppWords.text("from the car") + case .carEarlier: return AppWords.format("reported %@", l.atMs.map { AppWords.clock($0, zone: zone) } ?? "—") + case .you: return AppWords.text("you set") + case .estimated: return AppWords.text("estimated") + case .assumed: return AppWords.text("FTW assumes") + case .unconfirmed: return AppWords.text("not confirmed") } + } + + /// The chips, and a step of five from wherever the level stands. + public static let levelPresets: [Double] = [20, 40, 60, 80] + + public static func stepped(_ pct: Double, up: Bool) -> Double { + let next = up ? (pct / 5).rounded(.down) * 5 + 5 : (pct / 5).rounded(.up) * 5 - 5 + return min(100, max(0, next)) + } + + // MARK: What the charger does + + /// What the charger is doing, in a few words. Power leads when there is any. + public static func status(_ lp: Loadpoint, zone: TimeZone = .current) -> String { if let c = lp.charger, c.available != true { - return c.known ? "Charger status is out of date. FTW cannot confirm whether the car is charging." : "Waiting for the charger’s first status report." + return AppWords.text(c.known ? "Charger status is out of date." : "Waiting for the charger's first report.") } - if !lp.pluggedIn { return "Not plugged in" } + if !lp.pluggedIn { return AppWords.text("Not plugged in") } if lp.manualActive { return manualStatus(lp) } - if lp.powerW >= 100 { return "Charging at \(PowerFormat.text(lp.powerW))" } - if lp.chargingDeclined { return "The car stopped asking for charge. Check its charge limit or schedule. This does not confirm the battery is full." } - if lp.commandedKnown, lp.commandedReason == "site_meter_stale" { return "Paused for safety: house power readings are out of date. Charging resumes when readings return." } - if lp.commandedKnown, lp.commandedW == 0, ["fuse_cooldown", "fuse_limit"].contains(lp.commandedReason) { return "Paused: main-fuse protection. Charging resumes on its own." } - if lp.commandedKnown, let w = lp.commandedW, w > 0 { - return "FTW requests \(PowerFormat.text(w)). Waiting for the car to draw power." + (lp.charger?.reason.map { " Charger reports: \($0)." } ?? "") + if lp.powerW >= 100 { return AppWords.format("Charging at %@", AppWords.power(lp.powerW)) } + if lp.chargingDeclined { return AppWords.text("The car stopped asking for power.") } + if lp.commandedKnown, lp.commandedReason == "site_meter_stale" { return AppWords.text("Paused for safety: the electricity meter is not reporting.") } + if lp.commandedKnown, lp.commandedW == 0, ["fuse_cooldown", "fuse_limit"].contains(lp.commandedReason) { return AppWords.text("Paused by the main fuse. It resumes on its own.") } + if !lp.planPending, !lp.planOutdated, let first = lp.planWindows.first ?? lp.planStartMs.map({ Loadpoint.Window(fromMs: $0, toMs: lp.planEndMs ?? $0, peakW: nil, energyWh: nil) }), first.fromMs > 0 { + return AppWords.format("Plugged in · charges at %@", AppWords.clock(first.fromMs, zone: zone)) } - return "Plugged in — not charging right now" + return AppWords.text("Plugged in, not charging now") } /// A zero hold is a pause; older boxes omit their zero setpoint. @@ -264,86 +808,244 @@ public enum EVText { /// The hold is intent; only a fresh charger reading proves charging. public static func manualStatus(_ lp: Loadpoint) -> String { - let m = lp.manual - let request = m?.requestedA.map { "\(Int($0.rounded())) A" } ?? "your charge request" - let limit = m?.commandedA.map { "\(Int($0.rounded())) A" } ?? "the requested current" - let reason = m?.chargerReason.flatMap { $0.isEmpty ? nil : " Charger reports: \($0)." } ?? "" - let flowing = lp.powerW >= 100 ? PowerFormat.text(lp.powerW) : nil - switch m?.state { - case "unavailable": return "Charger status is out of date. FTW cannot confirm whether the car is charging." - case "pausing": return "Pause requested. " + (flowing.map { "\($0) is still flowing. " } ?? "") + "Waiting for the charger to stop." - case "paused": return "Paused by you. Charging stays off until you resume the plan, choose Charge now, or unplug." - case "charging": return lp.powerW > 0 ? "Charging at \(PowerFormat.text(lp.powerW)). \(request) requested." : "The charger reports charging. Waiting for a power reading." - case "sent": return "FTW received \(request). Waiting for the charger to confirm the new limit." + (flowing.map { " Still charging at \($0)." } ?? "") - case "accepted": return "Charger reports a \(limit) limit. Waiting for the car to start drawing…\(reason)" - case "not_drawing": return "Charger offers \(limit) but the car is not drawing.\(reason.isEmpty ? " Check the car’s charge limit or schedule." : reason)" - case "stalled": - if isPaused(lp) { return "The charger has not stopped after your pause request. Check the charger’s app." } - return "The charger has not acted on \(request)." + (flowing.map { " Still charging at \($0)." } ?? "") + (reason.isEmpty ? " Check the charger and the car’s charge limit or schedule." : reason) + if isPaused(lp) { + if lp.manual?.state == "pausing" || (lp.manual?.state == "stalled" && lp.powerW >= 100) { + return AppWords.text("Pause sent. Waiting for the charger to stop.") + } + return AppWords.text("Paused by you") + } + if lp.powerW >= 100 { return AppWords.format("Charging now at %@", AppWords.power(lp.powerW)) } + switch lp.manual?.state { + case "unavailable": return AppWords.text("Charger status is out of date.") + case "accepted", "charging": return AppWords.text("The charger is starting…") + case "not_drawing": return AppWords.text("The car is not taking power. Check the car's charge limit.") + case "stalled": return AppWords.text("The charger has not responded. Check the charger.") case "limited": - switch m?.limitReason { - case "charger_limit": return "The charger limits this request to \(limit) (\(request) requested)." - case "site_meter_stale": return "Paused for safety: house power readings are out of date. Charging resumes when readings return." - case "fuse_cooldown": return "Paused: main-fuse protection. Charging resumes on its own." - default: return "Main fuse limits this charge to \(limit) right now (\(request) requested)." + switch lp.manual?.limitReason { + case "charger_limit": return AppWords.text("The charger limits the current.") + case "site_meter_stale": return AppWords.text("Paused for safety: the electricity meter is not reporting.") + case "fuse_cooldown": return AppWords.text("Paused by the main fuse. It resumes on its own.") + default: return AppWords.text("The main fuse limits charging right now.") } - default: - if lp.powerW >= 100 { return "Charging at \(PowerFormat.text(lp.powerW))" } - return "Manual charge requested. Waiting for charger status." + default: return AppWords.text("Charge now sent to the charger") } } - public static func plan(_ lp: Loadpoint, nowMs: Double, canControl: Bool = true, calendar: Calendar = .current) -> String? { - if lp.planPending { return "Updating the charging plan…" } - if lp.planOutdated { return "Charging times are unavailable. Your settings are saved." } - if !lp.pluggedIn || lp.manualActive || lp.manualRestoreUnconfirmed || lp.chargingDeclined { return nil } - if let c = lp.charger, c.available != true { return nil } - if lp.gridDeferred, lp.schedule != nil { return "Waiting for tomorrow’s electricity prices. Solar surplus can charge the car meanwhile." } - if let s = lp.planStartMs, let e = lp.planEndMs, s > 0, e > nowMs { - return "Charging planned \(clock(s, calendar: calendar))–\(clock(e, calendar: calendar))." + /// The plan for this car in one line, or why there is none. + public static func plan(_ lp: Loadpoint, windows: [Loadpoint.Window], nowMs: Double, zone: TimeZone) -> String? { + if lp.planPending { return AppWords.text("Updating the plan…") } + if lp.planOutdated { return AppWords.text("Charging times are unavailable. Your goal is saved.") } + guard lp.pluggedIn, !lp.manualActive, !lp.chargingDeclined, lp.charger?.available != false else { return nil } + if lp.gridDeferred, lp.schedule != nil { return AppWords.text("Waiting for tomorrow's prices. Spare solar can charge meanwhile.") } + let ahead = windows.filter { $0.toMs > nowMs } + guard let first = ahead.first else { + return lp.schedule == nil ? nil : AppWords.text("No charging planned yet.") } - if lp.surplusOnly { return "Solar only: charging waits for spare solar power." } - if lp.schedule == nil, lp.powerW < 100 { - return canControl ? "No charging plan yet. Set a ready time, or choose Charge now." : "No charging plan yet. Ask an owner to set a ready time or start charging." + let wh = ahead.compactMap(\.energyWh).reduce(0, +) + let from = AppWords.clock(max(first.fromMs, nowMs), zone: zone) + let to = AppWords.clock(first.toMs, zone: zone) + let total = AppWords.energy(wh > 0 ? wh : (lp.planTotalWh ?? 0)) + if ahead.count > 1 { + return AppWords.format("Charging %1$@–%2$@, about %3$@, and %4$@ more times.", from, to, total, String(ahead.count - 1)) } - if lp.schedule != nil, lp.powerW < 100 { - return canControl ? "No charge window yet for this goal. Choose Charge now if you need to charge immediately." : "No charge window yet for this goal. An owner can start charging now." + return AppWords.format("Charging %1$@–%2$@, about %3$@.", from, to, total) + } + + public static func session(_ lp: Loadpoint) -> String? { + guard lp.pluggedIn, lp.sessionWh >= 50 else { return nil } + return AppWords.format("%@ this session", AppWords.energy(lp.sessionWh)) + } + + // MARK: Will the car be ready? + + public enum Readiness: Equatable, Sendable { + case reached + case onTrack(targetPct: Double, byMs: Double) + case short(reachPct: Double, targetPct: Double, byMs: Double) + } + + /// Within this much of the goal the plan counts as making it: a few + /// hundred watt-hours is rounding inside the plan, not a missed morning. + static func onTrackWh(_ capacityWh: Double) -> Double { max(300, capacityWh * 0.005) } + + /// Whether the car reaches its goal, from the plan's own shortfall for + /// it. Nil when the box has not said: no goal, a charge-now hold that + /// sets the plan aside, a plan being remade, or no shortfall read yet. + public static func readiness(_ lp: Loadpoint, level: Level?, shortfallWh: Double?, nowMs: Double, zone: TimeZone) -> Readiness? { + guard lp.pluggedIn, let s = lp.schedule, !lp.manualActive, !lp.planPending, !lp.planOutdated else { return nil } + if lp.goalComplete { return .reached } + guard let shortfallWh, shortfallWh.isFinite else { return nil } + let target = s.finishAtVehicleLimit ? 100 : s.socPct + let by = lp.targetTimeMs.flatMap { $0 > nowMs ? $0 : nil } ?? nextDeadlineMs(s, nowMs: nowMs, zone: zone) + if let pct = level?.pct, pct >= target { return .reached } + let capacity = (lp.vehicleCapacityWh ?? 0) > 0 ? lp.vehicleCapacityWh! : 60_000 + if shortfallWh <= onTrackWh(capacity) { return .onTrack(targetPct: target, byMs: by) } + let reach = max(level?.pct ?? 0, (target - shortfallWh / capacity * 100).rounded(.down)) + return .short(reachPct: reach, targetPct: target, byMs: by) + } + + public static func readiness(_ r: Readiness, level: Level?, nowMs: Double, zone: TimeZone) -> String { + let main: String + switch r { + case .reached: return AppWords.text("Goal reached.") + case .onTrack(let target, let by): + main = AppWords.format("On track: %1$@ by %2$@.", AppWords.percent(target), AppWords.day(by, nowMs: nowMs, zone: zone)) + case .short(let reach, let target, let by): + main = AppWords.format("Will not make it: about %1$@ by %2$@, not %3$@.", AppWords.percent(reach), AppWords.day(by, nowMs: nowMs, zone: zone), AppWords.percent(target)) + } + switch level?.origin { + case .assumed?, .unconfirmed?: return main + " " + AppWords.format("Counted from FTW's guess of %@.", AppWords.percent(level!.pct)) + default: return main } - return nil } - /// "85 % Ready by 07:00 · weekdays". Nil when the box has no schedule: - /// an app that cannot read one cannot claim its absence. - public static func schedule(_ lp: Loadpoint, at: Date = Date(), calendar: Calendar = .current) -> String? { - guard let s = lp.schedule else { return nil } - let t = localTime(minuteUTC: s.timeOfDayMinUTC, at: at, calendar: calendar) - let when = String(format: "%02d:%02d", t.hour, t.minute) - return "\(Int(s.socPct.rounded())) % Ready by \(when) · \(s.recurring ? days(s.days) : "once")" + // MARK: A command's outcome + + /// One command from the car view, followed from tap to outcome. + public struct Step: Equatable, Sendable { + public enum Ask: Equatable, Sendable { + case level(Double) + case chargeNow, pause, resume + } + + public enum Result: Equatable, Sendable { + /// The box did it, and read back what it now holds. + case applied(observed: Double?) + /// Something else changed it first; the box holds `observed`. + case superseded(observed: Double?) + /// Taken, not confirmed. Not a failure and not a success; the + /// note says more when the box did. + case unconfirmed(note: String?) + /// Refused, or never arrived. The reason is ready to show. + case refused(String) + } + + public var ask: Ask + /// The box said it has the intent (`cmd.ack`). + public var acked: Bool + /// What the box said happened (`cmd.result`), or why not. + public var result: Result? + + public init(ask: Ask, acked: Bool = false, result: Result? = nil) { + self.ask = ask + self.acked = acked + self.result = result + } + + public var settled: Bool { result != nil } } - public static func session(_ lp: Loadpoint) -> String? { - guard lp.pluggedIn, lp.sessionWh >= 50 else { return nil } - let kwh = lp.sessionWh / 1000 - return "\(kwh >= 10 ? String(Int(kwh.rounded())) : PowerFormat.fixed(kwh, 1)) kWh this session" + /// The receipt under a control: three marks and one sentence. + public struct Outcome: Equatable, Sendable { + public enum Mark: Equatable, Sendable { case done, active, waiting, failed } + public enum Tone: Equatable, Sendable { case working, done, warning, failed } + + public var sent: Mark + public var received: Mark + public var done: Mark + public var text: String + public var tone: Tone } - // MARK: The car's level + public static func outcome(_ step: Step, _ lp: Loadpoint?) -> Outcome { + let received: Outcome.Mark + let done: Outcome.Mark + let tone: Outcome.Tone + switch step.result { + case nil: + received = step.acked ? .done : .active + done = step.acked ? .active : .waiting + tone = .working + case .applied?: + received = .done + done = .done + tone = .done + case .superseded?: + received = .done + done = .failed + tone = .warning + case .unconfirmed?: + received = .done + done = .waiting + tone = .warning + case .refused?: + received = step.acked ? .done : .failed + done = .failed + tone = .failed + } + return Outcome(sent: .done, received: received, done: done, text: outcomeText(step, lp), tone: tone) + } - public static let socDefaultPct: Double = 50 + static func outcomeText(_ step: Step, _ lp: Loadpoint?) -> String { + if case .refused(let why)? = step.result { return why } + switch step.ask { + case .level(let asked): + let pct = AppWords.percent(asked) + switch step.result { + case nil: return AppWords.format(step.acked ? "FTW received %@." : "Sending %@ to FTW…", pct) + case .applied(let seen)?: return AppWords.format("FTW now plans from %@.", AppWords.percent(seen.map { $0 * 100 } ?? asked)) + case .superseded(let seen)?: return AppWords.format("FTW kept %@. A newer level came first.", seen.map { AppWords.percent($0 * 100) } ?? "—") + case .unconfirmed(let note)?: return note ?? AppWords.format("FTW received %@ but has not confirmed it yet.", pct) + case .refused?: return "" + } + case .chargeNow, .pause, .resume: + switch step.result { + case nil: + if step.acked { return AppWords.text("FTW received it.") } + switch step.ask { + case .pause: return AppWords.text("Sending Pause…") + case .resume: return AppWords.text("Sending Back to the plan…") + default: return AppWords.text("Sending Charge now…") + } + case .superseded?: return AppWords.text("Something else changed charging first. Try again.") + case .unconfirmed(let note)?: return note ?? AppWords.text("FTW received it but has not confirmed the result yet.") + case .applied?: + switch step.ask { + case .resume: return AppWords.text("Back to the plan. FTW decides when to charge.") + case .pause: + if let lp, lp.manualActive, lp.powerW >= 100 { return AppWords.text("Pause sent. Waiting for the charger to stop.") } + return AppWords.text("Paused. The car will not charge until you choose.") + default: + // The box holds the charge now; the charger's own account + // says whether the car takes it. + guard let lp, lp.manualActive, !isPaused(lp) else { return AppWords.text("FTW started charging. Waiting for the car to draw power.") } + if lp.powerW >= 100 { return AppWords.format("%@. The plan waits until you go back to it.", AppWords.format("Charging at %@", AppWords.power(lp.powerW))) } + switch lp.manual?.state { + case "not_drawing", "stalled", "limited", "unavailable": return manualStatus(lp) + default: return AppWords.text("FTW started charging. Waiting for the car to draw power.") + } + } + case .refused?: return "" + } + } + } - public static func socSource(_ lp: Loadpoint) -> String { - let retention: String - switch lp.socRetention { - case "session": retention = " FTW keeps this level for the same charging session, including after a box restart." - case "error": retention = " This level could not be saved for a box restart. Enter it again before relying on the plan after restarting." - default: retention = " This level must be entered again after a box restart." + /// Why a command did not happen, from the box's stable code. + public static func refusal(_ r: CmdResult) -> String { + if r.state == .expired { return AppWords.text("That took too long to reach FTW. Try again.") } + switch r.errorCode { + case "E_UNAVAILABLE": + switch r.errorArgs["reason"]?.string { + case "unplugged": return AppWords.text("Plug the car in first. FTW takes a level only while the car is plugged in.") + case "persistence_unconfirmed": return AppWords.text("FTW received it but could not save it for a restart.") + default: return AppWords.text("FTW cannot reach the charger right now. Try again shortly.") + } + case "E_PRECONDITION", "E_CONFLICT": return AppWords.text("Something changed at home while this was sending. Try again.") + case "E_SCOPE_DENIED", "E_GRANT_REVOKED": return AppWords.text("Only the home's owner can control charging.") + case "E_CMD_EXPIRED": return AppWords.text("That took too long to reach FTW. Try again.") + case "E_BOOTING": return AppWords.text("FTW is starting. Give it a minute.") + case "E_UNKNOWN_OP": return AppWords.text("Your box cannot do this yet. It may need an update.") + default: return AppWords.text("That did not go through. Try again.") } - switch lp.socSource { - case "assumed": return "Battery level needs confirmation. The plan currently assumes \(Int(lp.socPct ?? socDefaultPct)) %. Drag to match the car." + retention - case "vehicle": return "Reported by the car. Drag only to correct drift." - case "completed": return "The car stopped asking for charge. Its actual battery level is not confirmed. Drag to match the car." - default: return "Estimated from energy delivered. Drag to the real value and the plan follows." + retention + } + + /// Why a command never reached an answer. + public static func failure(_ error: Error) -> String { + if let e = error as? CommandError { + return e.code == "E_NO_ACK" ? AppWords.text("Did not reach FTW. Try again.") : AppWords.text("That did not go through. Try again.") } + if error is SessionError { return AppWords.text("Not connected to FTW right now. Try again when the connection is back.") } + return AppWords.text("That did not go through. Try again.") } // MARK: Charge now, in amps @@ -355,8 +1057,8 @@ public enum EVText { public let phases: Int } - /// The slider's range, with the box page's fallbacks: three phases at - /// 230 V, 6 to 16 A when no floor or ceiling was reported. + /// The range, with the box page's fallbacks: three phases at 230 V, 6 to + /// 16 A when no floor or ceiling was reported. public static func current(_ lp: Loadpoint) -> Current { let phases = (lp.phases ?? 0) > 0 ? lp.phases! : 3 let volts = (lp.voltageV ?? 0) > 0 ? lp.voltageV! : 230 @@ -383,40 +1085,4 @@ public enum EVText { public static func readout(_ lp: Loadpoint, amps: Int) -> String { "\(amps) A · \(PowerFormat.fixed(watts(lp, amps: amps) / 1000, 1)) kW" } - - // MARK: Battery boost - - public static let boostReserveDefaultPct = 30 - public static let boostReserveMinPct = 5 - public static let boostDurations: [(seconds: Int, label: String)] = [(1800, "30 min"), (3600, "1 h"), (7200, "2 h"), (14400, "4 h")] - public static let boostDurationDefault = 3600 - - static let boostStop: [String: String] = [ - "cancelled": "it was stopped by hand", - "expired": "its time ran out", - "vehicle_unplugged": "the car was unplugged", - "ev_target_reached": "the car reached its target", - "departure_reached": "the departure time came", - "operator_hold": "a manual charge took over", - "surplus_only": "the charger went back to spare solar only", - "site_safety_block": "the site meter went quiet", - "loadpoint_driver_unavailable": "your box lost touch with the charger", - "battery_unavailable": "your box lost touch with the house battery", - "battery_reserve_reached": "the house battery reached its reserve", - "battery_hold": "the house battery was held for something else", - "core_mode": "the site mode does not allow it", - "fuse_safety_block": "the fuse limit stepped in", - "restart_lease_invalid": "your box restarted and would not resume it", - ] - - public static func boostActive(_ lp: Loadpoint, calendar: Calendar = .current) -> String { - let reserve = lp.boostReservePct.map { " down to \(Int($0)) %" } ?? "" - let until = lp.boostExpiresAtMs.map { " until \(clock($0, calendar: calendar))" } ?? "" - return "Battery boost is on — the house battery is helping the car\(reserve)\(until)." - } - - public static func boostStopped(_ lp: Loadpoint) -> String? { - guard !lp.boostActive, let reason = lp.boostStopReason else { return nil } - return "The last boost ended because \(boostStop[reason] ?? "your box stopped it")." - } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index 7380ae2..63b9edf 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -133,6 +133,7 @@ public final class Session { var ackTimer: Cancellable var confirmTimer: Cancellable var acked = false + var onAck: (@MainActor () -> Void)? } private var pendingPrices: [UInt32: PendingSingle] = [:] @@ -342,8 +343,9 @@ public final class Session { /// Express an intent and follow it to its outcome. Three deadlines, /// because they are three events: no ack means it never reached the box; /// ack but no result means the hardware has not confirmed; a result is - /// what actually happened. - public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = []) async throws -> CmdResult { + /// what actually happened. `onAck` runs when the box takes the intent, + /// so a screen can say so before the result. + public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = [], onAck: (@MainActor () -> Void)? = nil) async throws -> CmdResult { guard let carrier else { throw SessionError.noCarrier } let cmdId = Self.uuidv7(nowMs: scheduler.nowMs) let body = CBOR.map([ @@ -372,7 +374,7 @@ public final class Session { // and the screen has to be able to say so. p.continuation.resume(returning: CmdResult(cmdId: cmdId, state: .unconfirmed)) } - pendingCommands[cmdId] = PendingCommand(continuation: cont, ackTimer: ackTimer, confirmTimer: confirmTimer) + pendingCommands[cmdId] = PendingCommand(continuation: cont, ackTimer: ackTimer, confirmTimer: confirmTimer, onAck: onAck) carrier.send(frame) } } @@ -442,8 +444,10 @@ public final class Session { case "cmd.ack": guard let cmdId = body["cmdId"]?.string, var p = pendingCommands[cmdId] else { return } p.ackTimer.cancel() + let first = !p.acked p.acked = true pendingCommands[cmdId] = p + if first { p.onAck?() } case "cmd.result": let result = CmdResult(body) guard let p = pendingCommands.removeValue(forKey: result.cmdId) else { return } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift index 2b34642..be6c6bd 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift @@ -26,7 +26,7 @@ extension SiteModel { do { return try JSON(parsing: res.body) } catch { - throw BoxAPIError(code: "E_BAD_BODY", help: "Your box sent something this app couldn't read.", status: res.status) + throw BoxAPIError(code: "E_BAD_BODY", help: AppWords.text("Your box sent something this app couldn't read."), status: res.status) } } @@ -48,7 +48,7 @@ extension SiteModel { } catch { // No code and no status: the wire went away or the deadline // passed. Both heal on their own. - throw BoxAPIError(code: "E_NO_ANSWER", help: "Your box didn't answer. Still trying.", status: nil) + throw BoxAPIError(code: "E_NO_ANSWER", help: AppWords.text("Your box didn't answer. Still trying."), status: nil) } } @@ -60,29 +60,29 @@ extension SiteModel { /// What happens now, for a refusal that never reached a handler. public static func refusalHelp(_ code: String, _ args: [String: CBOR] = [:]) -> String { switch code { - case "E_UNKNOWN_OP": return "Your box doesn't have that yet — it may be running older software." + case "E_UNKNOWN_OP": return AppWords.text("Your box doesn't have that yet — it may be running older software.") case "E_UNAVAILABLE": - return args["reason"]?.string == "busy" ? "Your box is busy with something else. This will fill in shortly." : "Your box can't answer that right now. Still trying." - case "E_SCOPE_DENIED": return "Only the owner of this home can change that." - case "E_GRANT_REVOKED": return "Your access to this home was withdrawn by its owner." - case "E_USE_CMD": return "That has to be done from the controls on the home screen." - case "E_UNSUPPORTED_MEDIA", "E_WHOLE_DOCUMENT", "E_LOCAL_ONLY": return "That one is only available on your box's own page, from home." - case "E_RESPONSE_TOO_LARGE": return "That's more than we can send over your connection — narrow the range." - case "E_NEEDS_STEP_UP": return "Your box needs more proof than this phone can give. Do it on your box." - default: return "That didn't work. Nothing on your box has changed." + return AppWords.text(args["reason"]?.string == "busy" ? "Your box is busy with something else. This will fill in shortly." : "Your box can't answer that right now. Still trying.") + case "E_SCOPE_DENIED": return AppWords.text("Only the owner of this home can change that.") + case "E_GRANT_REVOKED": return AppWords.text("Your access to this home was withdrawn by its owner.") + case "E_USE_CMD": return AppWords.text("That has to be done from the controls on the home screen.") + case "E_UNSUPPORTED_MEDIA", "E_WHOLE_DOCUMENT", "E_LOCAL_ONLY": return AppWords.text("That one is only available on your box's own page, from home.") + case "E_RESPONSE_TOO_LARGE": return AppWords.text("That's more than we can send over your connection — narrow the range.") + case "E_NEEDS_STEP_UP": return AppWords.text("Your box needs more proof than this phone can give. Do it on your box.") + default: return AppWords.text("That didn't work. Nothing on your box has changed.") } } /// What happens now, for a status a handler answered with. public static func statusHelp(_ status: Int, _ code: String?) -> String { - if code == "E_LAST_OWNER_PROTECTED" { return "Someone has to own this home, so the last owner cannot be removed." } + if code == "E_LAST_OWNER_PROTECTED" { return AppWords.text("Someone has to own this home, so the last owner cannot be removed.") } switch status { - case 404: return "That's no longer on your box." - case 403: return "Your box refused that from this phone." - case 409: return "Something changed on your box first. Nothing here was applied." - case 503: return "Your box can't answer that yet. Still trying." - case 500...: return "Something went wrong on your box. Nothing has changed." - default: return "Your box couldn't do that." + case 404: return AppWords.text("That's no longer on your box.") + case 403: return AppWords.text("Your box refused that from this phone.") + case 409: return AppWords.text("Something changed on your box first. Nothing here was applied.") + case 503: return AppWords.text("Your box can't answer that yet. Still trying.") + case 500...: return AppWords.text("Something went wrong on your box. Nothing has changed.") + default: return AppWords.text("Your box couldn't do that.") } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 5e29750..0ba7887 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -2,56 +2,55 @@ import Foundation import FTWShared import Observation -/// The charger sheet. Everything on it is a fact the box served; the -/// controls express intent with an expiry, and the box decides. After any -/// outcome the charger is read again, because the box's account is the -/// truth to repaint from. +/// The car view. Everything on it is a fact the box served; the controls +/// express intent and the box decides. Each command is followed from the +/// tap to the box's answer and stays on screen, and after any outcome the +/// charger is read again, because the box's account is the truth to +/// repaint from. @Observable @MainActor public final class LoadpointsModel: Activatable { - public enum Control: Sendable { case hold, boost, soc, surplus } - public enum Outcome: Sendable { case hold, release, pause, boost, unboost, soc, surplusOn, surplusOff } - - public enum Command: Sendable { - case idle - case sending(Control) - case applied(Control, Outcome) - case unconfirmed(Control) - case failed(Control, String) - - public var isSending: Bool { if case .sending = self { return true }; return false } + public enum GoalSave: Equatable, Sendable { + case saving + case saved + case removed + case failed(String) } public private(set) var points: [Loadpoint] = [] public private(set) var windows: [String: [Loadpoint.Window]] = [:] public private(set) var planMissing = false - public private(set) var planPending = false - public private(set) var planOutdated = false public private(set) var loading = false /// Whether the box has ever answered. No answer is not an empty bay. public private(set) var loaded = false public private(set) var readAtMs: Double? public private(set) var error: String? - public private(set) var command: Command = .idle - public private(set) var commandLoadpointID: String? - /// Applied choices stay on screen until a later read confirms them. - public private(set) var acceptedSoc: [String: Double] = [:] - public private(set) var acceptedSurplus: [String: Bool] = [:] - - // The sheet's drafts, held here so a reread never snaps a control from - // under a finger. - public var ampsDraft: [String: Int] = [:] - public var socDraft: [String: Double] = [:] - public var surplusDraft: [String: Bool] = [:] + /// The plan's shortfall per car, in battery watt-hours, read while the + /// car view is open. Missing means the plan has no goal for that car. + public private(set) var shortfallWh: [String: Double] = [:] + public private(set) var shortfallReadAtMs: Double? + /// The last level and the last charge command per charger, from the + /// tap to the box's answer. + public private(set) var levelStep: [String: EVText.Step] = [:] + public private(set) var holdStep: [String: EVText.Step] = [:] + public private(set) var goalSave: [String: GoalSave] = [:] + /// A level tapped and not yet answered: what the view shows meanwhile. + public private(set) var levelWanted: [String: Double] = [:] + /// Levels set from this phone, kept while the car stays plugged in, so + /// the view can say "you set it". + public private(set) var levelSet: [String: EVText.LevelSet] = [:] @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private let charging: ChargingWatch? @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var planAsk: LiveAsk? @ObservationIgnored private var active = 0 + @ObservationIgnored private var planWatchers = 0 @ObservationIgnored private var token = 0 - @ObservationIgnored private var settle: Cancellable? @ObservationIgnored private var inlinePlan = false @ObservationIgnored private var reading: Task? + @ObservationIgnored private var levelSending: Set = [] + @ObservationIgnored private var clearing: [String: Cancellable] = [:] public init(site: SiteModel, charging: ChargingWatch?) { self.site = site @@ -74,11 +73,42 @@ public final class LoadpointsModel: Activatable { ask?.evaluate() } + /// The car view is open: read the plan's shortfall too, once a minute + /// and whenever the car's plan changes. + public func watchPlan() { + planWatchers += 1 + if planAsk == nil { + planAsk = LiveAsk(site: site, want: { [weak self] in + guard let self, self.planWatchers > 0, self.site.documentVisible, self.site.hasPassthrough, self.inlinePlan else { return nil } + let goals = self.points.filter { $0.pluggedIn && $0.schedule != nil && !$0.planPending && !$0.planOutdated } + guard !goals.isEmpty else { return nil } + return "plan \(goals.map(Self.planKey).joined(separator: ",")) \(Int(self.site.nowMs / 60_000))" + }, ask: { [weak self] in try await self?.readShortfall() }) + } + planAsk?.evaluate() + } + + public func unwatchPlan() { + planWatchers = max(0, planWatchers - 1) + planAsk?.evaluate() + } + + /// What would make a shortfall read out of date for one car. + static func planKey(_ lp: Loadpoint) -> String { + let s = lp.schedule + return "\(lp.id):\(lp.socPct ?? -1):\(s?.socPct ?? -1):\(s?.timeOfDayMinUTC ?? -1):\(lp.planWindows.count):\(Int(lp.planWindows.first?.fromMs ?? 0)):\(Int(lp.planTotalWh ?? 0))" + } + /// Out of date: the read failed, the stream stopped, or it is old. public var stale: Bool { error != nil || site.session.phase != .streaming || (readAtMs.map { site.nowMs - $0 >= 15_000 } ?? false) } + /// Read now rather than at the next tick. + public func reread() { + Task { try? await self.loadChargers() } + } + // MARK: Reads public func loadChargers() async throws { @@ -95,35 +125,35 @@ public final class LoadpointsModel: Activatable { private func readChargers() async throws { token += 1 let mine = token - let socChoices = acceptedSoc - let surplusChoices = acceptedSurplus loading = true defer { if mine == token { loading = false } } do { let wire = try await site.callBox(.get, "/api/loadpoints") guard mine == token else { return } let list = wire?["loadpoints"]?.array ?? [] + let before = Dictionary(points.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) points = list.map(Loadpoint.init) + for lp in points { + // A level set here belongs to one plug-in. + let was = before[lp.id] + if !lp.pluggedIn || (was.map { lp.sessionWh + 100 < $0.sessionWh } ?? false) { levelSet[lp.id] = nil } + } inlinePlan = !points.isEmpty && points.allSatisfy(\.inlinePlan) if inlinePlan { - planPending = points.contains { $0.planPending } - planOutdated = points.contains { $0.planOutdated } windows = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0.planWindows) }) planMissing = false } - // A read that started before a command cannot confirm its choice. - acceptedSoc = acceptedSoc.filter { socChoices[$0.key] != $0.value } - acceptedSurplus = acceptedSurplus.filter { surplusChoices[$0.key] != $0.value } loaded = true readAtMs = site.scheduler.nowMs error = nil site.shareCars(points, fresh: true) + planAsk?.evaluate() } catch { guard mine == token else { return } if let e = error as? BoxAPIError, points.isEmpty { self.error = e.help } else { - self.error = "Not up to date — your box is out of reach" + self.error = AppWords.text("Not up to date — your box is out of reach") } throw error } @@ -138,14 +168,14 @@ public final class LoadpointsModel: Activatable { do { let wire = try await site.callBox(.get, "/api/mpc/plan") guard mine == token else { return } - planPending = wire?["meta"]?["replanning"]?.bool == true - planOutdated = wire?["meta"]?["outdated"]?.bool == true + let pending = wire?["meta"]?["replanning"]?.bool == true || wire?["meta"]?["outdated"]?.bool == true let actions = wire?["plan"]?["actions"]?.array ?? [] var out = [String: [Loadpoint.Window]]() for lp in points { - out[lp.id] = planPending || planOutdated || lp.planPending || lp.planOutdated ? [] : Self.chargeWindows(actions, loadpointID: lp.id) + out[lp.id] = pending || lp.planPending || lp.planOutdated ? [] : Self.chargeWindows(actions, loadpointID: lp.id) } windows = out + takeShortfall(wire, pending: pending) planMissing = false } catch { guard mine == token else { return } @@ -153,6 +183,23 @@ public final class LoadpointsModel: Activatable { } } + private func readShortfall() async throws { + let wire = try await site.callBox(.get, "/api/mpc/plan") + takeShortfall(wire, pending: wire?["meta"]?["replanning"]?.bool == true || wire?["meta"]?["outdated"]?.bool == true) + } + + /// A plan being remade says nothing about whether the car makes it. + private func takeShortfall(_ wire: JSON?, pending: Bool) { + var out = [String: Double]() + if !pending { + for (id, wh) in wire?["plan"]?["loadpoint_shortfall_wh"]?.object ?? [] { + if let v = wh.number, v.isFinite { out[id] = max(0, v) } + } + } + shortfallWh = out + shortfallReadAtMs = site.scheduler.nowMs + } + /// Adjacent charging slots fold into one window; a person asks when it /// will charge, not when the reason changes. static func chargeWindows(_ actions: [JSON], loadpointID: String) -> [Loadpoint.Window] { @@ -161,189 +208,201 @@ public final class LoadpointsModel: Activatable { guard let start = a["slot_start_ms"]?.number, let len = a["slot_len_min"]?.number, let w = a["loadpoint_power_w"]?[loadpointID]?.number, w > 0 else { continue } let end = start + len * 60_000 + let wh = w * len / 60 if var last = out.last, start <= last.toMs { last.toMs = end last.peakW = max(last.peakW ?? 0, w) + last.energyWh = (last.energyWh ?? 0) + wh out[out.count - 1] = last } else { - out.append(Loadpoint.Window(fromMs: start, toMs: end, peakW: w, energyWh: nil)) + out.append(Loadpoint.Window(fromMs: start, toMs: end, peakW: w, energyWh: wh)) } } return out } - // MARK: Commands - - /// Charge now: a hold at a chosen current until Stop or an unplug. - public func chargeNow(_ lp: Loadpoint, amps: Int) async { - await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("power_w", .number(EVText.watts(lp, amps: amps))), ("hold_s", .int(0)), ("phase_mode", .text(EVText.current(lp).phases == 1 ? "1p" : "3p"))], .hold, .hold, CommandText.help) + // MARK: The level + + /// The level a person tapped. It goes to the box at once; taps made + /// while one is on its way are folded into the next send, so ±5 pressed + /// three times sends the last value, not three. + public func setLevel(_ lp: Loadpoint, pct: Double) async { + let id = lp.id + levelWanted[id] = min(100, max(0, pct.rounded())) + guard !levelSending.contains(id) else { return } + levelSending.insert(id) + defer { levelSending.remove(id) } + var sent: Double? + while let want = levelWanted[id], want != sent { + sent = want + await follow(id, .level(want), op: Contract.opLoadpointSocSet, args: [("id", .text(id)), ("soc", .number(want / 100))], into: \.levelStep, tag: "level") + if case .applied(let seen)? = levelStep[id]?.result { + levelSet[id] = EVText.LevelSet(pct: seen.map { ($0 * 100).rounded() } ?? want, atMs: site.scheduler.nowMs) + } + if levelWanted[id] == want { + // Settled and nothing newer: the box's own reading takes over. + try? await loadChargers() + if levelWanted[id] == want { levelWanted[id] = nil } + } + } } - public func pauseCharging(_ lp: Loadpoint) async { - await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("power_w", .int(0)), ("hold_s", .int(0))], .hold, .pause, CommandText.help) + /// The level to show: a tap on its way, else where the car stands. + public func level(_ lp: Loadpoint) -> EVText.Level? { + guard var level = EVText.level(lp, answeredAtMs: readAtMs ?? site.nowMs, nowMs: site.nowMs, set: levelSet[lp.id]) else { return nil } + if let want = levelWanted[lp.id] { + level.pct = want + level.origin = .you + level.atMs = site.nowMs + level.setPct = nil + } + return level } - /// Release the hold; the plan takes back over. - public func stopCharging(_ lp: Loadpoint) async { - await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("clear", .bool(true))], .hold, .release, CommandText.help) - } + // MARK: Charge now, Pause, Back to the plan - /// Let the house battery push the car for a bounded while. - public func boost(_ lp: Loadpoint, reservePct: Int, durationS: Int) async { - await send(Contract.opLoadpointBoost, lp.id, [("id", .text(lp.id)), ("min_battery_soc_pct", .int(reservePct)), ("duration_s", .int(durationS))], .boost, .boost, CommandText.boostHelp) + /// Charge now: the charger's full current, held until Back to the plan + /// or an unplug. No time limit and no level gate: the level is often a + /// guess, and a guess must not lock the owner out (Fredrik, 4 Sep 2026). + public func chargeNow(_ lp: Loadpoint) async { + let range = EVText.current(lp) + await hold(lp, .chargeNow, [("id", .text(lp.id)), ("power_w", .number(EVText.watts(lp, amps: range.maxA))), ("hold_s", .int(0)), ("phase_mode", .text(range.phases == 1 ? "1p" : "3p"))]) } - public func stopBoost(_ lp: Loadpoint) async { - await send(Contract.opLoadpointBoost, lp.id, [("id", .text(lp.id)), ("cancel", .bool(true))], .boost, .unboost, CommandText.boostHelp) + /// A zero hold: the charger stays off until the owner chooses. + public func pause(_ lp: Loadpoint) async { + await hold(lp, .pause, [("id", .text(lp.id)), ("power_w", .int(0)), ("hold_s", .int(0))]) } - /// Whole percent from the slider, a fraction to the box. - public func setSoc(_ lp: Loadpoint, pct: Double) async { - await send(Contract.opLoadpointSocSet, lp.id, [("id", .text(lp.id)), ("soc", .number(pct / 100))], .soc, .soc, CommandText.socHelp) + /// Release the hold; the plan takes back over. + public func backToPlan(_ lp: Loadpoint) async { + await hold(lp, .resume, [("id", .text(lp.id)), ("clear", .bool(true))]) } - public func setSurplusOnly(_ lp: Loadpoint, _ on: Bool) async { - await send(Contract.opLoadpointSurplusOnlySet, lp.id, [("id", .text(lp.id)), ("surplus_only", .bool(on))], .surplus, on ? .surplusOn : .surplusOff, CommandText.help) + private func hold(_ lp: Loadpoint, _ ask: EVText.Step.Ask, _ args: [(String, CBOR)]) async { + if let step = holdStep[lp.id], !step.settled { return } + await follow(lp.id, ask, op: Contract.opLoadpointHold, args: args, into: \.holdStep, tag: "hold") + try? await loadChargers() + charging?.refresh() } - private func send(_ op: String, _ id: String, _ args: [(String, CBOR)], _ of: Control, _ did: Outcome, _ help: (CmdResult) -> String) async { - if command.isSending { return } - settle?.cancel() - commandLoadpointID = id - command = .sending(of) + /// One command, written into `into` at every stage: sent, received, + /// and what the box did or why not. + private func follow(_ id: String, _ ask: EVText.Step.Ask, op: String, args: [(String, CBOR)], into path: ReferenceWritableKeyPath, tag: String) async { + let key = "\(tag) \(id)" + clearing[key]?.cancel() + self[keyPath: path][id] = EVText.Step(ask: ask) + let result: EVText.Step.Result do { - let result = try await site.command(op, args: args) - switch result.state { - case .applied: - command = .applied(of, did) - if of == .soc, let soc = args.first(where: { $0.0 == "soc" })?.1.double { acceptedSoc[id] = (soc * 100).rounded() } - if of == .surplus, let on = args.first(where: { $0.0 == "surplus_only" })?.1.bool { acceptedSurplus[id] = on } - case .unconfirmed: - command = .unconfirmed(of) - default: - command = .failed(of, help(result)) + let r = try await site.core.command(op, args: args, onAck: { [weak self] in + guard let self, self[keyPath: path][id]?.ask == ask else { return } + self[keyPath: path][id]?.acked = true + }) + switch r.state { + case .applied: result = .applied(observed: r.observedValue) + case .superseded: result = .superseded(observed: r.observedValue) + case .unconfirmed: result = .unconfirmed(note: r.errorCode == nil ? nil : EVText.refusal(r)) + case .rejected, .expired: result = .refused(EVText.refusal(r)) } - } catch let e as CommandError { - command = .failed(of, e.help) + self[keyPath: path][id]?.acked = true } catch { - command = .failed(of, "FTW did not confirm the request. Reading its current state…") + result = .refused(EVText.failure(error)) } - if case .applied = command { - settle = site.scheduler.after(6_000) { [weak self] in self?.command = .idle } + guard self[keyPath: path][id]?.ask == ask else { return } + self[keyPath: path][id]?.result = result + // A success fades once the screen shows its effect; a refusal stays + // until the next tap. + if case .applied = result { + clearing[key] = site.scheduler.after(Self.doneShownMs) { [weak self] in + guard let self, self[keyPath: path][id]?.result == result else { return } + self[keyPath: path][id] = nil + } } - try? await loadChargers() - charging?.refresh() } - // MARK: Configure routes: schedule, vehicle + static let doneShownMs: Double = 20_000 - /// Save a goal in one PUT, so it costs one ceremony at most. - public func saveSchedule(_ lp: Loadpoint, socPct: Double, hour: Int, minute: Int, recurring: Bool, days: Int, surplusUnlockPct: Double, calendar: Calendar = .current) async throws { - guard let minUTC = EVText.minuteUTC(hour: hour, minute: minute, calendar: calendar) else { - throw BoxAPIError(code: "E_BAD_TIME", help: "Choose a valid ready time.", status: nil) - } - guard !recurring || days != 0 else { throw BoxAPIError(code: "E_NO_DAYS", help: "Choose at least one day.", status: nil) } - guard (10...100).contains(socPct) else { throw BoxAPIError(code: "E_BAD_SOC", help: "Choose a charge target from 10 to 100 %.", status: nil) } - guard (0...100).contains(surplusUnlockPct) else { throw BoxAPIError(code: "E_BAD_SOC", help: "Choose a home battery level from 1 to 100 %.", status: nil) } - _ = try await site.callBox(.put, "/api/loadpoints/\(Self.escape(lp.id))/schedule", body: [ - "soc": .number(socPct / 100), - "time_of_day_min_utc": .number(Double(minUTC)), - "recurring": .bool(recurring), - "days": .number(Double(!recurring || days == 0x7f ? 0 : days & 0x7f)), - "surplus_unlock_bat_soc": .number(surplusUnlockPct / 100), - ]) - try? await loadChargers() - charging?.refresh() - } + // MARK: The goal - public func removeSchedule(_ lp: Loadpoint) async throws { - _ = try await site.callBox(.delete, "/api/loadpoints/\(Self.escape(lp.id))/schedule") - // The DELETE confirms the removal even if the reread fails. - if let i = points.firstIndex(where: { $0.id == lp.id }) { - points[i].schedule = nil - points[i].targetSocPct = nil + /// Save a goal in one PUT. The ready time goes as the household clock + /// and as today's UTC minute: a box that reads the clock takes it, an + /// older one reads the minute on today's date, as Core does. + public func saveGoal(_ lp: Loadpoint, socPct: Double, clock: Int, recurring: Bool, days: Int, zone: TimeZone) async { + guard (0..<1440).contains(clock) else { goalSave[lp.id] = .failed(AppWords.text("Choose a valid ready time.")); return } + guard (10...100).contains(socPct) else { goalSave[lp.id] = .failed(AppWords.text("Choose a charge target from 10 to 100 %.")); return } + guard !recurring || days & 0x7f != 0 else { goalSave[lp.id] = .failed(AppWords.text("Choose at least one day.")); return } + let minuteUTC = EVText.minuteUTC(clock: clock, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: zone) + let mask = !recurring || days & 0x7f == 0x7f ? 0 : days & 0x7f + goalSave[lp.id] = .saving + do { + _ = try await site.callBox(.put, "/api/loadpoints/\(Self.escape(lp.id))/schedule", body: [ + "soc": .number(socPct / 100), + "time_of_day_min": .number(Double(clock)), + "time_of_day_min_utc": .number(Double(minuteUTC)), + "recurring": .bool(recurring), + "days": .number(Double(mask)), + "surplus_unlock_bat_soc": .number((lp.schedule?.surplusUnlockPct ?? 0) / 100), + ]) + goalSave[lp.id] = .saved + } catch let e as BoxAPIError { + goalSave[lp.id] = .failed(e.help) + } catch { + goalSave[lp.id] = .failed(AppWords.text("That did not go through. Try again.")) } try? await loadChargers() charging?.refresh() } - /// The car's usable battery size, 1 to 300 kWh. - public func setCapacity(_ lp: Loadpoint, kwh: Double) async throws -> String { - guard kwh.isFinite, (1...300).contains(kwh) else { - throw BoxAPIError(code: "E_BAD_CAPACITY", help: "Enter the usable battery size from 1 to 300 kWh.", status: nil) - } - let wh = (kwh * 1000).rounded() - _ = try await site.callBox(.post, "/api/loadpoints/\(Self.escape(lp.id))/vehicle", body: ["capacity_wh": .number(wh)]) + public func removeGoal(_ lp: Loadpoint) async { + goalSave[lp.id] = .saving do { - try await load() + _ = try await site.callBox(.delete, "/api/loadpoints/\(Self.escape(lp.id))/schedule") + // The DELETE confirms the removal even if the reread fails. + if let i = points.firstIndex(where: { $0.id == lp.id }) { + points[i].schedule = nil + points[i].targetSocPct = nil + } + goalSave[lp.id] = .removed + } catch let e as BoxAPIError { + goalSave[lp.id] = .failed(e.help) } catch { - return "Battery size saved. Current charging status is unavailable." + goalSave[lp.id] = .failed(AppWords.text("That did not go through. Try again.")) } + try? await loadChargers() charging?.refresh() - if let active = points.first(where: { $0.id == lp.id })?.vehicleCapacityWh, active != wh { - return "Saved as the usual battery size. This session uses \(EnergyFormat.short(active / 1000)) kWh." + } + + public static func goalText(_ save: GoalSave) -> String { + switch save { + case .saving: return AppWords.text("Saving the goal…") + case .saved: return AppWords.text("Goal saved. FTW is updating the plan.") + case .removed: return AppWords.text("Goal removed.") + case .failed(let help): return help } - return "Battery size saved. The plan uses this size for its estimates." } static func escape(_ id: String) -> String { id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))) ?? id } - // MARK: What the sheet shows + // MARK: What the view shows - public func socShown(_ lp: Loadpoint) -> Double { - socDraft[lp.id] ?? acceptedSoc[lp.id] ?? lp.socPct ?? EVText.socDefaultPct - } + /// The household zone, from the box's hello. + public var zone: TimeZone { EVText.householdZone(boxTZ: site.session.box?.tz) } - public func surplusShown(_ lp: Loadpoint) -> Bool { - surplusDraft[lp.id] ?? acceptedSurplus[lp.id] ?? lp.surplusOnly + public func readiness(_ lp: Loadpoint) -> EVText.Readiness? { + EVText.readiness(lp, level: level(lp), shortfallWh: shortfallWh[lp.id], nowMs: site.nowMs, zone: zone) } - /// The slider's amps: the draft, else the running hold, else the ceiling. - public func ampsShown(_ lp: Loadpoint) -> Int { - let range = EVText.current(lp) - let held: Int? = lp.manualActive && !lp.manualRestoreUnconfirmed && !EVText.isPaused(lp) ? lp.manualChargeW.map { EVText.amps(lp, watts: $0) } : nil - return min(range.maxA, max(range.minA, ampsDraft[lp.id] ?? held ?? range.maxA)) + public func outcome(level lp: Loadpoint) -> EVText.Outcome? { + levelStep[lp.id].map { EVText.outcome($0, lp) } } - /// One sentence per thing the box did, under the control that asked. - public func outcomeSentence(_ did: Outcome, _ lp: Loadpoint) -> String { - switch did { - case .hold: return stale ? "Waiting for current charger status." : lp.manual != nil ? EVText.status(lp) : "FTW received your charge request. Waiting for charger status." - case .release: return "The plan decides when to charge." - case .pause: return EVText.status(lp) - case .boost: return "Battery boost selected. The power readings show what the house battery supplies." - case .unboost: return "Boost stopped — the plan decides again." - case .soc: - if let v = acceptedSoc[lp.id] { return "Charge level accepted: \(Int(v)) %. Waiting for updated charging status." } - let pct = Int(lp.socPct ?? socShown(lp)) - let base = lp.socRetention == "error" ? "Charge level updated: \(pct) %. It could not be saved for a box restart." : "Charge level saved: \(pct) %." - let tail = lp.schedule == nil && !lp.manualActive && !lp.surplusOnly ? " Set a ready time, or choose Charge now." : planMissing ? " Charging times are not available yet." : "" - return base + tail - case .surplusOn: return acceptedSurplus[lp.id] != nil ? "Solar rule accepted. Waiting for updated charging status." : "Solar rule saved. The plan uses spare solar only." - case .surplusOff: return acceptedSurplus[lp.id] != nil ? "Solar rule accepted. Waiting for updated charging status." : "Solar rule saved. The plan may use grid power again." - } + public func outcome(hold lp: Loadpoint) -> EVText.Outcome? { + holdStep[lp.id].map { EVText.outcome($0, lp) } } - /// The outcome line under one control, or nil. - public func outcome(for control: Control, _ lp: Loadpoint) -> String? { - guard commandLoadpointID == lp.id else { - if control == .soc, acceptedSoc[lp.id] != nil { return outcomeSentence(.soc, lp) } - if control == .surplus, acceptedSurplus[lp.id] != nil { return "Solar rule accepted. Waiting for updated charging status." } - return nil - } - switch command { - case .applied(let of, let did) where of == control && (of != .hold || did == .release): - return outcomeSentence(did, lp) - case .unconfirmed(let of) where of == control: - return "FTW received the request. Its result is not confirmed yet." - case .failed(let of, let help) where of == control: - return help - default: - if control == .soc, acceptedSoc[lp.id] != nil { return outcomeSentence(.soc, lp) } - if control == .surplus, acceptedSurplus[lp.id] != nil { return "Solar rule accepted. Waiting for updated charging status." } - return nil - } + public func holdSending(_ lp: Loadpoint) -> Bool { + holdStep[lp.id].map { !$0.settled } ?? false } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index 897f1b1..5828d29 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -296,3 +296,85 @@ public final class SavingsModel: Activatable { periods = next.today.available || next.week.available ? next : nil } } + +/// "Is FTW in control?" in one calm line. Core owns every verdict: the +/// stream's dispatch block and each device's control feedback with its +/// severity. This only picks words, and never says yes from readings that +/// are not live: a cache shows when it was last known, in grey. +public enum InControl { + public enum Tone: Equatable, Sendable { case ok, warning, alarm, neutral, stale } + + public struct Answer: Equatable, Sendable { + public let tone: Tone + public let title: String + public let detail: String? + } + + /// - live: the stream is streaming, not the cache, every source live. + /// - status: GET /api/status, nil until it has answered. + /// - statusFresh: that answer is current. + public static func answer(live: Bool, lastKnownMs: Double?, dispatchBlockedBy: [String], status: JSON?, statusFresh: Bool, zone: TimeZone = .current) -> Answer { + guard live else { + return Answer(tone: .stale, title: AppWords.text("Not live right now"), detail: lastKnownMs.map { AppWords.format("Last known %@.", AppWords.clock($0, zone: zone)) } ?? AppWords.text("Waiting for your box.")) + } + if !dispatchBlockedBy.isEmpty { + return Answer(tone: .warning, title: AppWords.text("FTW is not in control right now"), detail: AppWords.text("A meter stopped reporting. Your home runs on grid power as usual.")) + } + guard let status, statusFresh else { + return Answer(tone: .neutral, title: AppWords.text("Checking…"), detail: AppWords.text("Asking your box how its devices respond.")) + } + guard let rows = status["control_feedback"]?.array else { + // A box from before control feedback: live and not blocked is + // all it says, so that is all this says. + return Answer(tone: .neutral, title: AppWords.text("Connected and live"), detail: AppWords.text("This box does not report whether devices follow it.")) + } + let valid = rows.filter { $0["driver"]?.string != nil && $0["status"]?.string != nil } + let alarms = valid.filter { $0["severity"]?.string == "alarm" } + let warnings = valid.filter { $0["severity"]?.string == "warning" } + if let first = alarms.first { + let title = alarms.count == 1 ? AppWords.format("%@ needs attention now", noun(first)) : AppWords.format("%@ devices need attention now", String(alarms.count)) + return Answer(tone: .alarm, title: title, detail: reasonTitle(first)) + } + if let first = warnings.first { + let title = warnings.count == 1 ? AppWords.format("%@ needs a look", noun(first)) : AppWords.format("%@ devices need a look", String(warnings.count)) + return Answer(tone: .warning, title: title, detail: reasonTitle(first)) + } + if !valid.contains(where: { $0["status"]?.string != "not_controlled" }) { + return Answer(tone: .neutral, title: AppWords.text("FTW controls no device right now"), detail: nil) + } + return Answer(tone: .ok, title: AppWords.text("Yes, FTW is in control"), detail: AppWords.text("Every device follows FTW.")) + } + + static func noun(_ row: JSON) -> String { + switch row["kind"]?.string { + case "battery": return AppWords.text("The battery") + case "ev", "v2x_charger": return AppWords.text("The charger") + case "pv": return AppWords.text("The solar inverter") + default: return AppWords.text("A device") + } + } + + /// Core's reason titles, the box page's words (web/control-feedback.js). + static let reasonTitles: [String: String] = [ + "waiting_response": "Adjusting", "no_command": "No command yet", "not_connected": "No car connected", + "no_plan_budget": "Waiting for the plan", "pv_surplus_pause": "Waiting for spare solar", "wake_kick": "Starting the charger", + "vehicle_complete": "Car is full", "vehicle_limit_completion": "Car reached its limit", "vehicle_not_requesting": "Car not taking power", + "battery_full": "Battery full", "battery_nearly_full": "Battery nearly full", "battery_nearly_empty": "Battery nearly empty", + "core_limit": "Limited by FTW", "fuse_limit": "Limited by the main fuse", "fuse_cooldown": "Waiting after a fuse limit", + "charger_limit": "Limited by FTW's charger setting", "device_limit": "Charger limit", "offered_current_lower": "Charger offers less current", + "device_fault": "Device fault", "telemetry_stale": "No fresh readings", "readings_lost": "Lost control", + "command_failed": "Commands failing", "command_unconfirmed": "Command not confirmed", "default_failed": "Cannot hand back control", + "response_unknown": "No power reading", "observe_only": "Monitoring only", "disabled": "Disabled", "device_control": "Own control", + "site_meter_stale": "Paused for safety", "site_phase_currents_stale": "Paused for safety", + ] + + static let statusTitles: [String: String] = [ + "following": "Following FTW", "waiting": "Waiting", "limited": "Limited", "not_following": "Not following", + "no_contact": "No contact", "not_controlled": "Not controlled by FTW", + ] + + static func reasonTitle(_ row: JSON) -> String { + let title = reasonTitles[row["reason"]?.string ?? ""] ?? statusTitles[row["status"]?.string ?? ""] ?? "Checking" + return AppWords.text(title) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index 5d177e7..2ed611c 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -49,35 +49,6 @@ import FTWDemo #expect(Flow.fuse(status: home.status.status!) != nil) } - @Test func theChargerSheetSendsIntentAndRereads() async throws { - let (_, home, s) = await demo() - home.loadpoints.activate() - await run(s, 1_000) - let lp = try #require(home.loadpoints.points.first) - #expect(lp.pluggedIn) - #expect(home.loadpoints.windows[lp.id] != nil) - - let hold = Task { await home.loadpoints.chargeNow(lp, amps: 10) } - await run(s, 1_000) - await hold.value - #expect(home.loadpoints.points.first?.manualActive == true) - - let release = Task { await home.loadpoints.stopCharging(lp) } - await run(s, 1_000) - await release.value - #expect(home.loadpoints.outcome(for: .hold, lp) == "The plan decides when to charge.") - - let soc = Task { await home.loadpoints.setSoc(lp, pct: 55) } - await run(s, 1_000) - await soc.value - #expect(home.loadpoints.points.first?.socPct == 55) - - let save = Task { try await home.loadpoints.saveSchedule(lp, socPct: 90, hour: 6, minute: 30, recurring: true, days: 0b0011111, surplusUnlockPct: 0) } - await run(s, 1_000) - try await save.value - #expect(home.loadpoints.points.first?.schedule?.socPct == 90) - } - @Test func leavingTheDemoTouchesNoSavedHome() async { let (app, _, _) = await demo() app.exitDemo() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift new file mode 100644 index 0000000..41ba87a --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -0,0 +1,494 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +/// The car view's rules: where a level comes from and how old it is, the +/// receipt under every command, the ready time the box really uses, and +/// whether the car makes it. In English unless a test says otherwise. +@Suite struct CarTextTests { + static let utc = TimeZone(identifier: "UTC")! + static let stockholm = TimeZone(identifier: "Europe/Stockholm")! + + func lp(_ json: String) throws -> Loadpoint { + Loadpoint(try JSON(parsing: Array(json.utf8))) + } + + /// 2026-10-12 10:00:00 UTC. + static let t: Double = 1_791_799_200_000 + + // MARK: The level's origin and age + + @Test func theLevelSaysWhoSaidIt() throws { + try AppWords.$language.withValue(.en) { + let t = Self.t + let assumed = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.2,"soc_source":"assumed"}"#) + let a = try #require(EVText.level(assumed, answeredAtMs: t, nowMs: t)) + #expect(a.origin == .assumed) + #expect(a.pct == 20) + #expect(EVText.origin(a, zone: Self.utc) == "FTW assumes 20\u{00A0}%. Nobody has told it the real level.") + #expect(EVText.originShort(a, zone: Self.utc) == "FTW assumes") + + let estimated = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.46,"soc_source":"inferred"}"#) + #expect(EVText.level(estimated, answeredAtMs: t, nowMs: t)?.origin == .estimated) + + // Set from this phone: said as such, and once the box has added + // what charged since, both numbers are honest. + let set = EVText.LevelSet(pct: 40, atMs: t - 600_000) + let you = try #require(EVText.level(estimated, answeredAtMs: t, nowMs: t, set: set)) + #expect(you.origin == .you) + #expect(you.pct == 46) + #expect(EVText.origin(you, zone: Self.utc) == "You set 40\u{00A0}% at 09:50. FTW adds what has charged since.") + let same = try #require(EVText.level(try lp(#"{"id":"a","plugged_in":true,"current_soc":0.4,"soc_source":"inferred"}"#), answeredAtMs: t, nowMs: t, set: set)) + #expect(EVText.origin(same, zone: Self.utc) == "You set 40\u{00A0}% at 09:50") + + let completed = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.8,"soc_source":"completed"}"#) + #expect(EVText.level(completed, answeredAtMs: t, nowMs: t)?.origin == .unconfirmed) + + // No car on the cable: no level, and none offered. + #expect(EVText.level(try lp(#"{"id":"a","plugged_in":false,"current_soc":0.6}"#), answeredAtMs: t, nowMs: t) == nil) + } + } + + /// Core plans from a car's report for five minutes. Older than that the + /// view still shows it, dated "last reported HH:MM". + @Test func aCarsLevelOlderThanFiveMinutesIsDated() throws { + try AppWords.$language.withValue(.en) { + let t = Self.t + let fresh = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"vehicle","vehicle_driver":"vag","vehicle_soc":0.64,"vehicle_soc_age_s":300}"#) + let f = try #require(EVText.level(fresh, answeredAtMs: t, nowMs: t)) + #expect(f.origin == .car) + #expect(f.pct == 64) + #expect(EVText.origin(f, zone: Self.utc) == "From the car") + + let old = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"inferred","vehicle_driver":"vag","vehicle_soc":0.64,"vehicle_soc_age_s":301}"#) + let o = try #require(EVText.level(old, answeredAtMs: t, nowMs: t)) + #expect(o.origin == .carEarlier) + #expect(o.atMs == t - 301_000) + #expect(EVText.origin(o, zone: Self.utc) == "From the car, last reported 09:54") + #expect(EVText.originShort(o, zone: Self.utc) == "reported 09:54") + + // The same answer ages on screen: two minutes later it is dated. + #expect(EVText.level(fresh, answeredAtMs: t, nowMs: t + 120_000)?.origin == .carEarlier) + + // The box's own stale flag dates it whatever the age. + let flagged = try lp(#"{"id":"a","plugged_in":true,"soc_source":"inferred","vehicle_driver":"vag","vehicle_soc":0.5,"vehicle_soc_age_s":10,"vehicle_stale":true}"#) + #expect(EVText.level(flagged, answeredAtMs: t, nowMs: t)?.origin == .carEarlier) + + // A box without vehicle_soc still dates a vehicle level by its age. + let legacy = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"vehicle","vehicle_soc_age_s":900}"#) + #expect(EVText.level(legacy, answeredAtMs: t, nowMs: t)?.origin == .carEarlier) + + // A level set here after the car's last report wins. + let later = EVText.LevelSet(pct: 70, atMs: t - 60_000) + #expect(EVText.level(old, answeredAtMs: t, nowMs: t, set: later)?.origin == .you) + let earlier = EVText.LevelSet(pct: 70, atMs: t - 900_000) + #expect(EVText.level(old, answeredAtMs: t, nowMs: t, set: earlier)?.origin == .carEarlier) + } + } + + @Test func theLevelInSwedish() throws { + try AppWords.$language.withValue(.sv) { + let t = Self.t + let assumed = try #require(EVText.level(try lp(#"{"id":"a","plugged_in":true,"current_soc":0.2,"soc_source":"assumed"}"#), answeredAtMs: t, nowMs: t)) + #expect(EVText.origin(assumed, zone: Self.utc) == "FTW antar 20\u{00A0}%. Ingen har angett den riktiga nivån.") + let old = try #require(EVText.level(try lp(#"{"id":"a","plugged_in":true,"soc_source":"inferred","vehicle_driver":"vag","vehicle_soc":0.64,"vehicle_soc_age_s":2400}"#), answeredAtMs: t, nowMs: t)) + #expect(EVText.origin(old, zone: Self.stockholm) == "Från bilen, senast rapporterad 11:20") + let you = EVText.Level(pct: 40, origin: .you, atMs: t, setPct: nil) + #expect(EVText.origin(you, zone: Self.stockholm) == "Du angav 40\u{00A0}% kl. 12:00") + #expect(EVText.status(try lp(#"{"id":"a","plugged_in":true,"current_power_w":7200}"#)) == "Laddar med 7,2\u{00A0}kW") + } + } + + @Test func plusAndMinusMoveByFive() { + #expect(EVText.stepped(41, up: true) == 45) + #expect(EVText.stepped(41, up: false) == 40) + #expect(EVText.stepped(40, up: true) == 45) + #expect(EVText.stepped(40, up: false) == 35) + #expect(EVText.stepped(100, up: true) == 100) + #expect(EVText.stepped(0, up: false) == 0) + } + + // MARK: The outcome row + + @Test func theReceiptFollowsAckAndResult() throws { + try AppWords.$language.withValue(.en) { + let sending = EVText.outcome(EVText.Step(ask: .level(40)), nil) + #expect(sending.sent == .done && sending.received == .active && sending.done == .waiting) + #expect(sending.tone == .working) + #expect(sending.text == "Sending 40\u{00A0}% to FTW…") + + let received = EVText.outcome(EVText.Step(ask: .level(40), acked: true), nil) + #expect(received.received == .done && received.done == .active) + #expect(received.text == "FTW received 40\u{00A0}%.") + + let done = EVText.outcome(EVText.Step(ask: .level(40), acked: true, result: .applied(observed: 0.4)), nil) + #expect(done.received == .done && done.done == .done && done.tone == .done) + #expect(done.text == "FTW now plans from 40\u{00A0}%.") + + let superseded = EVText.outcome(EVText.Step(ask: .level(40), acked: true, result: .superseded(observed: 0.45)), nil) + #expect(superseded.done == .failed && superseded.tone == .warning) + #expect(superseded.text == "FTW kept 45\u{00A0}%. A newer level came first.") + + let unconfirmed = EVText.outcome(EVText.Step(ask: .chargeNow, acked: true, result: .unconfirmed(note: nil)), nil) + #expect(unconfirmed.done == .waiting && unconfirmed.tone == .warning) + #expect(unconfirmed.text == "FTW received it but has not confirmed the result yet.") + + // Refused after the box took it, and never reaching it at all. + let why = EVText.refusal(CmdResult(cmdId: "c", state: .rejected, errorCode: "E_UNAVAILABLE", errorArgs: ["reason": .text("unplugged")])) + #expect(why == "Plug the car in first. FTW takes a level only while the car is plugged in.") + let refused = EVText.outcome(EVText.Step(ask: .level(40), acked: true, result: .refused(why)), nil) + #expect(refused.received == .done && refused.done == .failed && refused.tone == .failed) + #expect(refused.text == why) + let lost = EVText.outcome(EVText.Step(ask: .chargeNow, result: .refused(EVText.failure(CommandError(code: "E_NO_ACK", help: "")))), nil) + #expect(lost.received == .failed && lost.done == .failed) + #expect(lost.text == "Did not reach FTW. Try again.") + #expect(EVText.failure(SessionError.noCarrier) == "Not connected to FTW right now. Try again when the connection is back.") + + // Done for Charge now means the box holds it; the charger's own + // account says whether the car takes it. + let started = EVText.Step(ask: .chargeNow, acked: true, result: .applied(observed: 11_000)) + #expect(EVText.outcome(started, nil).text == "FTW started charging. Waiting for the car to draw power.") + let drawing = try lp(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":11000,"current_power_w":11000,"manual":{"state":"charging"}}"#) + #expect(EVText.outcome(started, drawing).text == "Charging at 11\u{00A0}kW. The plan waits until you go back to it.") + let refusing = try lp(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":11000,"manual":{"state":"not_drawing"}}"#) + #expect(EVText.outcome(started, refusing).text == "The car is not taking power. Check the car's charge limit.") + #expect(EVText.outcome(EVText.Step(ask: .resume, acked: true, result: .applied(observed: 0)), nil).text == "Back to the plan. FTW decides when to charge.") + } + } + + @Test func theReceiptInSwedish() { + AppWords.$language.withValue(.sv) { + let r = EVText.outcome(EVText.Step(ask: .chargeNow, acked: true), nil) + #expect(r.text == "FTW har tagit emot det.") + #expect(EVText.outcome(EVText.Step(ask: .pause, acked: true, result: .applied(observed: 0)), nil).text == "Pausad. Bilen laddar inte förrän du väljer.") + #expect(AppWords.text("Sent") == "Skickat") + #expect(AppWords.text("Received") == "Mottaget") + #expect(AppWords.text("Done") == "Utfört") + } + } + + // MARK: The ready time over 25 October 2026 + + static func noon(_ day: Int, _ month: Int = 10) -> Date { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = stockholm + return cal.date(from: DateComponents(year: 2026, month: month, day: day, hour: 12))! + } + + static func utcDate(_ s: String) -> Date { ISO8601DateFormatter().date(from: s)! } + + /// Summer time ends on 25 October 2026. 07:00 is 05:00 UTC before and + /// 06:00 UTC after; the box keeps 07:00 and answers with today's minute. + @Test func theReadyTimeIsTheHouseholdClockAcrossOctober25() throws { + let z = Self.stockholm + #expect(EVText.minuteUTC(clock: 420, at: Self.noon(24), zone: z) == 300) + #expect(EVText.minuteUTC(clock: 420, at: Self.noon(25), zone: z) == 360) + #expect(EVText.minuteUTC(clock: 420, at: Self.noon(26), zone: z) == 360) + #expect(EVText.clockFromUTC(300, at: Self.noon(24), zone: z) == 420) + #expect(EVText.clockFromUTC(360, at: Self.noon(25), zone: z) == 420) + #expect(EVText.clockFromUTC(360, at: Self.noon(26), zone: z) == 420) + // A box that kept the UTC minute really charges by 06:00 after the + // change, and the view says 06:00 rather than the 07:00 once asked. + #expect(EVText.clockFromUTC(300, at: Self.noon(26), zone: z) == 360) + + // The hour that happens twice counts its first pass; the hour that + // never happens (29 March) gives the end of the gap. Core does both. + #expect(EVText.minuteUTC(clock: 150, at: Self.noon(25), zone: z) == 30) + #expect(EVText.minuteUTC(clock: 150, at: Self.noon(29, 3), zone: z) == 60) + } + + @Test func theGoalShowsTheWallTimeTheBoxUses() throws { + try AppWords.$language.withValue(.en) { + let z = Self.stockholm + let before = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":300,"recurring":true,"days":31}}"#) + let after = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":360,"recurring":true,"days":31}}"#) + let clocked = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":300,"time_of_day_min":420,"recurring":true,"days":31}}"#) + #expect(EVText.goal(try #require(before.schedule), at: Self.noon(24), zone: z) == "80\u{00A0}% by 07:00 on weekdays") + #expect(EVText.goal(try #require(after.schedule), at: Self.noon(26), zone: z) == "80\u{00A0}% by 07:00 on weekdays") + // A box that sends the household clock is read as it says. + #expect(EVText.goal(try #require(clocked.schedule), at: Self.noon(26), zone: z) == "80\u{00A0}% by 07:00 on weekdays") + + // From Saturday noon the next weekday 07:00 is Monday, in winter time. + let next = EVText.nextDeadlineMs(try #require(before.schedule), nowMs: Self.noon(24).timeIntervalSince1970 * 1000, zone: z) + #expect(next == Self.utcDate("2026-10-26T06:00:00Z").timeIntervalSince1970 * 1000) + var daily = try #require(before.schedule) + daily.days = 0 + let sunday = EVText.nextDeadlineMs(daily, nowMs: Self.noon(24).timeIntervalSince1970 * 1000, zone: z) + #expect(sunday == Self.utcDate("2026-10-25T06:00:00Z").timeIntervalSince1970 * 1000) + } + AppWords.$language.withValue(.sv) { + let s = Loadpoint.Schedule(socPct: 80, timeOfDayMinUTC: 300, clockMin: nil, surplusUnlockPct: 0, recurring: true, days: 0b0011111, finishAtVehicleLimit: false) + #expect(EVText.goal(s, at: Self.noon(24), zone: Self.stockholm) == "80\u{00A0}% till 07:00 vardagar") + } + } + + @Test func theHouseholdZoneIsTheBoxsWhenItNamesOne() throws { + let phone = TimeZone(identifier: "America/New_York")! + #expect(EVText.householdZone(boxTZ: "Europe/Stockholm", phone: phone) == Self.stockholm) + #expect(EVText.householdZone(boxTZ: "Local", phone: phone) == phone) + #expect(EVText.householdZone(boxTZ: "UTC", phone: phone) == phone) + #expect(EVText.householdZone(boxTZ: "", phone: phone) == phone) + #expect(EVText.householdZone(boxTZ: "Not/AZone", phone: phone) == phone) + try AppWords.$language.withValue(.en) { + let away = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":300,"recurring":true,"days":0}}"#) + let now = Self.noon(24).timeIntervalSince1970 * 1000 + #expect(EVText.nextGoal(away, nowMs: now, zone: Self.stockholm, phone: phone) == "Next: tomorrow 07:00, home time") + #expect(EVText.nextGoal(away, nowMs: now, zone: Self.stockholm, phone: Self.stockholm) == "Next: tomorrow 07:00") + } + } + + // MARK: Will the car be ready? + + @Test func readinessComesFromThePlansShortfall() throws { + try AppWords.$language.withValue(.en) { + let z = Self.stockholm + let now = Self.noon(24).timeIntervalSince1970 * 1000 + let json = #"{"id":"a","plugged_in":true,"current_soc":0.35,"soc_source":"inferred","vehicle_capacity_wh":64000,"target_time":"2026-10-25T06:00:00Z","schedule":{"soc":0.8,"time_of_day_min_utc":360,"recurring":true,"days":0}}"# + let car = try lp(json) + let level = EVText.level(car, answeredAtMs: now, nowMs: now) + #expect(EVText.readiness(car, level: level, shortfallWh: 0, nowMs: now, zone: z) == .onTrack(targetPct: 80, byMs: Self.utcDate("2026-10-25T06:00:00Z").timeIntervalSince1970 * 1000)) + let short = try #require(EVText.readiness(car, level: level, shortfallWh: 18_200, nowMs: now, zone: z)) + #expect(short == .short(reachPct: 51, targetPct: 80, byMs: Self.utcDate("2026-10-25T06:00:00Z").timeIntervalSince1970 * 1000)) + #expect(EVText.readiness(short, level: level, nowMs: now, zone: z) == "Will not make it: about 51\u{00A0}% by tomorrow 07:00, not 80\u{00A0}%.") + #expect(EVText.readiness(.onTrack(targetPct: 80, byMs: now + 3_600_000), level: level, nowMs: now, zone: z) == "On track: 80\u{00A0}% by today 13:00.") + + // From a guess, it says so. + let guessed = EVText.Level(pct: 20, origin: .assumed, atMs: nil, setPct: nil) + #expect(EVText.readiness(.onTrack(targetPct: 80, byMs: now + 3_600_000), level: guessed, nowMs: now, zone: z).hasSuffix("Counted from FTW's guess of 20\u{00A0}%.")) + + // Nothing claimed without the box's word. + #expect(EVText.readiness(car, level: level, shortfallWh: nil, nowMs: now, zone: z) == nil) + let pending = try lp(json.replacingOccurrences(of: #""id":"a","#, with: #""id":"a","plan_pending":true,"#)) + #expect(EVText.readiness(pending, level: level, shortfallWh: 0, nowMs: now, zone: z) == nil) + let holding = try lp(json.replacingOccurrences(of: #""id":"a","#, with: #""id":"a","manual_active":true,"#)) + #expect(EVText.readiness(holding, level: level, shortfallWh: 0, nowMs: now, zone: z) == nil) + let done = try lp(json.replacingOccurrences(of: #""id":"a","#, with: #""id":"a","goal_complete":true,"#)) + #expect(EVText.readiness(done, level: level, shortfallWh: nil, nowMs: now, zone: z) == .reached) + } + } + + // MARK: Is FTW in control? + + @Test func inControlIsNeverGreenFromACache() throws { + try AppWords.$language.withValue(.en) { + let following = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) + let ok = InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: true) + #expect(ok.tone == .ok) + #expect(ok.title == "Yes, FTW is in control") + + // The same verdict from a cache, or from an old status read, is + // not a yes. + let cached = InControl.answer(live: false, lastKnownMs: Self.t, dispatchBlockedBy: [], status: following, statusFresh: true, zone: Self.utc) + #expect(cached.tone == .stale) + #expect(cached.detail == "Last known 10:00.") + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: false).tone == .neutral) + + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: ["meter"], status: following, statusFresh: true).tone == .warning) + let old = try JSON(parsing: Array(#"{"grid_w":100}"#.utf8)) + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: old, statusFresh: true).title == "Connected and live") + + let alarm = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"no_contact","reason":"telemetry_stale","severity":"alarm"},{"driver":"sungrow","kind":"battery","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) + let a = InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: alarm, statusFresh: true) + #expect(a.tone == .alarm) + #expect(a.title == "The charger needs attention now") + #expect(a.detail == "No fresh readings") + + let warnings = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"limited","reason":"fuse_limit","severity":"warning"},{"driver":"sungrow","kind":"battery","status":"limited","reason":"battery_full","severity":"warning"}]}"#.utf8)) + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: warnings, statusFresh: true).title == "2 devices need a look") + } + try AppWords.$language.withValue(.sv) { + let following = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: true).title == "Ja, FTW styr") + #expect(AppWords.text("Is FTW in control?") == "Styr FTW?") + } + } +} + +/// The car view against the simulated box: plug in, give a level, set a +/// goal, Charge now and back to the plan, and every receipt on the way. +@MainActor +@Suite struct CarFlowTests { + @MainActor + final class Rig { + let scheduler = ManualScheduler(nowMs: CarTextTests.t) + var box: SimulatedBox? + var app: AppModel! + + init() { + app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { [unowned self] s in + let box = SimulatedBox(scheduler: s, house: SimulatedHouse(timeZone: CarTextTests.stockholm)) + box.resultDelayMs = 900 + box.house.chargerRampMs = 4_000 + self.box = box + return LoopbackCarrier(box: box, scheduler: s) + }) + } + + func run(_ ms: Double, step: Double = 50) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + } + + @Test func plugInGiveALevelSetAGoalAndChargeNow() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + #expect(car.pluggedIn) + #expect(home.loadpoints.zone == CarTextTests.stockholm) + #expect(home.loadpoints.level(car)?.origin == .assumed) + #expect(home.loadpoints.level(car)?.pct == 20) + + // The level: sent, received, done, and then "you set". + let level = Task { await home.loadpoints.setLevel(car, pct: 40) } + await rig.run(100) + #expect(home.loadpoints.levelStep[car.id] == EVText.Step(ask: .level(40))) + #expect(home.loadpoints.level(car)?.pct == 40) + await rig.run(300) + #expect(home.loadpoints.levelStep[car.id]?.acked == true) + #expect(home.loadpoints.levelStep[car.id]?.result == nil) + await rig.run(1_500) + await level.value + #expect(home.loadpoints.levelStep[car.id]?.result == .applied(observed: 0.4)) + #expect(box.receivedCommands.last == Contract.opLoadpointSocSet) + let after = try #require(home.loadpoints.points.first) + #expect(home.loadpoints.level(after)?.origin == .you) + #expect(home.loadpoints.level(after)?.pct == 40) + + // Three quick taps send the last one, not three. + let sent = box.receivedCommands.count + let a = Task { await home.loadpoints.setLevel(after, pct: 45) } + let b = Task { await home.loadpoints.setLevel(after, pct: 50) } + let c = Task { await home.loadpoints.setLevel(after, pct: 55) } + await rig.run(5_000) + await a.value; await b.value; await c.value + #expect(box.receivedCommands.count - sent == 2) + #expect(box.house.carSocFraction == 0.55) + + // The goal, saved as the household clock, read back on it. + await home.loadpoints.saveGoalAndWait(car, rig, socPct: 90, clock: 6 * 60 + 30, recurring: true, days: 0b0011111) + #expect(box.house.schedule?.clockMin == 390) + #expect(home.loadpoints.goalSave[car.id] == .saved) + let goal = try #require(home.loadpoints.points.first?.schedule) + #expect(EVText.goal(goal, at: Date(timeIntervalSince1970: CarTextTests.t / 1000), zone: home.loadpoints.zone) == "90\u{00A0}% by 06:30 on weekdays") + + // Whether it makes it comes from the plan's shortfall. + home.loadpoints.watchPlan() + await rig.run(6_000, step: 100) + #expect(home.loadpoints.shortfallWh[car.id] != nil) + let withGoal = try #require(home.loadpoints.points.first) + #expect(home.loadpoints.readiness(withGoal) != nil) + home.loadpoints.unwatchPlan() + + // Charge now: no time limit, the charger's full current. + let charge = Task { await home.loadpoints.chargeNow(withGoal) } + await rig.run(400) + #expect(home.loadpoints.holdStep[car.id]?.acked == true) + #expect(home.loadpoints.holdSending(withGoal)) + await rig.run(1_500) + await charge.value + #expect(home.loadpoints.holdStep[car.id]?.result == .applied(observed: 11_000)) + #expect(box.house.manualHoldW == 11_000) + await rig.run(5_000, step: 100) + home.loadpoints.reread() + await rig.run(500) + let charging = try #require(home.loadpoints.points.first) + #expect(charging.manualActive) + #expect(charging.powerW == 11_000) + #expect(home.loadpoints.outcome(hold: charging)?.text == "Charging at 11\u{00A0}kW. The plan waits until you go back to it.") + // A plan set aside says nothing about the goal. + #expect(home.loadpoints.readiness(charging) == nil) + + let back = Task { await home.loadpoints.backToPlan(charging) } + await rig.run(2_000) + await back.value + #expect(home.loadpoints.outcome(hold: charging)?.text == "Back to the plan. FTW decides when to charge.") + #expect(home.loadpoints.points.first?.manualActive == false) + // A success fades once its effect is on screen. + await rig.run(LoadpointsModel.doneShownMs + 1_000, step: 500) + #expect(home.loadpoints.holdStep[car.id] == nil) + } + } + + @Test func unpluggedTakesNoLevelAndSaysWhy() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + let set = Task { await home.loadpoints.setLevel(car, pct: 60) } + await rig.run(2_000) + await set.value + #expect(home.loadpoints.levelSet[car.id] != nil) + + box.setCarPluggedIn(false) + home.loadpoints.reread() + await rig.run(1_000) + let out = try #require(home.loadpoints.points.first) + #expect(!out.pluggedIn) + #expect(home.loadpoints.level(out) == nil) + #expect(home.loadpoints.levelSet[car.id] == nil) + + // The view offers no level now; if one is sent anyway, the box's + // refusal reads as what to do. + let refused = Task { await home.loadpoints.setLevel(out, pct: 60) } + await rig.run(2_000) + await refused.value + #expect(home.loadpoints.outcome(level: out)?.text == "Plug the car in first. FTW takes a level only while the car is plugged in.") + #expect(home.loadpoints.outcome(level: out)?.tone == .failed) + + // Plugging in again starts over: FTW assumes 20 %. + box.setCarPluggedIn(true) + home.loadpoints.reread() + await rig.run(1_000) + let back = try #require(home.loadpoints.points.first) + #expect(home.loadpoints.level(back)?.origin == .assumed) + #expect(home.loadpoints.level(back)?.pct == 20) + } + } + + /// Every demo situation opens, so each screen can be shown without a box. + @Test func everyDemoCarOpens() async throws { + for situation in DemoCar.allCases { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + situation.apply(to: try #require(rig.box).house, nowMs: rig.scheduler.nowMs) + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + switch situation { + case .unplugged: #expect(!car.pluggedIn) + case .car: #expect(home.loadpoints.level(car)?.origin == .car) + case .carOld: #expect(home.loadpoints.level(car)?.origin == .carEarlier) + case .assumed: #expect(home.loadpoints.level(car)?.origin == .assumed) + case .estimated, .late: #expect(home.loadpoints.level(car)?.origin == .estimated) + case .noGoal: #expect(car.schedule == nil) + case .charging: #expect(car.manualActive) + } + } + } +} + +extension LoadpointsModel { + @MainActor + func saveGoalAndWait(_ lp: Loadpoint, _ rig: CarFlowTests.Rig, socPct: Double, clock: Int, recurring: Bool, days: Int) async { + let task = Task { await saveGoal(lp, socPct: socPct, clock: clock, recurring: recurring, days: days, zone: zone) } + await rig.run(1_500) + await task.value + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift index e4fff59..be1185b 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift @@ -39,26 +39,28 @@ import Testing } @Test func evSentencesSayOnlyWhatTheBoxSaid() throws { - let unplugged = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":false,"current_soc":0.5}"#.utf8))) - #expect(EVText.status(unplugged) == "Not plugged in") - #expect(unplugged.socPct == nil) + try AppWords.$language.withValue(.en) { + let unplugged = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":false,"current_soc":0.5}"#.utf8))) + #expect(EVText.status(unplugged) == "Not plugged in") + #expect(unplugged.socPct == nil) - let charging = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_power_w":7200,"current_soc":0.41,"delivered_wh_session":3040}"#.utf8))) - #expect(EVText.status(charging) == "Charging at 7.2 kW") - #expect(charging.socPct == 41) - #expect(EVText.session(charging) == "3.0 kWh this session") + let charging = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_power_w":7200,"current_soc":0.41,"delivered_wh_session":3040}"#.utf8))) + #expect(EVText.status(charging) == "Charging at 7.2\u{00A0}kW") + #expect(charging.socPct == 41) + #expect(EVText.session(charging) == "3.0\u{00A0}kWh this session") - let paused = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":0,"manual":{"state":"paused"}}"#.utf8))) - #expect(EVText.isPaused(paused)) - #expect(EVText.status(paused).hasPrefix("Paused by you.")) + let paused = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":0,"manual":{"state":"paused"}}"#.utf8))) + #expect(EVText.isPaused(paused)) + #expect(EVText.status(paused) == "Paused by you") - let stale = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"charger":{"known":true,"available":false}}"#.utf8))) - #expect(EVText.status(stale) == "Charger status is out of date. FTW cannot confirm whether the car is charging.") + let stale = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"charger":{"known":true,"available":false}}"#.utf8))) + #expect(EVText.status(stale) == "Charger status is out of date.") - #expect(EVText.days(0) == "every day") - #expect(EVText.days(0b0011111) == "weekdays") - #expect(EVText.days(0b1100000) == "weekends") - #expect(EVText.days(0b0000101) == "Mon, Wed") + #expect(EVText.days(0) == "every day") + #expect(EVText.days(0b0011111) == "weekdays") + #expect(EVText.days(0b1100000) == "weekends") + #expect(EVText.days(0b0000101) == "Mon, Wed") + } } /// A level from the car is as old as the car's report. Any other level @@ -81,17 +83,6 @@ import Testing #expect(EVText.readout(capped, amps: 16) == "16 A · 11.0 kW") } - @Test func scheduleClockConvertsThroughUTC() { - var cal = Calendar(identifier: .gregorian) - cal.timeZone = TimeZone(identifier: "Europe/Stockholm")! - let summer = Date(timeIntervalSince1970: 1_750_000_000) - let utc = EVText.minuteUTC(hour: 7, minute: 0, at: summer, calendar: cal) - #expect(utc == 5 * 60) - let back = EVText.localTime(minuteUTC: 300, at: summer, calendar: cal) - #expect(back.hour == 7 && back.minute == 0) - #expect(EVText.minuteUTC(hour: 25, minute: 99, calendar: cal) == nil) - } - @Test func flowUsesMagnitudesAndDirections() { let r = Flow.readings(fields: [F.gridW: -1500, F.pvW: -4000, F.batteryW: 1200, F.batterySoc: 612, F.loadW: 1300]) let grid = r.nodes.first { $0.role == .grid }! From 91ebff4989a2a193df96de19601884cb55da98da Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:02:11 +0200 Subject: [PATCH 06/25] Draw the curve's bars from zero and keep the last hour label whole A bar mark with an x interval and a single y lies on its side, so the curve showed short dashes at each price. Rectangles from zero to the price fix that. Hour labels fall every third hour, clear of the right edge, on the 24-hour clock the rest of the widget uses. The average line takes the muted tone instead of the system tint. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWWidgets/PriceViews.swift | 52 ++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift index 89f0e49..1cf5432 100644 --- a/appleApp/FTWWidgets/PriceViews.swift +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -170,29 +170,61 @@ struct Curve: View { var body: some View { Chart { ForEach(moment.curve) { bar in - BarMark( - xStart: .value("From", date(bar.startMs)), - xEnd: .value("To", date(bar.endMs)), - y: .value("Price", PriceUnits.display(bar.minor, moment.currency)) - ) - .foregroundStyle(Tone.of(bar.tone)) - .opacity(bar.current ? 1 : 0.55) + mark(bar) } if let mean = moment.meanMinor { RuleMark(y: .value("Average", PriceUnits.display(mean, moment.currency))) .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3])) - .foregroundStyle(.secondary) + .foregroundStyle(Tone.flat) } } + .chartXScale(domain: date(start)...date(end)) .chartXAxis { - AxisMarks(values: .stride(by: .hour, count: 3)) { _ in - AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .omitted))) + AxisMarks(values: hours) { value in + AxisValueLabel { + if let d = value.as(Date.self) { Text(Self.hour(d)) } + } } } .chartYAxis(.hidden) .accessibilityHidden(true) } + /// A bar from zero, as wide as the slot. A rectangle, because a bar + /// mark with an x interval and one y lies on its side. + private func mark(_ bar: PriceStrip.Bar) -> some ChartContent { + let price: Double = PriceUnits.display(bar.minor, moment.currency) + return RectangleMark( + xStart: .value("From", date(bar.startMs)), + xEnd: .value("To", date(bar.endMs)), + yStart: .value("Zero", 0.0), + yEnd: .value("Price", price) + ) + .foregroundStyle(Tone.of(bar.tone)) + .opacity(bar.current ? 1 : 0.55) + } + + private var start: Double { moment.curve.first?.startMs ?? moment.atMs } + private var end: Double { moment.curve.last?.endMs ?? moment.atMs } + + /// Every third whole hour, kept clear of the right edge so no label is + /// cut short. + private var hours: [Date] { + let hour: Double = 3_600_000 + var t = (start / hour).rounded(.up) * hour + var out: [Date] = [] + while t < end - hour { + if Calendar.current.component(.hour, from: date(t)) % 3 == 0 { out.append(date(t)) } + t += hour + } + return out + } + + /// "02", on the 24-hour clock the rest of the widget uses. + private static func hour(_ d: Date) -> String { + String(format: "%02d", Calendar.current.component(.hour, from: d)) + } + private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } } From 52ccaf7c39abe32a9016902b549df876271efdf6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:10:23 +0200 Subject: [PATCH 07/25] Add an app store distribution permission to the license App stores attach their own terms to every copy, and the GNU AGPL forbids further restrictions on recipients. Sourceful owns all code here today, so it may publish through the App Store as it is. That stops being true once someone else's code comes in under plain AGPL. LICENSE gains a second section 7 permission, next to the Energyplan one. It allows conveying the app through a store such as the App Store, TestFlight or Google Play despite the store's terms. Every other AGPL condition stays: source for the exact version outside the store, license and source offer reachable in the app, no added restrictions, and installation information except the distributor's own signing keys. CONTRIBUTING now says each contribution grants both permissions, so the permission covers future code as well. LICENSING.md and the README say the same in plain words. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 6 ++++-- LICENSE | 42 +++++++++++++++++++++++++++++++++++++++++- LICENSING.md | 23 ++++++++++++++++------- README.md | 5 +++-- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69ceeda..2b1673b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,10 @@ remain unchanged. See [FTW's contribution guide](https://github.com/srcfl/ftw/bl ## License of contributions -Contributions to this version use GNU AGPL v3 only with the Energyplan -combination permission in [LICENSE](LICENSE), including that permission. +Contributions to this version use GNU AGPL v3 only with both additional +permissions in [LICENSE](LICENSE): the Energyplan combination permission and +the app store distribution permission. By contributing, you grant both +permissions for your contribution. Preserve third-party licenses and notices. Sign each commit with the DCO as described in [FTW's contribution guide](https://github.com/srcfl/ftw/blob/master/CONTRIBUTING.md). A sign-off does not assign copyright or grant commercial relicensing rights. diff --git a/LICENSE b/LICENSE index 9f6b17d..b812ef6 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,8 @@ FTW native app Copyright 2026 Sourceful Labs AB and contributors. This version of the project, as a whole, is licensed under the GNU Affero -General Public License, version 3 only, with the additional permission below. +General Public License, version 3 only, with the two additional permissions +below. Separately identified third-party material and Energyplan binaries retain their own licenses. NOTICE preserves earlier licenses and attributions. See LICENSING.md for scope, source access and distribution instructions. @@ -266,3 +267,42 @@ You may omit Energyplan and use or convey FTW under the GNU AGPL alone. No household-only or noncommercial condition applies to the AGPL-covered code. As section 7 permits, you may remove this additional permission from your copy. If you do so, the GNU AGPL applies without this permission. + +------------------------------------------------------------------------ +FTW app store distribution permission + +Additional permission under GNU AGPL version 3 section 7. + +App distribution services, such as the Apple App Store, TestFlight and +Google Play, attach their own terms to every copy they deliver. Those terms +may limit how a recipient can use, copy or install that copy, and the service +may add technical measures such as encryption or signing. The GNU AGPL +forbids further restrictions on recipients (section 10), so such terms can +conflict with it. + +For the FTW code covered by this grant, the copyright holders permit you to +convey this program, or a work based on it, in object code form through such +a service, and to accept that service's terms, even where those terms would +otherwise conflict with the GNU AGPL. This permission covers only the +restrictions that come from the service's own terms and technical measures. + +All other conditions of the GNU AGPL remain in force. In particular: + +- Offer the Corresponding Source of the exact version you convey, under the + GNU AGPL and at no charge, outside the service and free of its terms. +- Make the license text and that source offer reachable from within the + program. +- Add no restrictions of your own beyond those the service imposes. +- Provide the Installation Information that section 6 requires. You need not + provide your own signing keys, provisioning profiles or service account + credentials, as long as recipients can build the Corresponding Source and + install it on their own device with the platform's standard developer + tools. + +This permission grants no exception for proprietary modules, plugins or +derivatives of FTW. Third-party code retains its own terms; this grant does +not override them. + +If you modify this program, you may extend this permission to your version. +As section 7 permits, you may also remove it from your copy. If you do so, +the GNU AGPL applies without this permission. diff --git a/LICENSING.md b/LICENSING.md index 21756d0..59f3fa1 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -1,7 +1,8 @@ # FTW native app licensing -This version uses GNU AGPL v3 only, with the narrow Energyplan combination -permission in [LICENSE](LICENSE). [NOTICE](NOTICE) preserves prior Apache +This version uses GNU AGPL v3 only, with two narrow additional permissions in +[LICENSE](LICENSE): the Energyplan combination permission and the app store +distribution permission. [NOTICE](NOTICE) preserves prior Apache rights and attribution. Third-party files retain their own licenses. ## Use and redistribution @@ -61,8 +62,16 @@ build without a revision uses development repository links. An extracted source archive builds without Git; pass its published revision to retain the exact links. Forks must change these links to their own corresponding source. -A store's distribution terms must allow recipients the AGPL rights. This -license change does not establish compatibility with any app store agreement -or grant an exception to store restrictions. Resolve conflicting terms before -distributing through that store, using separate rights only where the rights -holders can grant them. +App stores attach their own terms to each copy. The app store distribution +permission in LICENSE lets you convey FTW through a store such as the App +Store, TestFlight or Google Play despite those terms. Offer the exact +version's Corresponding Source outside the store, make the license and the +source offer reachable in the app, and add no restrictions of your own. +Recipients must be able to build that source and install it on their own +device with the platform's standard developer tools; your signing keys and +store accounts stay yours. + +The permission covers code whose rights holders granted it. Every +contribution to this repository comes with it, as CONTRIBUTING.md says. +Third-party code keeps its own terms, so check that each dependency allows +store distribution before you add it. diff --git a/README.md b/README.md index 5ae3564..7ea34ee 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ wrapping copy so Now paints without a passkey prompt. ## Licence -AGPL-3.0-only with the Energyplan combination permission. See -[LICENSE](LICENSE), [LICENSING.md](LICENSING.md) and [NOTICE](NOTICE). +AGPL-3.0-only with the Energyplan combination permission and an app store +distribution permission. See [LICENSE](LICENSE), [LICENSING.md](LICENSING.md) +and [NOTICE](NOTICE). Earlier Apache-licensed versions retain their earlier grants. From 669b830c2a49611cc3f2d24b52aa16bac37cddab Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:14:14 +0200 Subject: [PATCH 08/25] Fit the lock screen, drop UIKit, and time the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the lock screen the inline line shares its width with the date, so it now shows the price and the start of the cheapest stretch ("273 öre · billigast 02:45"), and "Inga priser · öppna FTW" when there is none. The rectangle's lower lines shrink a little before they would be cut. Colours resolve through a SwiftUI ShapeStyle instead of UIColor, and the background task reads the scene phase instead of UIApplication. The timeline sorts the slots and reads what the total holds once, not once per entry: 121 entries took 37-40 ms in a Debug build in the simulator, now 14-17 ms, file read included. An OSSignposter interval wraps it for Instruments, and the log line carries the time. The price figure changes with a numeric content transition. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/FTWApp.swift | 6 ++- .../Sources/FTWShared/PriceGlance.swift | 52 +++++++++++++------ .../Resources/sv.lproj/Localizable.strings | 3 ++ .../FTWSharedTests/PriceGlanceTests.swift | 10 ++-- appleApp/FTWWidgets/PriceViews.swift | 50 +++++++++++------- appleApp/FTWWidgets/PriceWidget.swift | 7 ++- 6 files changed, 87 insertions(+), 41 deletions(-) diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index f35afc0..cb35e34 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -52,6 +52,7 @@ struct FTWApp: App { // switcher: slow the stream, keep the session. Only the // background closes it. app.setVisible(phase == .active) + PricesInBackground.inBackground = phase == .background if phase == .background { app.suspend() PricesInBackground.schedule(app) @@ -74,6 +75,9 @@ enum PricesInBackground { /// Listed under BGTaskSchedulerPermittedIdentifiers in FTW-Info.plist. static let id = "energy.ftw.app.prices" static let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + /// Set from the scene phase. A cold launch for this task has no scene, + /// so it starts in the background. + static var inBackground = true static func schedule(_ app: AppModel) { guard let site = app.site, site.siteId != nil, !app.isDemo else { return } @@ -100,7 +104,7 @@ enum PricesInBackground { let prices = await site.refreshSharedPrices() log.info("background prices: \(prices == nil ? "nothing written" : "\(prices!.slots.count) slots written", privacy: .public)") // Someone may have opened the app meanwhile; their session stays. - if UIApplication.shared.applicationState == .background { app.suspend() } + if inBackground { app.suspend() } } } diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift index d5e6c15..950ac0c 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift @@ -11,8 +11,6 @@ import Foundation public enum PriceGlance { /// How far ahead the medium widget's curve reaches. public static let curveMs: Double = 12 * 3_600_000 - /// The stretch "cheapest" looks for, as on the app's price card. - public static let blockHours: Double = 2 /// What the box's total holds besides the market price. The wire has /// spot and total but not the settings between them, so this is read off @@ -65,6 +63,8 @@ public enum PriceGlance { public let includedText: String /// "Cheapest 02–04" public let cheapestText: String? + /// "cheapest 02:45": the start alone, for one short line. + public let cheapestStartText: String? /// Why there is no price: "No prices after 24:00 · open FTW". public let emptyText: String? /// "Fetched 13:05" @@ -73,11 +73,13 @@ public enum PriceGlance { /// Whether the widget should go grey: no price for this moment. public var isEmpty: Bool { state != .price } - /// One line for the lock screen's inline slot. + /// One line for the lock screen's inline slot, which shares its + /// width with the date. Without a price it only sends the reader to + /// the app; the other families say when the prices ended. public var inlineText: String { - guard let priceText else { return emptyText ?? "" } + guard let priceText else { return GlanceWord.noPricesShort.text } let price = "\(priceText) \(unitLabel)" - return cheapestText.map { "\(price) · \($0)" } ?? price + return cheapestStartText.map { "\(price) · \($0)" } ?? price } } @@ -85,14 +87,16 @@ public enum PriceGlance { /// last where the prices end. public static func moments(_ read: CacheRead?, nowMs: Double, calendar: Calendar = .current) -> [Moment] { guard let read else { return [moment(nil, atMs: nowMs, calendar: calendar)] } + // Sorted and read once, not once per moment. let slots = usable(read.value.prices) + let included = included(slots) var edges = Set() for s in slots { edges.insert(s.startMs) edges.insert(s.startMs + s.durationMs) } let ahead = edges.filter { $0 > nowMs }.sorted() - return ([nowMs] + ahead).map { moment(read, atMs: $0, calendar: calendar) } + return ([nowMs] + ahead).map { moment(read, slots: slots, included: included, atMs: $0, calendar: calendar) } } /// The widget at one moment. @@ -101,15 +105,18 @@ public enum PriceGlance { return Moment( atMs: t, state: .nothing, carrier: .none, srcState: .never, readAtMs: nil, currency: "", current: nil, cheapest: nil, curve: [], meanMinor: nil, included: .spotOnly, - priceText: nil, unitText: "", unitLabel: "", includedText: "", cheapestText: nil, + priceText: nil, unitText: "", unitLabel: "", includedText: "", cheapestText: nil, cheapestStartText: nil, emptyText: GlanceWord.openToSee.text, fetchedText: nil ) } + let slots = usable(read.value.prices) + return moment(read, slots: slots, included: included(slots), atMs: t, calendar: calendar) + } + + private static func moment(_ read: CacheRead, slots: [PriceSlot], included: Included, atMs t: Double, calendar: Calendar) -> Moment { let prices = read.value.prices - let slots = usable(prices) let unit = PriceUnits.unit(prices.currency) let current = slots.first { t >= $0.startMs && t < $0.startMs + $0.durationMs } - let included = included(slots) let state: State if current != nil { @@ -124,8 +131,8 @@ public enum PriceGlance { var curve: [PriceStrip.Bar] = [] var mean: Double? if current != nil { - let summary = PriceStrip.summary(prices, nowMs: t) - cheapest = PriceStrip.bestBlock(slots.filter { $0.startMs + $0.durationMs > t }, hours: blockHours, cheapest: true) + let summary = PriceStrip.summary(Prices(zone: prices.zone, currency: prices.currency, slots: slots, stale: prices.stale), nowMs: t) + cheapest = summary.cheapest curve = summary.bars.filter { $0.startMs < t + curveMs } mean = summary.meanMinor } @@ -157,6 +164,7 @@ public enum PriceGlance { unitLabel: unit.label, includedText: text(included), cheapestText: cheapest.map { clock.cheapest($0, from: t) }, + cheapestStartText: cheapest.map { clock.cheapestStart($0, from: t) }, emptyText: emptyText, fetchedText: GlanceWord.fetched.format(clock.at(read.record.readAtMs, asEnd: false, from: t)) ) @@ -217,9 +225,12 @@ enum GlanceWord: String, CaseIterable { case cheapest = "Cheapest %@" case cheapestNow = "Cheapest now until %@" case tomorrow = "tomorrow %@" + case cheapestStart = "cheapest %@" + case cheapestStartNow = "cheapest now" case noPricesAfter = "No prices after %@ · open FTW" case noPriceUntil = "No price until %@ · open FTW" case openToSee = "Open FTW to see prices" + case noPricesShort = "No prices · open FTW" case fetched = "Fetched %@" case inclGridFeeAndVAT = "incl. grid fee and VAT" case inclVAT = "incl. VAT" @@ -286,11 +297,20 @@ struct Clock { if block.startMs <= t { return GlanceWord.cheapestNow.format(short(b)) } - var range = "\(short(a))–\(short(b))" + let range = "\(short(a))–\(short(b))" + return GlanceWord.cheapest.format(misleads(a, from: t) ? GlanceWord.tomorrow.format(range) : range) + } + + /// "cheapest 02:45", "cheapest now", "cheapest tomorrow 15:00". + func cheapestStart(_ block: PriceStrip.Block, from t: Double) -> String { + if block.startMs <= t { return GlanceWord.cheapestStartNow.text } + let a = wall(block.startMs, asEnd: false) + return GlanceWord.cheapestStart.format(misleads(a, from: t) ? GlanceWord.tomorrow.format(a.hm) : a.hm) + } + + /// A time tomorrow that the clock alone would read as later today. + private func misleads(_ a: Wall, from t: Double) -> Bool { let now = wall(t, asEnd: false) - if a.day > now.day, (a.hour, a.minute) > (now.hour, now.minute) { - range = GlanceWord.tomorrow.format(range) - } - return GlanceWord.cheapest.format(range) + return a.day > now.day && (a.hour, a.minute) > (now.hour, now.minute) } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 3f30126..2fe7e81 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -34,9 +34,12 @@ "Cheapest %@" = "Billigast %@"; "Cheapest now until %@" = "Billigast nu till %@"; "tomorrow %@" = "i morgon %@"; +"cheapest %@" = "billigast %@"; +"cheapest now" = "billigast nu"; "No prices after %@ · open FTW" = "Inga priser efter %@ · öppna FTW"; "No price until %@ · open FTW" = "Inget pris förrän %@ · öppna FTW"; "Open FTW to see prices" = "Öppna FTW för att se priser"; +"No prices · open FTW" = "Inga priser · öppna FTW"; "Fetched %@" = "Hämtat %@"; "incl. grid fee and VAT" = "inkl. nätavgift och moms"; "incl. VAT" = "inkl. moms"; diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift index 0e03bf1..df2cb40 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift @@ -81,7 +81,7 @@ import Testing #expect(last.cheapestText == nil) #expect(last.curve.isEmpty) #expect(last.emptyText == "No prices after 18:00 · open FTW") - #expect(last.inlineText == "No prices after 18:00 · open FTW") + #expect(last.inlineText == "No prices · open FTW") } /// Nothing after the end is priced: not the last hour stretched, not @@ -162,9 +162,12 @@ import Testing let evening = PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar) #expect(evening.cheapest?.startMs == at(13, 2)) #expect(evening.cheapestText == "Cheapest 02–04") - #expect(evening.inlineText == "\(evening.priceText!) öre · Cheapest 02–04") + // The inline line shares its width with the date: the start alone. + #expect(evening.inlineText == "\(evening.priceText!) öre · cheapest 02:00") // Inside the block it is now, and says until when. - #expect(PriceGlance.moment(r, atMs: at(13, 2, 30), calendar: calendar).cheapestText == "Cheapest now until 04") + let inside = PriceGlance.moment(r, atMs: at(13, 2, 30), calendar: calendar) + #expect(inside.cheapestText == "Cheapest now until 04") + #expect(inside.cheapestStartText == "cheapest now") } @Test func cheapestFollowsQuarterHours() { @@ -177,6 +180,7 @@ import Testing @Test func tomorrowIsNamedOnlyWhenTheClockWouldMislead() { let r = read(night(cheapFrom: at(13, 15), cheapTo: at(13, 17)), readAtMs: at(12, 13, 5)) #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestText == "Cheapest tomorrow 15–17") + #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestStartText == "cheapest tomorrow 15:00") // The same block seen the next morning is today's. #expect(PriceGlance.moment(r, atMs: at(13, 9), calendar: calendar).cheapestText == "Cheapest 15–17") } diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift index 1cf5432..f97cad9 100644 --- a/appleApp/FTWWidgets/PriceViews.swift +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -11,15 +11,15 @@ struct PriceWidgetView: View { var body: some View { content - .containerBackground(for: .widget) { background } + .containerBackground(for: .widget) { Rectangle().fill(background) } } /// Grey when there is no price for this moment. The lock screen draws /// its own, so the accessory families bring none. - private var background: Color { + private var background: AnyShapeStyle { switch family { - case .accessoryRectangular, .accessoryInline, .accessoryCircular: return .clear - default: return moment.isEmpty ? Tone.empty : Tone.surface + case .accessoryRectangular, .accessoryInline, .accessoryCircular: return AnyShapeStyle(.clear) + default: return AnyShapeStyle(moment.isEmpty ? Tone.empty : Tone.surface) } } @@ -94,9 +94,9 @@ struct RectangularPrice: View { Text("\(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") .widgetAccentable() if let cheapest = moment.cheapestText { - Text(cheapest).font(.caption) + Text(cheapest).font(.caption).lineLimit(1).minimumScaleFactor(0.75) } - Text(moment.includedText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.7) } else { Text(moment.emptyText ?? "").font(.caption).foregroundStyle(.secondary).lineLimit(3) } @@ -115,6 +115,7 @@ struct PriceFigure: View { var body: some View { Text(price) .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .contentTransition(.numericText()) .minimumScaleFactor(0.5) .lineLimit(1) .widgetAccentable() @@ -231,14 +232,14 @@ struct Curve: View { /// The app's colour roles (FTW/UI/Theme.swift, from the web app's tokens), /// the few a price widget needs. enum Tone { - static let surface = pair(0xFAFAF8, 0x161616) - static let empty = pair(0xDCDCD6, 0x2A2A2A) - static let cheap = pair(0x009639, 0x5FD37F) - static let dear = pair(0xCC243D, 0xFF6E74) - static let negative = pair(0xD48500, 0xFFB000) - static let flat = pair(0x686862, 0x858585) - - static func of(_ tone: PriceStrip.Tone) -> Color { + static let surface = Pair(0xFAFAF8, 0x161616) + static let empty = Pair(0xDCDCD6, 0x2A2A2A) + static let cheap = Pair(0x009639, 0x5FD37F) + static let dear = Pair(0xCC243D, 0xFF6E74) + static let negative = Pair(0xD48500, 0xFFB000) + static let flat = Pair(0x686862, 0x858585) + + static func of(_ tone: PriceStrip.Tone) -> Pair { switch tone { case .cheap: return cheap case .dear: return dear @@ -246,14 +247,23 @@ enum Tone { case .negative: return negative } } +} + +/// One colour for light and one for dark, picked by SwiftUI where it draws. +struct Pair: ShapeStyle { + let light: Color + let dark: Color + + init(_ light: UInt32, _ dark: UInt32) { + self.light = Self.color(light) + self.dark = Self.color(dark) + } - private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { - Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) + func resolve(in environment: EnvironmentValues) -> Color { + environment.colorScheme == .dark ? dark : light } -} -private extension UIColor { - convenience init(rgb: UInt32) { - self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) + private static func color(_ rgb: UInt32) -> Color { + Color(red: Double((rgb >> 16) & 0xFF) / 255, green: Double((rgb >> 8) & 0xFF) / 255, blue: Double(rgb & 0xFF) / 255) } } diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift index 2e42dc2..bb50c0f 100644 --- a/appleApp/FTWWidgets/PriceWidget.swift +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -4,6 +4,7 @@ import SwiftUI import WidgetKit private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") +private let signposter = OSSignposter(subsystem: "energy.ftw.app", category: "widgets") @main struct FTWWidgets: WidgetBundle { @@ -46,9 +47,13 @@ struct PriceTimeline: TimelineProvider { /// The app reloads every timeline after it writes, so nothing here asks /// to be woken. The last entry falls where the prices end and stays. func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let interval = signposter.beginInterval("price timeline") + let started = ContinuousClock.now let entries = entries(now: .now) + let ms = (ContinuousClock.now - started) / .milliseconds(1) + signposter.endInterval("price timeline", interval) if let last = entries.last?.moment { - log.info("price timeline: \(entries.count, privacy: .public) entries, last \(String(describing: last.state), privacy: .public)") + log.info("price timeline: \(entries.count, privacy: .public) entries in \(ms, format: .fixed(precision: 1), privacy: .public) ms, last \(String(describing: last.state), privacy: .public)") } completion(Timeline(entries: entries, policy: .never)) } From 97b5048c7a491fda58ded041973e8721dd6b0d15 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:21:31 +0200 Subject: [PATCH 09/25] Add the car view and cut Now to what is looked at daily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now keeps four things: "Is FTW in control?" in one calm line, the power flow, the car as one row that opens the car view, and the price card. The headline sentence, today's totals, savings and the fuse go. The line is green only when Core's control feedback says so over a live stream; a cache is grey and dated. The car view, in FTW/Car: - How full the car is, and who says so. Plugged in and unread, FTW assumes 20 % and says so with a badge. 20/40/60/80 and ±5 send loadpoint.soc.set at once; quick taps fold into one send. - The goal, on the household clock, with the next deadline, whether the plan makes it, and when it charges. "Use 80 % by 07:00 on weekdays" when there is none; an editor for the rest. - Charge now, Pause and Back to the plan, each with a receipt: sent, received, done, or why not. - In the demo, a card to plug the car in and out. Every word is in English and Swedish. The tab bar with one tab gives way to a navigation stack. The bands above every screen stop growing past xxxLarge text, and the flow diagram past xLarge. The demo's loopback could deliver a frame before the one sent ahead of it: each frame had its own timer, and live timers with the same delay need not fire in order. An api.chunk before its api.head read as "your box didn't answer". Each timer now delivers the oldest frame. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Car/CarView.swift | 336 ++++++++++++++++++ appleApp/FTW/Car/GoalEditor.swift | 164 +++++++++ appleApp/FTW/UI/FlowDiagram.swift | 20 +- appleApp/FTW/UI/NowView.swift | 265 +++++++------- appleApp/FTW/UI/PriceChart.swift | 20 +- appleApp/FTW/UI/RootView.swift | 82 ++--- appleApp/FTW/UI/Theme.swift | 15 +- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 23 +- .../FTWKit/Sources/FTWKit/Format/EV.swift | 17 + .../FTWKit/Tests/FTWKitTests/CarTests.swift | 53 +++ 10 files changed, 795 insertions(+), 200 deletions(-) create mode 100644 appleApp/FTW/Car/CarView.swift create mode 100644 appleApp/FTW/Car/GoalEditor.swift diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift new file mode 100644 index 0000000..9c0c501 --- /dev/null +++ b/appleApp/FTW/Car/CarView.swift @@ -0,0 +1,336 @@ +import FTWDemo +import FTWKit +import SwiftUI + +/// The car: how full it is and who says so, the goal and whether it makes +/// it, and Charge now, Pause and Back to the plan. Every tap is followed to +/// the box's answer, on screen. +struct CarView: View { + let app: AppModel + let home: HomeModels + let id: String? + @State private var correcting = false + @State private var editingGoal = false + + private var model: LoadpointsModel { home.loadpoints } + private var site: SiteModel { home.site } + private var canControl: Bool { site.canConfigure } + + private var lp: Loadpoint? { + id.flatMap { want in model.points.first { $0.id == want } } ?? model.points.first + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let lp { + status(lp) + level(lp) + goal(lp) + if lp.pluggedIn, canControl { controls(lp) } + if app.isDemo { demo(lp) } + } else if model.loaded { + Hint(L("This box has no charger.")) + } else { + ProgressView().frame(maxWidth: .infinity) + } + } + .padding(16) + .frame(maxWidth: 640, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + .navigationTitle(L("Car")) + .navigationBarTitleDisplayMode(.inline) + .refreshable { try? await model.loadChargers() } + .onAppear { + model.activate() + model.watchPlan() + } + .onDisappear { + model.unwatchPlan() + model.deactivate() + } + .sheet(isPresented: $editingGoal) { + if let lp { GoalEditor(model: model, lp: lp) } + } + } + + // MARK: What it does now + + @ViewBuilder private func status(_ lp: Loadpoint) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(EVText.status(lp, zone: model.zone)) + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let session = EVText.session(lp) { Hint(session) } + if model.stale { + Hint(model.error ?? model.readAtMs.map { AppWords.format("Last known %@.", AppWords.clock($0, zone: model.zone)) } ?? L("Waiting for your box."), tone: Theme.generation) + } + } + .accessibilityElement(children: .combine) + } + + // MARK: How full is it? + + @ViewBuilder private func level(_ lp: Loadpoint) -> some View { + let level = model.level(lp) + let assumed = level?.origin == .assumed || level?.origin == .unconfirmed + Card { + Kicker(L("How full is the car?")) + if let level { + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 10) { levelFigure(level, assumed: assumed) } + VStack(alignment: .leading, spacing: 6) { levelFigure(level, assumed: assumed) } + } + // The badge says FTW assumes; the line says what to do. + Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) + .font(.subheadline) + .foregroundStyle(assumed ? Theme.fg : Theme.fgDim) + .fixedSize(horizontal: false, vertical: true) + if canControl { + if level.origin == .car, !correcting { + Button(L("Correct the level")) { correcting = true }.buttonStyle(.quiet) + Hint(L("Only correct it if the car shows something else.")) + } else { + LevelChips(current: level.pct) { pct in + Task { await model.setLevel(lp, pct: pct) } + } + } + } + } else if !lp.pluggedIn { + Text(L("Plug in the car to tell FTW its level.")) + .font(.subheadline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(L("Level unknown")).font(.headline) + } + if let outcome = model.outcome(level: lp) { + ReceiptView(outcome: outcome) + } + } + .overlay(alignment: .leading) { + if assumed { + UnevenRoundedRectangle(topLeadingRadius: Theme.radius, bottomLeadingRadius: Theme.radius) + .fill(Theme.generation) + .frame(width: 4) + } + } + } + + @ViewBuilder private func levelFigure(_ level: EVText.Level, assumed: Bool) -> some View { + Text(AppWords.percent(level.pct)) + .font(Theme.percent(.largeTitle, weight: .bold)) + .foregroundStyle(assumed ? Theme.fgMuted : Theme.fg) + .contentTransition(.numericText()) + if assumed { + Text(L("FTW assumes")) + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.onAccent) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Theme.generation, in: Capsule()) + .fixedSize() + } + } + + // MARK: The goal + + @ViewBuilder private func goal(_ lp: Loadpoint) -> some View { + Card { + Kicker(L("Goal")) + if let s = lp.schedule { + Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: model.zone)) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let next = EVText.nextGoal(lp, nowMs: site.nowMs, zone: model.zone) { + Hint(next) + } + if lp.pluggedIn { + if let r = model.readiness(lp) { + Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.nowMs, zone: model.zone)) + } + if let plan = EVText.plan(lp, windows: model.windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: model.zone) { + Hint(plan) + } + } + if canControl { + Button(L("Change")) { editingGoal = true }.buttonStyle(.outline) + } + } else { + Text(L("No goal yet. Without one, FTW only charges from spare solar.")) + .font(.subheadline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if canControl { + Button(AppWords.format("Use %@", EVText.suggestedGoalText())) { + let g = EVText.suggestedGoal + Task { await model.saveGoal(lp, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days, zone: model.zone) } + } + .buttonStyle(.primary) + Button(L("Change")) { editingGoal = true }.buttonStyle(.quiet) + } + } + if let save = model.goalSave[lp.id], !editingGoal { + Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.importing }; return Theme.fgDim }()) + } + } + } + + // MARK: Charge now, Pause, Back to the plan + + @ViewBuilder private func controls(_ lp: Loadpoint) -> some View { + let paused = EVText.isPaused(lp) + let holding = lp.manualActive && !paused + let busy = model.holdSending(lp) + Card { + if holding || paused { + Button(L("Back to the plan")) { Task { await model.backToPlan(lp) } } + .buttonStyle(.primary) + .disabled(busy) + } + if !holding { + Button(L("Charge now")) { Task { await model.chargeNow(lp) } } + .buttonStyle(paused ? .secondary : .primary) + .disabled(busy) + } + if !paused { + Button(L("Pause")) { Task { await model.pause(lp) } } + .buttonStyle(.secondary) + .disabled(busy) + } + Hint(L("Charge now runs until you go back to the plan or unplug.")) + if let outcome = model.outcome(hold: lp) { + ReceiptView(outcome: outcome) + } + } + } + + // MARK: The demo's own cable + + @ViewBuilder private func demo(_ lp: Loadpoint) -> some View { + Card { + Kicker(L("Demo")) + Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { + SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) + model.reread() + } + .buttonStyle(.outline) + Hint(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) + } + } +} + +/// 20, 40, 60, 80 and a step of five either way. Each tap goes to the box +/// at once; there is no Save. +struct LevelChips: View { + let current: Double + let choose: (Double) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 68), spacing: 8)], spacing: 8) { + ForEach(EVText.levelPresets, id: \.self) { pct in + chip(AppWords.percent(pct), selected: current.rounded() == pct, label: AppWords.percent(pct)) { choose(pct) } + } + } + HStack(spacing: 8) { + chip("−5", selected: false, label: AppWords.format("Set %@", AppWords.percent(EVText.stepped(current, up: false)))) { choose(EVText.stepped(current, up: false)) } + chip("+5", selected: false, label: AppWords.format("Set %@", AppWords.percent(EVText.stepped(current, up: true)))) { choose(EVText.stepped(current, up: true)) } + } + } + } + + private func chip(_ text: String, selected: Bool, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(text) + .font(Theme.percent(.body)) + .frame(maxWidth: .infinity, minHeight: 44) + .foregroundStyle(selected ? Theme.onAccent : Theme.fg) + .background(selected ? Theme.accent : Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .overlay(RoundedRectangle(cornerRadius: Theme.radiusSmall).strokeBorder(Theme.line, lineWidth: selected ? 0 : 1)) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} + +/// Whether the car makes its goal, in the plan's own numbers. +struct Readiness: View { + let readiness: EVText.Readiness + let text: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") + .foregroundStyle(short ? Theme.generation : Theme.exporting) + .accessibilityHidden(true) + Text(text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } + + private var short: Bool { + if case .short = readiness { return true } + return false + } +} + +/// The receipt under a control: sent, received, done, and one sentence. +struct ReceiptView: View { + let outcome: EVText.Outcome + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { marks } + VStack(alignment: .leading, spacing: 4) { marks } + } + Text(outcome.text) + .font(.footnote.weight(.medium)) + .foregroundStyle(color) + .fixedSize(horizontal: false, vertical: true) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .accessibilityElement(children: .combine) + .animation(.default, value: outcome) + } + + @ViewBuilder private var marks: some View { + mark(L("Sent"), outcome.sent) + mark(L("Received"), outcome.received) + mark(L("Done"), outcome.done) + } + + private func mark(_ label: String, _ state: EVText.Outcome.Mark) -> some View { + HStack(spacing: 4) { + switch state { + case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(Theme.exporting) + case .active: ProgressView().controlSize(.mini) + case .waiting: Image(systemName: "circle").foregroundStyle(Theme.fgMuted) + case .failed: Image(systemName: "xmark.circle.fill").foregroundStyle(Theme.importing) + } + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(state == .waiting ? Theme.fgMuted : Theme.fg) + } + } + + private var color: Color { + switch outcome.tone { + case .working: return Theme.fgDim + case .done: return Theme.exporting + case .warning: return Theme.generation + case .failed: return Theme.importing + } + } +} diff --git a/appleApp/FTW/Car/GoalEditor.swift b/appleApp/FTW/Car/GoalEditor.swift new file mode 100644 index 0000000..dc63242 --- /dev/null +++ b/appleApp/FTW/Car/GoalEditor.swift @@ -0,0 +1,164 @@ +import FTWKit +import SwiftUI + +/// The goal: how full, by when, on which days. One Save, one PUT. The time +/// is set and shown on the household clock, the one the box keeps. +struct GoalEditor: View { + let model: LoadpointsModel + let lp: Loadpoint + @Environment(\.dismiss) private var dismiss + @State private var socPct: Double + @State private var time: Date + @State private var days: Days + @State private var saving = false + + enum Days: Hashable { + case weekdays, everyDay, weekends, once + case custom(Int) + + init(recurring: Bool, mask: Int) { + guard recurring else { self = .once; return } + switch mask & 0x7f { + case 0, 0x7f: self = .everyDay + case 0b0011111: self = .weekdays + case 0b1100000: self = .weekends + default: self = .custom(mask & 0x7f) + } + } + + var recurring: Bool { self != .once } + + var mask: Int { + switch self { + case .weekdays: return 0b0011111 + case .everyDay, .once: return 0x7f + case .weekends: return 0b1100000 + case .custom(let m): return m + } + } + + var label: String { + switch self { + case .weekdays: return L("Weekdays") + case .everyDay: return L("Every day") + case .weekends: return L("Weekends") + case .once: return L("Once") + case .custom(let m): + let days = EVText.days(m) + return days.prefix(1).uppercased() + days.dropFirst() + } + } + } + + init(model: LoadpointsModel, lp: Loadpoint) { + self.model = model + self.lp = lp + let zone = model.zone + let now = Date() + let s = lp.schedule + _socPct = State(initialValue: s.map { $0.finishAtVehicleLimit ? 100 : $0.socPct } ?? EVText.suggestedGoal.socPct) + let clock = s.map { EVText.goalClock($0, at: now, zone: zone) } ?? EVText.suggestedGoal.clock + _time = State(initialValue: Self.date(clock: clock, zone: zone)) + _days = State(initialValue: s.map { Days(recurring: $0.recurring, mask: $0.days) } ?? .weekdays) + } + + static func calendar(_ zone: TimeZone) -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + return cal + } + + /// Today at that clock in the household zone, for the time wheel. + static func date(clock: Int, zone: TimeZone) -> Date { + let cal = calendar(zone) + return cal.date(bySettingHour: clock / 60, minute: clock % 60, second: 0, of: Date()) ?? Date() + } + + private var clock: Int { + let c = Self.calendar(model.zone).dateComponents([.hour, .minute], from: time) + return (c.hour ?? 0) * 60 + (c.minute ?? 0) + } + + private var choices: [Days] { + var out: [Days] = [.weekdays, .everyDay, .weekends, .once] + if case .custom = days { out.insert(days, at: 0) } + return out + } + + private var failure: String? { + if case .failed(let help)? = model.goalSave[lp.id], !saving { return help } + return nil + } + + var body: some View { + NavigationStack { + Form { + Section(L("Charge to")) { + Stepper(value: $socPct, in: 10...100, step: 5) { + Text(AppWords.percent(socPct)).font(Theme.percent(.title2)) + } + } + Section(L("Ready by")) { + DatePicker(L("Ready by"), selection: $time, displayedComponents: .hourAndMinute) + .datePickerStyle(.wheel) + .labelsHidden() + .environment(\.timeZone, model.zone) + .frame(maxWidth: .infinity) + } + Section(L("Repeat")) { + Picker(L("Repeat"), selection: $days) { + ForEach(choices, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.inline) + .labelsHidden() + } + Section { + Text(preview).font(.subheadline).foregroundStyle(Theme.fgDim) + if let failure { + Text(failure).font(.subheadline).foregroundStyle(Theme.importing) + } + } + if lp.schedule != nil { + Section { + Button(L("Remove goal"), role: .destructive) { + Task { + saving = true + await model.removeGoal(lp) + saving = false + if case .removed? = model.goalSave[lp.id] { dismiss() } + } + } + .disabled(saving) + } + } + } + .navigationTitle(L("Goal")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L("Cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if saving { + ProgressView() + } else { + Button(L("Save")) { + Task { + saving = true + await model.saveGoal(lp, socPct: socPct, clock: clock, recurring: days.recurring, days: days.mask, zone: model.zone) + saving = false + if case .saved? = model.goalSave[lp.id] { dismiss() } + } + } + } + } + } + } + } + + /// The goal as it will read on the car view. + private var preview: String { + let s = Loadpoint.Schedule(socPct: socPct, timeOfDayMinUTC: 0, clockMin: clock, surplusUnlockPct: 0, recurring: days.recurring, days: days == .everyDay ? 0 : days.mask, finishAtVehicleLimit: false) + return EVText.goal(s, at: Date(), zone: model.zone) + } +} diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift index 0c24500..36c6882 100644 --- a/appleApp/FTW/UI/FlowDiagram.swift +++ b/appleApp/FTW/UI/FlowDiagram.swift @@ -39,6 +39,9 @@ struct FlowDiagram: View { .frame(height: Self.height) .frame(maxWidth: 520) .frame(maxWidth: .infinity) + // A drawing with fixed places: past this size its labels would + // collide, and every figure in it is also said in words elsewhere. + .dynamicTypeSize(...DynamicTypeSize.xLarge) } /// Where each node sits. Several nodes in one corner stack toward the @@ -100,21 +103,24 @@ private struct Hub: View { Image(systemName: "house.fill") .font(.title2) .foregroundStyle(Theme.accent) - Text("\(Text(parts.text).font(Theme.number(22, weight: .bold)))\(Text(" \(parts.unit)").font(.caption))") + Text("\(Text(AppWords.decimal(parts.text)).font(Theme.number(22, weight: .bold)))\(Text(" \(parts.unit)").font(.caption))") .foregroundStyle(Theme.fg) - Text("HOME").font(.caption2.weight(.semibold)).tracking(0.8).foregroundStyle(Theme.fgDim) + Text(L("HOME")).font(.caption2.weight(.semibold)).tracking(0.8).foregroundStyle(Theme.fgDim) if let pct = selfPoweredPct { - Text("\(Int(pct.rounded()))% self-powered today") + Text(AppWords.format("%@ self-powered today", AppWords.percent(pct))) .font(.caption2) .foregroundStyle(Theme.fgMuted) .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.8) + .frame(maxWidth: 92) } } .frame(width: 118, height: 118) .background(Circle().fill(Theme.surfaceElevated)) .overlay(Circle().strokeBorder(Theme.accent.opacity(0.6), lineWidth: 2)) .accessibilityElement(children: .combine) - .accessibilityLabel("Home using \(parts.joined)") + .accessibilityLabel(AppWords.format("Home using %@", AppWords.power(loadKw * 1000))) } } @@ -127,17 +133,17 @@ private struct Bubble: View { let parts = PowerFormat.parts(node.kw * 1000) let color = Theme.color(node.tone) VStack(spacing: 1) { - Text(node.title).font(.caption2.weight(.semibold)).tracking(0.6).foregroundStyle(Theme.fgDim) + Text(L(node.title)).font(.caption2.weight(.semibold)).tracking(0.6).foregroundStyle(Theme.fgDim) if let name = node.name { Text(name).font(.caption2).foregroundStyle(Theme.fgMuted).lineLimit(1) } - Text("\(Text(parts.text).font(Theme.number(17, weight: .bold)))\(Text(" \(parts.unit)").font(.caption2))") + Text("\(Text(AppWords.decimal(parts.text)).font(Theme.number(17, weight: .bold)))\(Text(" \(parts.unit)").font(.caption2))") .foregroundStyle(color) if let soc = node.socPct { SocBar(pct: soc) } if !node.sub.isEmpty { - Text(node.sub).font(.caption2).foregroundStyle(Theme.fgDim) + Text(L(node.sub)).font(.caption2).foregroundStyle(Theme.fgDim) } if !node.dailyParts.isEmpty { HStack(spacing: 4) { diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 78c2797..c80442a 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -1,11 +1,12 @@ import FTWKit import SwiftUI -/// Now, the first screen. A glance: one sentence at the top, the house -/// under it, then price, today and the fuse. +/// Now, the first screen, kept to what is looked at every day: whether FTW +/// is in control, the house, the car and the price. struct NowView: View { let app: AppModel let home: HomeModels + let openCar: (String) -> Void private var site: SiteModel { home.site } @@ -13,16 +14,16 @@ struct NowView: View { Group { switch site.session.phase { case .booting: - Title("Your box is starting") - Text("This can take a few minutes after an update while it tidies its database. Nothing is wrong. It will appear here as soon as it is ready.") + Title(L("Your box is starting")) + Text(L("This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.")) if let boot = site.session.boot { Hint("\(Self.bootWords(boot.phase)) · \(boot.pct)%") } case .terminated: - Title("Access ended") - Text(site.session.terminated == .revoked ? "Your access to this home was withdrawn by its owner." : "This session ended.") + Title(L("Access ended")) + Text(site.session.terminated == .revoked ? L("Your access to this home was withdrawn by its owner.") : L("This session ended.")) if site.session.terminated == .revoked { - Button("Your box won't let this phone in?") { app.recovering = true }.buttonStyle(.quiet) + Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) } default: if site.session.fields.isEmpty { @@ -35,24 +36,22 @@ struct NowView: View { .onAppear { home.status.activate() home.prices.activate() - home.savings.activate() } .onDisappear { home.status.deactivate() home.prices.deactivate() - home.savings.deactivate() } } /// Paired, and not one reading has ever arrived. A hollow house would /// read as a home at zero, so this says what is true instead. @ViewBuilder private var nothingYet: some View { - Title("Nothing from your box yet") - Text(app.connectHelp ?? "This phone is paired to it and keeps trying on its own. Your house appears here as soon as the box answers.") + Title(L("Nothing from your box yet")) + Text(app.connectHelp ?? L("This phone is paired to it and keeps trying on its own. Your home appears here as soon as the box answers.")) if app.connectHelp != nil { - Button("Get this phone back in") { app.recovering = true }.buttonStyle(.primary) + Button(L("Get this phone back in")) { app.recovering = true }.buttonStyle(.primary) } else if !app.isDemo { - Button("Your box won't let this phone in?") { app.recovering = true }.buttonStyle(.quiet) + Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) } } @@ -60,8 +59,8 @@ struct NowView: View { private var live: Bool { site.isLive } - private var flowFields: [Int: Double] { - Flow.withLoadpointEV(site.session.fields, evW: home.charging.chargeW) + private var chargeW: Double { + home.loadpoints.stale ? 0 : Flow.loadpointChargeW(home.loadpoints.points) } private var watchingStatus: Bool { @@ -74,169 +73,177 @@ struct NowView: View { if let status = home.status.status, statusLive || !live { return Flow.readings(status: status) } - return Flow.readings(fields: flowFields) + return Flow.readings(fields: Flow.withLoadpointEV(site.session.fields, evW: chargeW)) } @ViewBuilder private var house: some View { if let help = app.connectHelp { Card { Text(help) - Button("Get this phone back in") { app.recovering = true }.buttonStyle(.quiet) + Button(L("Get this phone back in")) { app.recovering = true }.buttonStyle(.quiet) } } if site.session.needsUpdate { - Card { Text("This app is older than your box. Some things are hidden until it updates.") } + Card { Text(L("This app is older than your box. Some things are hidden until it updates.")) } } - Text(Explanation.explain(fields: flowFields, dispatchBlockedBy: site.session.dispatchBlockedBy, ceilingW: site.ceilingW).headline) - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - .accessibilityAddTraits(.isHeader) + InControlRow(answer: InControl.answer( + live: live, + lastKnownMs: site.cachedAtMs ?? site.lastFrameAtMs, + dispatchBlockedBy: site.session.dispatchBlockedBy, + status: home.status.status, + statusFresh: statusLive + )) // Motion claims power is flowing right now, so a cached view holds still. FlowDiagram(readings: readings, moving: live) { _ in } if home.status.status != nil, !statusLive { - Hint("Device details are out of date.\(live ? " Showing live totals." : "")") + Hint(L(live ? "Device details are out of date. Showing live totals." : "Device details are out of date.")) } - NowOutlook(site: site, home: home, statusLive: statusLive) - } - static func bootWords(_ phase: String) -> String { - switch phase { - case "vacuum": return "tidying its records" - case "migrate": return "bringing its records up to date" - case "drivers": return "waking the equipment" - default: return "getting ready" + if site.heardFromBox { + ForEach(home.loadpoints.points) { lp in + CarRow(lp: lp, loadpoints: home.loadpoints) { openCar(lp.id) } + } } - } -} - -/// Price, today and the fuse: the rest of a glance. -private struct NowOutlook: View { - let site: SiteModel - let home: HomeModels - let statusLive: Bool - var body: some View { if let prices = home.prices.prices { Card { PriceChart(prices: prices, nowMs: site.nowMs, compact: true) if home.prices.hasHole { - Hint("Some hours are missing their price.") + Hint(L("Some hours are missing their price.")) } else if prices.stale { - Hint("Tomorrow's rates aren't published yet.") + Hint(L("Tomorrow's rates aren't published yet.")) } } } - if let today = today { - todayCard(today) - } - if let status = home.status.status, let fuse = Flow.fuse(status: status) { - fuseCard(fuse) - } } - private struct Today { - let importWh: Double - let exportWh: Double - let pvWh: Double + static func bootWords(_ phase: String) -> String { + switch phase { + case "vacuum": return "tidying its records" + case "migrate": return "bringing its records up to date" + case "drivers": return "waking the equipment" + default: return "getting ready" + } } +} - private var today: Today? { - guard let t = home.status.status?["energy"]?["today"], t.object != nil else { return nil } - return Today(importWh: EnergyFormat.wholeWh(t["import_wh"]?.number), exportWh: EnergyFormat.wholeWh(t["export_wh"]?.number), pvWh: EnergyFormat.wholeWh(t["pv_wh"]?.number)) - } +/// "Is FTW in control?" in one calm line. Green only when Core says yes +/// over a live stream; a cache is grey and dated. +struct InControlRow: View { + let answer: InControl.Answer - private func todayCard(_ today: Today) -> some View { - Card { - Kicker(statusLive ? "Since midnight" : "Last known") - Text(statusLive ? "Today" : "Last totals").font(.headline) - if !statusLive, let at = home.status.receivedAtMs { - Hint("Energy totals last updated \(Clock.dayAndTime(at)).") - } - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) { - EnergyTile(label: "Imported", wh: today.importWh, color: Theme.importing) - EnergyTile(label: "Exported", wh: today.exportWh, color: Theme.exporting) - EnergyTile(label: "Solar", wh: today.pvWh, color: Theme.generation) - if let savings = home.savings.periods { - VStack(alignment: .leading, spacing: 2) { - Text("Saved \(home.prices.currency)").font(.caption).foregroundStyle(Theme.fgDim) - if savings.today.available { - Text(Savings.compact(savings.today.savedMinor)) - .font(Theme.number(20)) - .foregroundStyle(savings.today.savedMinor >= 0 ? Theme.exporting : Theme.importing) - } else { - Text("—").font(Theme.number(20)) - } - if savings.week.available { - Text("\(Savings.compact(savings.week.savedMinor)) this week").font(.caption).italic().foregroundStyle(Theme.fgDim) - } - } + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: symbol) + .font(.body.weight(.semibold)) + .foregroundStyle(color) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Kicker(L("Is FTW in control?")) + Text(answer.title) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let detail = answer.detail { + Hint(detail) } } + Spacer(minLength: 0) + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + .overlay(alignment: .leading) { + UnevenRoundedRectangle(topLeadingRadius: Theme.radius, bottomLeadingRadius: Theme.radius) + .fill(color) + .frame(width: 4) } + .accessibilityElement(children: .combine) } - private func fuseCard(_ fuse: Flow.Fuse) -> some View { - Card { - Kicker(statusLive ? "Live safety" : "Last known") - Text("Fuse").font(.headline) - if !statusLive, let at = home.status.receivedAtMs { - Hint("Fuse readings last updated \(Clock.dayAndTime(at)).") - } - if !fuse.phases.isEmpty { - ForEach(fuse.phases) { phase in - FuseBar(label: phase.label, amps: phase.amps, pct: phase.pct, exporting: phase.exporting) - } - } else if let fallback = fuse.fallback { - FuseBar(label: "\(Int(fuse.maxAmps)) A", amps: fallback.amps, pct: fallback.pct, exporting: false) - } + private var symbol: String { + switch answer.tone { + case .ok: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .alarm: return "exclamationmark.octagon.fill" + case .neutral: return "circle.dotted" + case .stale: return "clock" } } -} - -struct EnergyTile: View { - let label: String - let wh: Double - let color: Color - var body: some View { - let parts = EnergyFormat.parts(wh) - VStack(alignment: .leading, spacing: 2) { - Text(label).font(.caption).foregroundStyle(Theme.fgDim) - Text("\(Text(parts.text).font(Theme.number(20)))\(Text(" \(parts.unit)").font(.caption))") - .foregroundStyle(color) + private var color: Color { + switch answer.tone { + case .ok: return Theme.exporting + case .warning: return Theme.generation + case .alarm: return Theme.importing + case .neutral, .stale: return Theme.fgMuted } } } -private struct FuseBar: View { - let label: String - let amps: Double - let pct: Double - let exporting: Bool +/// The car on Now: one row that opens the car view. +struct CarRow: View { + let lp: Loadpoint + let loadpoints: LoadpointsModel + let open: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(label).font(.caption.weight(.semibold)).foregroundStyle(Theme.fgDim) - Spacer() - Text("\(PowerFormat.fixed(abs(amps), 1)) A").font(Theme.number(13)) - } - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(Theme.surfaceSunken) - Capsule().fill(color).frame(width: geo.size.width * min(1, max(0, pct / 100))) + Button(action: open) { + HStack(spacing: 12) { + Image(systemName: lp.pluggedIn ? "bolt.car.fill" : "car.side") + .font(.title3) + .foregroundStyle(Theme.mobility) + .frame(width: 32) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + // One text, so it wraps as one line at large sizes. + heading + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + Text(line) + .font(.subheadline) + .foregroundStyle(Theme.fgDim) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.fgMuted) + .accessibilityHidden(true) } - .frame(height: 6) - } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) } - private var color: Color { - if pct >= 90 { return Theme.importing } - if pct >= 70 { return Theme.generation } - return exporting ? Theme.exporting : Theme.storage + /// "Car 20 % FTW assumes". + private var heading: Text { + let car = Text(L("Car")).font(.headline) + guard let level = loadpoints.level(lp) else { return car } + let pct = Text(AppWords.percent(level.pct)).font(Theme.percent(.headline)).foregroundColor(level.origin == .assumed ? Theme.fgMuted : Theme.fg) + let origin = Text(EVText.originShort(level, zone: loadpoints.zone)).font(.caption).foregroundColor(Theme.fgDim) + return Text("\(car) \(pct) \(origin)") + } + + /// What it does now; unplugged, the goal it will meet next time. + private var line: String { + if loadpoints.stale, let at = loadpoints.readAtMs { + return AppWords.format("Last known %@.", AppWords.clock(at, zone: loadpoints.zone)) + } + if !lp.pluggedIn, let s = lp.schedule { + return "\(EVText.status(lp)) · \(EVText.goal(s, at: Date(), zone: loadpoints.zone))" + } + return EVText.status(lp, zone: loadpoints.zone) } } diff --git a/appleApp/FTW/UI/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift index f7139f7..34fa97d 100644 --- a/appleApp/FTW/UI/PriceChart.swift +++ b/appleApp/FTW/UI/PriceChart.swift @@ -16,38 +16,38 @@ struct PriceChart: View { VStack(alignment: .leading, spacing: 10) { if compact { VStack(alignment: .leading, spacing: 2) { - Kicker("Market now") - Text("Electricity price").font(.headline) + Kicker(L("Market now")) + Text(L("Electricity price")).font(.headline) } HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { Text("\(Text(PriceStrip.text(summary.current?.totalMinor, prices.currency)).font(Theme.number(28, weight: .bold)))\(Text(" \(unit.perKwh)").font(.caption))") - Text("\(prices.zone.isEmpty ? "—" : prices.zone) · incl. fees and VAT").font(.caption).foregroundStyle(Theme.fgMuted) + Text("\(prices.zone.isEmpty ? "—" : prices.zone) · \(L("incl. fees and VAT"))").font(.caption).foregroundStyle(Theme.fgMuted) } Spacer() VStack(alignment: .trailing, spacing: 2) { - Text("Cheapest 2 h").font(.caption).foregroundStyle(Theme.fgDim) + Text(L("Cheapest 2 h")).font(.caption).foregroundStyle(Theme.fgDim) if let block = summary.cheapest { Text("\(PriceStrip.text(block.meanMinor, prices.currency)) \(unit.label)").font(Theme.number(15)).foregroundStyle(Theme.exporting) - Text("\(Clock.time(block.startMs))–\(Clock.time(block.endMs))").font(.caption).foregroundStyle(Theme.fgDim) + Text("\(AppWords.clock(block.startMs, zone: .current))–\(AppWords.clock(block.endMs, zone: .current))").font(.caption).foregroundStyle(Theme.fgDim) } else { - Text("No 2 h window published").font(.caption).foregroundStyle(Theme.fgDim) + Text(L("No 2 h window published")).font(.caption).foregroundStyle(Theme.fgDim) } } } .accessibilityElement(children: .combine) if summary.bars.isEmpty { - Hint("No prices published ahead yet.") + Hint(L("No prices published ahead yet.")) } else { strip(summary, unit: unit) .frame(height: 64) if let mean = summary.meanMinor { - Hint("Dotted line: average ahead, \(PriceStrip.text(mean, prices.currency)) \(unit.label)", tone: Theme.fgMuted) + Hint(AppWords.format("Dotted line: average ahead, %@", "\(PriceStrip.text(mean, prices.currency)) \(unit.label)"), tone: Theme.fgMuted) } } } else { HStack { - Text("Price to import").font(.headline) + Text(L("Price to import")).font(.headline) Spacer() Text(unit.perKwh).font(.caption).foregroundStyle(Theme.fgMuted) } @@ -95,7 +95,7 @@ struct PriceChart: View { .foregroundStyle(Theme.accent) .lineStyle(StrokeStyle(lineWidth: 1)) .annotation(position: .top, alignment: .leading) { - Text("now").font(Theme.number(10)).foregroundStyle(Theme.accent) + Text(L("now")).font(Theme.number(10)).foregroundStyle(Theme.accent) } } .chartXAxis { diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index a9e017a..7739268 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -21,16 +21,18 @@ struct RootView: View { } } -enum HomeTab: String, Hashable { - case now +/// Where a home can go from Now. M1 has one place: the car. +enum HomeRoute: Hashable { + /// One charger's car; nil is the first. + case car(String?) - /// Where a home opens. Debug builds take `-FTWTab now` and the like, so - /// CI can photograph every screen of the demo. - static var initial: HomeTab { + /// Where a home opens. Debug builds take `-FTWTab car` and the like, so + /// every screen of the demo can be photographed. + static var initial: [HomeRoute] { #if DEBUG - return UserDefaults.standard.string(forKey: "FTWTab").flatMap(HomeTab.init(rawValue:)) ?? .now + return UserDefaults.standard.string(forKey: "FTWTab") == "car" ? [.car(nil)] : [] #else - return .now + return [] #endif } } @@ -38,7 +40,7 @@ enum HomeTab: String, Hashable { struct HomeView: View { let app: AppModel let home: HomeModels - @State private var tab: HomeTab = .initial + @State private var path: [HomeRoute] = HomeRoute.initial var body: some View { VStack(spacing: 0) { @@ -47,23 +49,27 @@ struct HomeView: View { } else { FreshnessBandView(band: home.site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: home.site.lastFrameAtMs) } - ChargingNotice(site: home.site, charging: home.charging) - // The car view is the next tab here. - TabView(selection: $tab) { - Tab("Now", systemImage: "house", value: HomeTab.now) { - Screen(app: app) { - NowView(app: app, home: home) + NavigationStack(path: $path) { + Screen(app: app) { + NowView(app: app, home: home) { path.append(.car($0)) } + } + .toolbar(.hidden, for: .navigationBar) + .navigationDestination(for: HomeRoute.self) { route in + switch route { + case .car(let id): CarView(app: app, home: home, id: id) } } } } - .onAppear { home.charging.activate() } - .onDisappear { home.charging.deactivate() } + // Now and the car read the chargers from one model, so the box is + // asked once every five seconds, not twice. + .onAppear { home.loadpoints.activate() } + .onDisappear { home.loadpoints.deactivate() } } } -/// One tab's scrolling page, with pull to refresh. -private struct Screen: View { +/// One scrolling page, with pull to refresh. +struct Screen: View { let app: AppModel @ViewBuilder var content: Content @@ -116,6 +122,9 @@ struct FreshnessBandView: View { .background(Theme.surfaceSunken.ignoresSafeArea(edges: .top)) .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } .accessibilityElement(children: .combine) + // One line of chrome above every screen: it must not crowd out the + // screen at the largest text sizes. + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) } private var color: Color { @@ -138,45 +147,20 @@ struct DemoBand: View { Circle() .fill(site.session.phase == .streaming ? Theme.accent : Theme.fgMuted) .frame(width: 8, height: 8) - Text(site.session.phase == .streaming ? "Live demo · simulated home" : "Starting the demo") + Text(L(site.session.phase == .streaming ? "Live demo · simulated home" : "Starting the demo")) .font(.footnote.weight(.medium)) + .lineLimit(1) + .minimumScaleFactor(0.7) Spacer() - Button("Exit demo", action: exit) + Button(L("Exit demo"), action: exit) .font(.footnote.weight(.semibold)) + .lineLimit(1) .buttonStyle(.quiet) } .padding(.horizontal, 16) .padding(.vertical, 6) .background(Theme.surfaceSunken.ignoresSafeArea(edges: .top)) .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } - } -} - -/// A car on the cable, said once above every screen. Only what the box -/// said, and dated when it is out of date. -struct ChargingNotice: View { - let site: SiteModel - let charging: ChargingWatch - - var body: some View { - let fresh = charging.fresh && site.session.phase == .streaming - ForEach(site.heardFromBox ? charging.points.filter(\.pluggedIn) : []) { lp in - let current = fresh && lp.charger?.available != false - VStack(alignment: .leading, spacing: 4) { - Text(current ? "Car connected" : "Car status is out of date") - .font(.subheadline.weight(.semibold)) - Hint(current ? EVText.status(lp, canControl: site.canConfigure) : "Waiting for current charger status. The last reading cannot confirm charging.", tone: Theme.fg) - if current, let plan = EVText.plan(lp, nowMs: site.nowMs, canControl: site.canConfigure) { - Hint(plan) - } - if lp.manualSaveError { Hint(EVText.manualSaveErrorText) } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceRaised) - .overlay(alignment: .leading) { Theme.mobility.frame(width: 3) } - .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } - } + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) } } diff --git a/appleApp/FTW/UI/Theme.swift b/appleApp/FTW/UI/Theme.swift index 258a9b0..83c0d91 100644 --- a/appleApp/FTW/UI/Theme.swift +++ b/appleApp/FTW/UI/Theme.swift @@ -47,6 +47,12 @@ enum Theme { .system(size: size, weight: weight, design: .monospaced) } + /// A percentage: even digits, and a % that reads as a sign, not a + /// fixed-width glyph. + static func percent(_ style: Font.TextStyle, weight: Font.Weight = .semibold) -> Font { + .system(style, weight: weight).monospacedDigit() + } + private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { return Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) } @@ -106,6 +112,7 @@ struct Hint: View { struct FTWButtonStyle: ButtonStyle { enum Kind { case primary, quiet, outline, danger } var kind: Kind + var wide = false @Environment(\.isEnabled) private var enabled func makeBody(configuration: Configuration) -> some View { @@ -113,7 +120,7 @@ struct FTWButtonStyle: ButtonStyle { .font(.body.weight(kind == .quiet ? .regular : .semibold)) .padding(.vertical, kind == .quiet ? 6 : 11) .padding(.horizontal, kind == .quiet ? 4 : 16) - .frame(maxWidth: kind == .primary || kind == .danger ? .infinity : nil) + .frame(maxWidth: wide || kind == .primary || kind == .danger ? .infinity : nil) .foregroundStyle(foreground) .background(background, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) .overlay { @@ -148,6 +155,8 @@ extension ButtonStyle where Self == FTWButtonStyle { static var quiet: FTWButtonStyle { FTWButtonStyle(kind: .quiet) } static var outline: FTWButtonStyle { FTWButtonStyle(kind: .outline) } static var danger: FTWButtonStyle { FTWButtonStyle(kind: .danger) } + /// The second choice beside a primary one, as wide as it. + static var secondary: FTWButtonStyle { FTWButtonStyle(kind: .outline, wide: true) } } /// A pressed-button choice between a few options, the web app's range picker. @@ -191,3 +200,7 @@ enum Clock { Date(timeIntervalSince1970: ms / 1000).formatted(date: .long, time: .omitted) } } + +/// The app's words in the phone's language, English or Swedish +/// (FTWKit's `AppWords`). Text built from a String is shown as given. +func L(_ en: String) -> String { AppWords.text(en) } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 2aa7a1f..ba0c9e4 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -393,6 +393,14 @@ public final class SimulatedBox { "easee": ["status": "ok", "ev_w": .number(house.evWatts)], ], "fuse": ["max_amps": .number(house.fuseAmps), "phases": 3, "voltage": 230], + // Core's answer to "Are we in control?", one row per device. + "control_feedback": [ + ["driver": "sungrow", "kind": "battery", "mode": "plan", "status": "following", "reason": "plan", "severity": "info", "evidence": "measured", "readings_fresh": true, "actual_w": .number(house.batteryW)], + ["driver": "easee", "kind": "ev", "mode": house.manualHoldW == nil ? "plan" : "manual", + "status": house.evWatts > 100 || !house.carPluggedIn ? "following" : "waiting", + "reason": .string(!house.carPluggedIn ? "not_connected" : house.manualHoldW != nil ? "manual_hold" : house.evWatts > 100 ? "plan" : "no_plan_budget"), + "severity": "info", "evidence": "measured", "readings_fresh": true, "actual_w": .number(house.evWatts)], + ], "phase_amps": .array((0..<3).map { .number(((perPhase + Double($0) * 90) / 230 * 10).rounded() / 10) }), "phase_powers": .array((0..<3).map { .number((perPhase + Double($0) * 90).rounded()) }), ]) @@ -576,6 +584,11 @@ public final class LoopbackCarrier: Carrier { private var closed = false private var onFrame: @MainActor (Bytes) -> Void = { _ in } private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + // Frames in flight, oldest first. A real socket keeps order; timers + // with the same delay need not fire in order, so each timer delivers + // the oldest frame rather than its own. + private var toApp: [Bytes] = [] + private var toBox: [Bytes] = [] public init(box: SimulatedBox, scheduler: Scheduler, latencyMs: Double = 120) { self.box = box @@ -583,9 +596,10 @@ public final class LoopbackCarrier: Carrier { self.latencyMs = latencyMs box.send = { [weak self] frame in guard let self else { return } + self.toApp.append(frame) self.scheduler.after(self.latencyMs) { [weak self] in - guard let self, !self.closed else { return } - self.onFrame(frame) + guard let self, !self.closed, !self.toApp.isEmpty else { return } + self.onFrame(self.toApp.removeFirst()) } } scheduler.after(latencyMs) { [weak self] in @@ -602,9 +616,10 @@ public final class LoopbackCarrier: Carrier { public func send(_ frame: Bytes) { guard status.isOpen, !closed else { return } + toBox.append(frame) scheduler.after(latencyMs) { [weak self] in - guard let self, !self.closed else { return } - self.box.receive(frame) + guard let self, !self.closed, !self.toBox.isEmpty else { return } + self.box.receive(self.toBox.removeFirst()) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 24df9ed..36dbf23 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -19,6 +19,16 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { public var days: Int /// The goal is the car's own limit rather than a percentage. public var finishAtVehicleLimit: Bool + + public init(socPct: Double, timeOfDayMinUTC: Int, clockMin: Int?, surplusUnlockPct: Double, recurring: Bool, days: Int, finishAtVehicleLimit: Bool) { + self.socPct = socPct + self.timeOfDayMinUTC = timeOfDayMinUTC + self.clockMin = clockMin + self.surplusUnlockPct = surplusUnlockPct + self.recurring = recurring + self.days = days + self.finishAtVehicleLimit = finishAtVehicleLimit + } } public struct Manual: Equatable, Sendable { @@ -347,6 +357,7 @@ public enum AppWords { "FTW's estimate from energy delivered": "FTW:s uppskattning från laddad energi", "FTW assumes %@. Nobody has told it the real level.": "FTW antar %@. Ingen har angett den riktiga nivån.", "FTW assumes": "FTW antar", + "Nobody has told FTW the car's level yet. Choose it below.": "Ingen har angett bilens nivå än. Välj den nedan.", "from the car": "från bilen", "reported %@": "rapporterad %@", "you set": "du angav", @@ -369,6 +380,12 @@ public enum AppWords { "Remove goal": "Ta bort målet", "Charge to": "Ladda till", "Repeat": "Upprepa", + "Weekdays": "Vardagar", + "Every day": "Varje dag", + "Weekends": "Helger", + "Once": "En gång", + "This box has no charger.": "Boxen har ingen laddare.", + "Set %@": "Ange %@", "Saving the goal…": "Sparar målet…", "Goal saved. FTW is updating the plan.": "Målet är sparat. FTW räknar om planen.", "Goal removed.": "Målet är borttaget.", diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift index 41ba87a..b352ea9 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -461,6 +461,19 @@ import FTWDemo } } + /// The demo answers "Is FTW in control?" as Core does: from live + /// control feedback, and only while the stream is live. + @Test func theDemoSaysFTWIsInControl() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + home.status.activate() + await rig.run(3_000) + let site = home.site + let answer = InControl.answer(live: site.isLive, lastKnownMs: site.lastFrameAtMs, dispatchBlockedBy: site.session.dispatchBlockedBy, status: home.status.status, statusFresh: home.status.fresh) + #expect(answer.tone == .ok) + } + /// Every demo situation opens, so each screen can be shown without a box. @Test func everyDemoCarOpens() async throws { for situation in DemoCar.allCases { @@ -492,3 +505,43 @@ extension LoadpointsModel { await task.value } } + +/// Timers that fire in the reverse of the order they were set, as the live +/// scheduler's sleeping tasks may. +@MainActor +final class ReversingScheduler: Scheduler { + var nowMs: Double = 0 + private var due: [@MainActor () -> Void] = [] + + func after(_ ms: Double, _ action: @escaping @MainActor () -> Void) -> Cancellable { + due.append(action) + return Cancellable() + } + + func random() -> Double { 0.5 } + + func fireAll() { + let now = due + due = [] + for action in now.reversed() { action() } + } +} + +@MainActor +@Suite struct LoopbackTests { + /// The demo's answers arrive in the order the box sent them, whatever + /// order the timers fire in. Out of order, a chunk before its head read + /// as "your box didn't answer". + @Test func framesArriveInTheOrderTheyWereSent() { + let scheduler = ReversingScheduler() + let box = SimulatedBox(scheduler: ManualScheduler()) + let carrier = LoopbackCarrier(box: box, scheduler: scheduler) + var got: [Bytes] = [] + carrier.setHandlers(onFrame: { got.append($0) }, onStatus: { _ in }) + scheduler.fireAll() + let sent: [Bytes] = [[1], [2], [3]] + for f in sent { box.send?(f) } + scheduler.fireAll() + #expect(got == sent) + } +} From 815215cb1dbc3e217924e3d18ec7cd8def46b0aa Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:25:10 +0200 Subject: [PATCH 10/25] Write the cache off the main thread, skip unchanged prices, follow the rendering mode - The App Group write runs off the main actor (a @concurrent function), in the order it was asked for. A sign-out asked for meanwhile wins: writes and clears share a lock and a per-directory clear count, so a late write never puts the home back. - The same prices again are neither written nor reloaded, so the widget timeline is rebuilt only for new prices. The background task waits for the write to land before iOS can suspend it. - The curve follows widgetRenderingMode: tinted, clear and StandBy's one-colour mode keep the current and cheap slots strong and fade the rest. The cheapest line and the symbol take the accent. - A bolt symbol heads every family, as in Apple's own widgets. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/FTWApp.swift | 9 ++- .../Sources/FTWKit/State/SiteModel.swift | 77 ++++++++++++++----- appleApp/FTWKit/Sources/FTWShared/Cache.swift | 35 ++++++++- .../Tests/FTWKitTests/WidgetPricesTests.swift | 24 +++++- appleApp/FTWWidgets/PriceViews.swift | 44 +++++++++-- 5 files changed, 154 insertions(+), 35 deletions(-) diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index cb35e34..e0591e1 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -55,7 +55,7 @@ struct FTWApp: App { PricesInBackground.inBackground = phase == .background if phase == .background { app.suspend() - PricesInBackground.schedule(app) + Task { await PricesInBackground.schedule(app) } } } } @@ -79,10 +79,11 @@ enum PricesInBackground { /// so it starts in the background. static var inBackground = true - static func schedule(_ app: AppModel) { + static func schedule(_ app: AppModel) async { guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + let pricesEndMs = await site.sharedPricesEndMs() let nowMs = Date.now.timeIntervalSince1970 * 1000 - let at = Date(timeIntervalSince1970: PriceRefresh.next(afterMs: nowMs, pricesEndMs: site.sharedPricesEndMs) / 1000) + let at = Date(timeIntervalSince1970: PriceRefresh.next(afterMs: nowMs, pricesEndMs: pricesEndMs) / 1000) let request = BGAppRefreshTaskRequest(identifier: id) request.earliestBeginDate = at do { @@ -96,7 +97,6 @@ enum PricesInBackground { } static func run(_ app: AppModel) async { - defer { schedule(app) } guard let site = app.site, site.siteId != nil, !app.isDemo else { return } log.info("background prices: woken") // A new carrier if the last one was closed, or a fresh stream. @@ -105,6 +105,7 @@ enum PricesInBackground { log.info("background prices: \(prices == nil ? "nothing written" : "\(prices!.slots.count) slots written", privacy: .public)") // Someone may have opened the app meanwhile; their session stays. if inBackground { app.suspend() } + await schedule(app) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 78489ba..12a6db6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -43,6 +43,12 @@ public final class SiteModel { /// What widgets read. Nil for the demo and in tests. @ObservationIgnored private let shared: AppGroupFiles? @ObservationIgnored private var lastCars: (entry: CarEntry, atMs: Double)? + /// The prices last left for the widgets, so the same prices again are + /// neither written nor reloaded. + @ObservationIgnored private var lastPrices: PriceEntry? + /// The latest write to `shared`. Each waits for the one before, so they + /// land in the order they were asked for. + @ObservationIgnored private var writing: Task? /// Called after every write to `shared`, so the app can wake its widgets. @ObservationIgnored public var sharedWritten: (@MainActor () -> Void)? @ObservationIgnored private var attemptStartedAtMs: Double @@ -370,17 +376,24 @@ public final class SiteModel { /// each of those. static let carRewriteMs: Double = 5 * 60_000 - /// Leave today's and tomorrow's prices for the widgets. - func sharePrices(_ prices: Prices, fromMs: Double) { - guard let siteId, let shared else { return } + /// Leave today's and tomorrow's prices for the widgets. The same prices + /// again are skipped: the widgets' timeline already holds them, and a + /// reload would only redraw it. Returns the write, for a caller that + /// must not end before it lands. + @discardableResult + func sharePrices(_ prices: Prices, fromMs: Double) -> Task? { + guard let siteId, let shared else { return nil } + let entry = PriceEntry(fromMs: fromMs, prices: prices) + guard entry != lastPrices else { return nil } + lastPrices = entry let record = CacheRecord( siteId: siteId, carrier: session.carrier, srcState: prices.stale ? .stale : .live, readAtMs: scheduler.nowMs, - value: PriceEntry(fromMs: fromMs, prices: prices) + value: entry ) - write(record, to: shared) + return write(record, to: shared) } /// Leave the chargers for the widgets, as the box just described them. @@ -405,23 +418,48 @@ public final class SiteModel { write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared) } - private func write(_ record: CacheRecord, to shared: AppGroupFiles) { - guard !destroyed else { return } - do { - try shared.write(record) - log.info("wrote \(Value.name, privacy: .public) for widgets, source \(record.srcState.rawValue, privacy: .public)") - sharedWritten?() - } catch { - // A widget showing yesterday's record with its date is better - // than no app; the next read tries again. - log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)") + /// Encoding and the file write run off the main actor. A sign-out + /// asked for meanwhile wins: the write is dropped rather than putting + /// the home back (`AppGroupFiles.clearCount`). + @discardableResult + private func write(_ record: CacheRecord, to shared: AppGroupFiles) -> Task? { + guard !destroyed else { return nil } + let asked = shared.clearCount + let previous = writing + let task = Task { [weak self] in + await previous?.value + do { + guard try await shared.write(record, asked: asked) else { return } + log.info("wrote \(Value.name, privacy: .public) for widgets, source \(record.srcState.rawValue, privacy: .public)") + guard let self, !self.destroyed else { return } + self.sharedWritten?() + } catch { + // A widget showing yesterday's record with its date is + // better than no app; the next read tries again. + log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)") + if Value.self == PriceEntry.self { self?.lastPrices = nil } + } } + writing = task + return task } /// Where the prices the widgets hold run out. Nil when they hold none. - public var sharedPricesEndMs: Double? { - guard let siteId, let record = shared?.read(PriceEntry.self, siteId: siteId) else { return nil } - return record.value.prices.slots.map { $0.startMs + $0.durationMs }.max() + /// Read from what this process wrote, or off the main actor from disk + /// after a cold launch. + public func sharedPricesEndMs() async -> Double? { + if let lastPrices { return Self.end(lastPrices) } + guard let siteId, let shared else { return nil } + return await Self.readEnd(shared, siteId: siteId) + } + + @concurrent + private nonisolated static func readEnd(_ shared: AppGroupFiles, siteId: String) async -> Double? { + shared.read(PriceEntry.self, siteId: siteId).flatMap { end($0.value) } + } + + private nonisolated static func end(_ entry: PriceEntry) -> Double? { + entry.prices.slots.map { $0.startMs + $0.durationMs }.max() } /// The background refresh: wait for the stream, ask for today's and @@ -445,6 +483,9 @@ public final class SiteModel { do { let wire = try await prices(fromMs: from, toMs: from + PriceModel.horizonMs) sharePrices(wire, fromMs: from) + // iOS may suspend the app once the task returns: land the write + // first, or the one already on its way with these prices. + await writing?.value return wire } catch { log.error("background prices: \(error.localizedDescription, privacy: .public)") diff --git a/appleApp/FTWKit/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift index 61221b1..fb6bf43 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Cache.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization // What the app leaves on disk for its widgets, and later for a notification // extension: one home's prices and its car, each with where it came from @@ -158,14 +159,40 @@ public struct AppGroupFiles: CacheSource { /// Only the app writes. Atomic, so a widget never reads half a file. public func write(_ record: CacheRecord) throws { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let data = try JSONEncoder().encode(record) - try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + try Self.clears.withLock { _ in try store(record) } + } + + /// Writes off the caller's thread. `asked` is `clearCount` when the write + /// was asked for: a clear since then means the home was signed out, so + /// the write is dropped rather than putting the home back. Returns + /// whether it wrote. + @concurrent + public nonisolated func write(_ record: CacheRecord, asked: Int) async throws -> Bool { + try Self.clears.withLock { counts in + guard counts[directory, default: 0] == asked else { return false } + try store(record) + return true + } } /// Everything, for signing out. A widget then has nothing to draw. public func removeAll() { - try? FileManager.default.removeItem(at: directory) + Self.clears.withLock { counts in + counts[directory, default: 0] += 1 + try? FileManager.default.removeItem(at: directory) + } + } + + /// How many times this process has cleared this cache. Writes and + /// clears take the same lock, so they land in the order they were asked + /// for. + public var clearCount: Int { Self.clears.withLock { $0[directory, default: 0] } } + private static let clears = Mutex<[URL: Int]>([:]) + + private func store(_ record: CacheRecord) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(record) + try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift index 4466e09..b0c4667 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift @@ -42,7 +42,27 @@ import Testing #expect(read.carrier == .cache) #expect(read.record.readAtMs >= asked && read.record.readAtMs <= scheduler.nowMs) #expect(wakes.count == 1) - #expect(site.sharedPricesEndMs == written.slots.map { $0.startMs + $0.durationMs }.max()) + #expect(await site.sharedPricesEndMs() == written.slots.map { $0.startMs + $0.durationMs }.max()) + + // The same prices again are neither written nor reloaded. + let again = Task { await site.refreshSharedPrices() } + await run(2_000) + #expect(await again.value == written) + #expect(wakes.count == 1) + } + + /// A sign-out that lands while a write is on its way wins: the home is + /// not put back for the widgets. + @Test func aSignOutBeatsAWriteOnItsWay() async throws { + let wakes = Counter() + let site = site(wakes: wakes) + let prices = Prices(zone: "SE3", currency: "SEK", slots: [PriceSlot(startMs: 0, durationMs: 3_600_000, spotMinor: 40, totalMinor: 60)], stale: false) + let write = try #require(site.sharePrices(prices, fromMs: 0)) + site.destroy() + shared.removeAll() + await write.value + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(wakes.count == 0) } /// No stream, no write: the widgets keep what they had, and the task @@ -55,6 +75,6 @@ import Testing #expect(await task.value == nil) #expect(shared.siteIDs(PriceEntry.self).isEmpty) #expect(wakes.count == 0) - #expect(site.sharedPricesEndMs == nil) + #expect(await site.sharedPricesEndMs() == nil) } } diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift index f97cad9..f2c8d91 100644 --- a/appleApp/FTWWidgets/PriceViews.swift +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -27,7 +27,7 @@ struct PriceWidgetView: View { switch family { case .systemMedium: MediumPrice(moment: moment) case .accessoryRectangular: RectangularPrice(moment: moment) - case .accessoryInline: Text(moment.inlineText) + case .accessoryInline: Label(moment.inlineText, systemImage: Header.symbol) default: SmallPrice(moment: moment) } } @@ -40,7 +40,7 @@ struct SmallPrice: View { var body: some View { VStack(alignment: .leading, spacing: 2) { if let price = moment.priceText { - Text(PriceGlance.priceNow).font(.caption).foregroundStyle(.secondary) + Header(text: PriceGlance.priceNow) PriceFigure(price: price, size: 38) Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.8) @@ -64,7 +64,7 @@ struct MediumPrice: View { if let price = moment.priceText { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 2) { - Text(PriceGlance.priceNow).font(.caption).foregroundStyle(.secondary) + Header(text: PriceGlance.priceNow) PriceFigure(price: price, size: 34) Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(2).minimumScaleFactor(0.8) @@ -91,7 +91,8 @@ struct RectangularPrice: View { var body: some View { VStack(alignment: .leading, spacing: 0) { if let price = moment.priceText { - Text("\(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + Text("\(Image(systemName: Header.symbol)) \(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + .contentTransition(.numericText()) .widgetAccentable() if let cheapest = moment.cheapestText { Text(cheapest).font(.caption).lineLimit(1).minimumScaleFactor(0.75) @@ -108,6 +109,23 @@ struct RectangularPrice: View { // MARK: Parts +/// "⚡ Pris nu": the symbol takes the accent colour, the words stay quiet. +struct Header: View { + static let symbol = "bolt.fill" + let text: String + + var body: some View { + HStack(spacing: 3) { + Image(systemName: Self.symbol) + .imageScale(.small) + .foregroundStyle(Tone.accent) + .widgetAccentable() + Text(text).foregroundStyle(.secondary) + } + .font(.caption) + } +} + struct PriceFigure: View { let price: String let size: CGFloat @@ -132,6 +150,7 @@ struct Cheapest: View { .foregroundStyle(Tone.cheap) .lineLimit(1) .minimumScaleFactor(0.7) + .widgetAccentable() } } } @@ -152,7 +171,7 @@ struct Empty: View { let moment: PriceGlance.Moment var body: some View { - Text(PriceGlance.title).font(.caption).foregroundStyle(.secondary) + Header(text: PriceGlance.title) Spacer(minLength: 4) Text(moment.emptyText ?? "") .font(.footnote.weight(.medium)) @@ -165,8 +184,12 @@ struct Empty: View { /// The next twelve hours as bars from zero, at the resolution the box sent: /// green below the average ahead, red above, the current slot solid. +/// +/// Tinted and clear home screens and StandBy at night draw in one colour, so +/// there the current slot and the cheap ones stand out and the rest are faint. struct Curve: View { let moment: PriceGlance.Moment + @Environment(\.widgetRenderingMode) private var renderingMode var body: some View { Chart { @@ -201,8 +224,14 @@ struct Curve: View { yStart: .value("Zero", 0.0), yEnd: .value("Price", price) ) - .foregroundStyle(Tone.of(bar.tone)) - .opacity(bar.current ? 1 : 0.55) + .foregroundStyle(renderingMode == .fullColor ? AnyShapeStyle(Tone.of(bar.tone)) : AnyShapeStyle(.primary)) + .opacity(opacity(bar)) + } + + private func opacity(_ bar: PriceStrip.Bar) -> Double { + if bar.current { return 1 } + guard renderingMode != .fullColor else { return 0.55 } + return bar.tone == .cheap || bar.tone == .negative ? 0.8 : 0.3 } private var start: Double { moment.curve.first?.startMs ?? moment.atMs } @@ -238,6 +267,7 @@ enum Tone { static let dear = Pair(0xCC243D, 0xFF6E74) static let negative = Pair(0xD48500, 0xFFB000) static let flat = Pair(0x686862, 0x858585) + static let accent = Pair(0xDA7F00, 0xFFAC41) static func of(_ tone: PriceStrip.Tone) -> Pair { switch tone { From 56e48974b89dcbb964b0083f3c8b8ebbedc1a30d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:33:02 +0200 Subject: [PATCH 11/25] Archive the iPhone app and send it to TestFlight from CI A tag ios-v1.2.3, or a manual run, archives the app with Xcode 27 on GitHub's xcode-27 image, signs it with Sourceful's team through an App Store Connect API key and uploads it with method app-store-connect and destination upload. The version comes from the tag, the build number from the run. Without the ASC_* secrets, and on every pull request, it archives unsigned and says the upload was skipped. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ios-release.yml | 170 ++++++++++++++++++++++++++++++ appleApp/ExportOptions.plist | 24 +++++ 2 files changed, 194 insertions(+) create mode 100644 .github/workflows/ios-release.yml create mode 100644 appleApp/ExportOptions.plist diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml new file mode 100644 index 0000000..9b692d3 --- /dev/null +++ b/.github/workflows/ios-release.yml @@ -0,0 +1,170 @@ +name: iOS release + +# Archives the iPhone app and sends it to App Store Connect, where it lands +# in TestFlight. A tag `ios-v1.2.3` ships version 1.2.3; a manual run ships +# the version in appleApp/FTW.xcconfig. The build number is this workflow's +# run number. appleApp/RELEASING.md has the steps around it. +# +# Without the three ASC_* secrets it builds an unsigned archive instead and +# says that the upload was skipped. A pull request that changes this file or +# the export options always takes that path, secrets or not. + +on: + workflow_dispatch: + push: + tags: ['ios-v*'] + pull_request: + paths: ['.github/workflows/ios-release.yml', 'appleApp/ExportOptions.plist'] + +permissions: + contents: read + +concurrency: + group: ios-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Archive the iPhone app for App Store Connect + # GitHub's image with Xcode 27 (public preview in October 2026). The + # macos-26 image stops at Xcode 26.6. + runs-on: xcode-27 + timeout-minutes: 60 + env: + # The release, by path. Picking the newest Xcode on this image would + # pick a beta. + DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer + steps: + - uses: actions/checkout@v4 + + - name: Show Xcode and the iOS SDK + run: | + xcodebuild -version + xcodebuild -version -sdk iphoneos SDKVersion + + - name: Test FTWKit, FTWShared and FTWDemo + working-directory: appleApp/FTWKit + run: swift test + + - name: Choose the version and build number + id: version + run: | + if [[ $GITHUB_REF_TYPE == tag && $GITHUB_REF_NAME == ios-v* ]]; then + version=${GITHUB_REF_NAME#ios-v} + else + version=$(sed -n 's/^MARKETING_VERSION *= *//p' appleApp/FTW.xcconfig) + fi + # What App Store Connect accepts: one to three whole numbers. + if ! [[ $version =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then + echo "::error::'$version' is not a version App Store Connect accepts. Tag as ios-v1.2.3." + exit 1 + fi + # A re-run keeps the run number, and App Store Connect refuses a + # build number twice. + build=$GITHUB_RUN_NUMBER + if (( GITHUB_RUN_ATTEMPT > 1 )); then build=$build.$GITHUB_RUN_ATTEMPT; fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "build=$build" >> "$GITHUB_OUTPUT" + echo "Version $version, build $build" + + # Secrets cannot appear in an `if`, so this step turns them into a + # yes or no. It prints nothing about them. + - name: Check for the App Store Connect key + id: key + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + if [[ $GITHUB_EVENT_NAME != pull_request && -n $ASC_KEY_ID && -n $ASC_ISSUER_ID && -n $ASC_KEY_P8 ]]; then + key=$RUNNER_TEMP/AuthKey.p8 + (umask 077; printf '%s\n' "$ASC_KEY_P8" > "$key") + echo "path=$key" >> "$GITHUB_OUTPUT" + echo "upload=true" >> "$GITHUB_OUTPUT" + else + echo "upload=false" >> "$GITHUB_OUTPUT" + fi + + # Xcode signs with Sourceful's team (FTW.xcconfig) and makes what it + # needs through the key: a development certificate on this runner for + # the archive, then the App Store profiles and a cloud-managed + # distribution certificate for the export. + - name: Archive, signed + if: steps.key.outputs.upload == 'true' + working-directory: appleApp + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: >- + xcodebuild archive -quiet -project FTW.xcodeproj -scheme FTW + -configuration Release -destination 'generic/platform=iOS' + -archivePath "$RUNNER_TEMP/FTW.xcarchive" + -allowProvisioningUpdates + -authenticationKeyPath "${{ steps.key.outputs.path }}" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + MARKETING_VERSION=${{ steps.version.outputs.version }} + CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }} + FTW_SOURCE_REVISION=$GITHUB_SHA + + - name: Archive, unsigned (dry run) + if: steps.key.outputs.upload != 'true' + working-directory: appleApp + run: >- + xcodebuild archive -quiet -project FTW.xcodeproj -scheme FTW + -configuration Release -destination 'generic/platform=iOS' + -archivePath "$RUNNER_TEMP/FTW.xcarchive" + CODE_SIGNING_ALLOWED=NO + MARKETING_VERSION=${{ steps.version.outputs.version }} + CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }} + FTW_SOURCE_REVISION=$GITHUB_SHA + + # App Store Connect refuses a widget whose version differs from its + # app's, so check both before sending anything. + - name: Check the version in the app and the widget + run: | + app=$RUNNER_TEMP/FTW.xcarchive/Products/Applications/FTW.app + for plist in "$app/Info.plist" "$app"/PlugIns/*.appex/Info.plist; do + got=$(plutil -extract CFBundleShortVersionString raw "$plist")/$(plutil -extract CFBundleVersion raw "$plist") + echo "$got ${plist#$app/} $(plutil -extract DTSDKName raw "$plist")" + [[ $got == ${{ steps.version.outputs.version }}/${{ steps.version.outputs.build }} ]] || { echo "::error::$plist has $got"; exit 1; } + done + + - name: Upload to App Store Connect + if: steps.key.outputs.upload == 'true' + working-directory: appleApp + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: >- + xcodebuild -exportArchive + -archivePath "$RUNNER_TEMP/FTW.xcarchive" + -exportOptionsPlist ExportOptions.plist + -exportPath "$RUNNER_TEMP/export" + -allowProvisioningUpdates + -authenticationKeyPath "${{ steps.key.outputs.path }}" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + + - name: Report + run: | + v="${{ steps.version.outputs.version }} (${{ steps.version.outputs.build }})" + if [[ "${{ steps.key.outputs.upload }}" == true ]]; then + echo "FTW $v went to App Store Connect. It shows in TestFlight once Apple has processed it." >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning title=Upload skipped::Dry run: FTW $v was archived unsigned and not sent to App Store Connect. A pull request never uploads; otherwise set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 (appleApp/RELEASING.md)." + echo "**Upload skipped.** Dry run: FTW $v was archived unsigned and not sent to App Store Connect." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Remove the key + if: always() + run: rm -f "$RUNNER_TEMP/AuthKey.p8" + + # Xcode's own account of the export, for when App Store Connect says no. + - name: Keep the export logs + if: failure() && steps.key.outputs.upload == 'true' + uses: actions/upload-artifact@v4 + with: + name: export-logs + path: ${{ runner.temp }}/export + if-no-files-found: ignore diff --git a/appleApp/ExportOptions.plist b/appleApp/ExportOptions.plist new file mode 100644 index 0000000..62896d2 --- /dev/null +++ b/appleApp/ExportOptions.plist @@ -0,0 +1,24 @@ + + + + + + method + app-store-connect + destination + upload + teamID + L66M368PZ6 + signingStyle + automatic + manageAppVersionAndBuildNumber + + uploadSymbols + + + From 9e7db2eb993b0e6d2fb8adcff6da8d9cceab65e8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:41:10 +0200 Subject: [PATCH 12/25] Show Source & licenses, pinned to the build's commit, and remove this phone The app store permission in LICENSE asks that the license and an offer of the exact version's source be reachable in the app. A Copy Files phase puts LICENSE, NOTICE and LICENSING.md from the repository root into the bundle, and a script phase records the commit: FTW_SOURCE_REVISION from CI, else git, with a tree that has changes marked as a local build. Source & licenses shows version, build and commit, links to the tree and the license at that commit, the three texts joined for a phone's width, and that there is no third-party code. A test fails when the package or the project gains a dependency. Remove this phone clears the key, the pairing, the sealed readings and the widgets' records (AppModel.leave) and says how to remove the phone on the box. The registry has no op for a phone to remove itself. Both are reached from the foot of Now and of pairing. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW.xcconfig | 6 + appleApp/FTW.xcodeproj/project.pbxproj | 56 ++++++ appleApp/FTW/About/AboutLink.swift | 55 +++++ appleApp/FTW/About/AboutView.swift | 150 ++++++++++++++ appleApp/FTW/About/LicenseView.swift | 65 ++++++ appleApp/FTW/About/RemovePhoneView.swift | 66 ++++++ appleApp/FTW/UI/NowView.swift | 1 + appleApp/FTW/UI/PairView.swift | 1 + .../Sources/FTWKit/Identity/AboutWords.swift | 87 ++++++++ .../Sources/FTWKit/Identity/BuildSource.swift | 93 +++++++++ .../Sources/FTWKit/Identity/LicenseText.swift | 143 +++++++++++++ .../Sources/FTWKit/Identity/Origin.swift | 5 +- .../FTWKit/Identity/PhoneRemoval.swift | 27 +++ .../FTWKit/Tests/FTWKitTests/AboutTests.swift | 189 ++++++++++++++++++ 14 files changed, 942 insertions(+), 2 deletions(-) create mode 100644 appleApp/FTW/About/AboutLink.swift create mode 100644 appleApp/FTW/About/AboutView.swift create mode 100644 appleApp/FTW/About/LicenseView.swift create mode 100644 appleApp/FTW/About/RemovePhoneView.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift diff --git a/appleApp/FTW.xcconfig b/appleApp/FTW.xcconfig index 8a90e57..e4a5a20 100644 --- a/appleApp/FTW.xcconfig +++ b/appleApp/FTW.xcconfig @@ -22,3 +22,9 @@ CODE_SIGN_STYLE = Automatic // anyone without one. The entitlements, and with them the App Group, still // travel with the app. CODE_SIGN_IDENTITY[sdk=iphonesimulator*] = - + +// The commit Source & licenses links to, which the license asks the app to +// offer. CI passes the full commit (xcodebuild … FTW_SOURCE_REVISION=$GITHUB_SHA). +// Left empty, the "Record the source revision" phase asks git, and a tree +// with changes is a local build that links to the repository instead. +FTW_SOURCE_REVISION = diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj index 9a2ab24..f3b1b06 100644 --- a/appleApp/FTW.xcodeproj/project.pbxproj +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ F7A000000000000000000014 /* FTWKit in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000016 /* FTWKit */; }; F7A000000000000000000030 /* FTWDemo in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000031 /* FTWDemo */; }; F7A000000000000000000032 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000033 /* FTWShared */; }; + F7A000000000000000000064 /* LICENSE in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000060 /* LICENSE */; }; + F7A000000000000000000065 /* NOTICE in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000061 /* NOTICE */; }; + F7A000000000000000000066 /* LICENSING.md in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000062 /* LICENSING.md */; }; F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000040 /* FTWWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -35,6 +38,19 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000067 /* Copy the license texts */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Licenses; + dstSubfolderSpec = 7; + files = ( + F7A000000000000000000064 /* LICENSE in Copy the license texts */, + F7A000000000000000000065 /* NOTICE in Copy the license texts */, + F7A000000000000000000066 /* LICENSING.md in Copy the license texts */, + ); + name = "Copy the license texts"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -44,6 +60,9 @@ F7A000000000000000000008 /* FTW.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FTW.xcconfig; sourceTree = ""; }; F7A000000000000000000009 /* FTW-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTW-Info.plist"; sourceTree = ""; }; F7A00000000000000000000A /* FTWWidgets-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWWidgets-Info.plist"; sourceTree = ""; }; + F7A000000000000000000060 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = SOURCE_ROOT; }; + F7A000000000000000000061 /* NOTICE */ = {isa = PBXFileReference; lastKnownFileType = text; name = NOTICE; path = ../NOTICE; sourceTree = SOURCE_ROOT; }; + F7A000000000000000000062 /* LICENSING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = LICENSING.md; path = ../LICENSING.md; sourceTree = SOURCE_ROOT; }; F7A000000000000000000040 /* FTWWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -91,10 +110,21 @@ F7A000000000000000000009 /* FTW-Info.plist */, F7A000000000000000000007 /* FTWWidgets.entitlements */, F7A00000000000000000000A /* FTWWidgets-Info.plist */, + F7A000000000000000000063 /* License texts */, F7A000000000000000000004 /* Products */, ); sourceTree = ""; }; + F7A000000000000000000063 /* License texts */ = { + isa = PBXGroup; + children = ( + F7A000000000000000000060 /* LICENSE */, + F7A000000000000000000061 /* NOTICE */, + F7A000000000000000000062 /* LICENSING.md */, + ); + name = "License texts"; + sourceTree = ""; + }; F7A000000000000000000004 /* Products */ = { isa = PBXGroup; children = ( @@ -114,6 +144,8 @@ F7A000000000000000000011 /* Sources */, F7A000000000000000000012 /* Frameworks */, F7A000000000000000000013 /* Resources */, + F7A000000000000000000067 /* Copy the license texts */, + F7A000000000000000000068 /* Record the source revision */, F7A000000000000000000036 /* Embed Foundation Extensions */, ); buildRules = ( @@ -215,6 +247,30 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + F7A000000000000000000068 /* Record the source revision */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Record the source revision"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Licenses/REVISION", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# The commit Source & licenses links to. CI passes FTW_SOURCE_REVISION\n# (GITHUB_SHA). Otherwise git says. A tree with changes, or no git at all,\n# is a local build, and the screen says so.\nset -eu\nout=\"$SCRIPT_OUTPUT_FILE_0\"\nmkdir -p \"$(dirname \"$out\")\"\nrev=\"${FTW_SOURCE_REVISION:-}\"\nif [ -n \"$rev\" ]; then\n if [ ${#rev} -ne 40 ] || [ -n \"$(printf '%s' \"$rev\" | tr -d '0-9a-f')\" ]; then\n echo \"error: FTW_SOURCE_REVISION must be a full 40-character commit, not '$rev'\"\n exit 1\n fi\nelif head=$(git -C \"$SRCROOT\" rev-parse --verify HEAD 2>/dev/null) &&\n changes=$(git -C \"$SRCROOT\" --no-optional-locks status --porcelain 2>/dev/null); then\n rev=\"$head\"\n if [ -n \"$changes\" ]; then rev=\"$head-dirty\"; fi\nfi\nprintf '%s' \"$rev\" > \"$out\"\necho \"Source revision: ${rev:-unknown}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ F7A000000000000000000011 /* Sources */ = { isa = PBXSourcesBuildPhase; diff --git a/appleApp/FTW/About/AboutLink.swift b/appleApp/FTW/About/AboutLink.swift new file mode 100644 index 0000000..8c03aa7 --- /dev/null +++ b/appleApp/FTW/About/AboutLink.swift @@ -0,0 +1,55 @@ +import FTWKit +import SwiftUI + +/// The way to Source & licenses and to removing this phone, at the foot of +/// Now and of pairing. The license asks that its text and the source offer +/// be reachable from inside the app, and App Review asks the same of +/// removing what the app set up. +struct AboutLink: View { + let app: AppModel + @State private var open: AboutView.Start? + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + let stack = typeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 8)) : AnyLayout(HStackLayout(spacing: 20)) + VStack(spacing: 6) { + stack { + Button(AboutWords.text("Source & licenses")) { open = .about } + if AboutView.canRemove(app) { + Button(AboutWords.text("Remove this phone")) { open = .remove } + } + } + .font(.footnote.weight(.medium)) + .foregroundStyle(Theme.accent) + .buttonStyle(.plain) + Text(verbatim: "FTW \(BuildSource.bundled.versionText)") + .font(.caption) + .foregroundStyle(Theme.fgMuted) + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 28) + .padding(.bottom, 8) + .sheet(item: $open) { start in + AboutView(app: app, start: start) + } + #if DEBUG + // For screenshots: `-FTWAbout about`, `remove` or a file name such + // as `LICENSE` opens Source & licenses there. + .task { + if let name = UserDefaults.standard.string(forKey: "FTWAbout") { open = AboutView.Start(name) } + } + #endif + } +} + +extension BuildSource { + /// This build, as its bundle records it. The "Record the source revision" + /// phase writes the commit; CI sets FTW_SOURCE_REVISION to GITHUB_SHA. + static let bundled = BuildSource( + version: AppInfo.version, + build: AppInfo.buildNumber, + recorded: Bundle.main.url(forResource: "REVISION", withExtension: nil, subdirectory: "Licenses") + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } + ) +} diff --git a/appleApp/FTW/About/AboutView.swift b/appleApp/FTW/About/AboutView.swift new file mode 100644 index 0000000..fb167e1 --- /dev/null +++ b/appleApp/FTW/About/AboutView.swift @@ -0,0 +1,150 @@ +import FTWKit +import SwiftUI + +/// Source & licenses: what this build is, where its exact source is, and +/// the license texts the build copied in from the repository root. +struct AboutView: View { + enum Start: Hashable, Identifiable { + case about, remove + case document(LicenseText.Document) + + var id: Self { self } + + init?(_ name: String) { + switch name { + case "about": self = .about + case "remove": self = .remove + default: + guard let document = LicenseText.Document(rawValue: name) else { return nil } + self = .document(document) + } + } + } + + enum Route: Hashable { + case document(LicenseText.Document) + case remove + } + + let app: AppModel + @State private var path: [Route] + @Environment(\.dismiss) private var dismiss + private let source = BuildSource.bundled + + init(app: AppModel, start: Start = .about) { + self.app = app + switch start { + case .about: _path = State(initialValue: []) + case .remove: _path = State(initialValue: [.remove]) + case .document(let document): _path = State(initialValue: [.document(document)]) + } + } + + /// A paired phone has something to remove. The demo has not, and its + /// way out is Exit demo. + static func canRemove(_ app: AppModel) -> Bool { + !app.isDemo && (app.vault.isEnrolled || app.sites.current() != nil) + } + + var body: some View { + NavigationStack(path: $path) { + List { + Section { header } + + Section { + LabeledContent(AboutWords.text("Version"), value: source.version) + LabeledContent(AboutWords.text("Build"), value: source.build) + LabeledContent(AboutWords.text("Commit")) { + Text(source.revisionText) + .font(source.isLocal ? .body : .body.monospaced()) + .textSelection(.enabled) + } + } header: { + Text(AboutWords.text("This version")) + } footer: { + if let note = source.localNote { Text(note) } + } + + Section { + Link(destination: source.sourceURL) { + Label(source.isLocal ? AboutWords.text("Source code on GitHub") : AboutWords.text("Source code of this version"), systemImage: "chevron.left.forwardslash.chevron.right") + } + Link(destination: source.licenseURL) { + Label(source.isLocal ? AboutWords.text("License on GitHub") : AboutWords.text("License at this commit"), systemImage: "doc.text") + } + } header: { + Text(AboutWords.text("Source code")) + } footer: { + Text(AboutWords.text("FTW is free software. You may run, study, change and share it under the GNU AGPL v3 with the two additional permissions in LICENSE. The link gives the complete source of this exact version, at no charge.")) + } + + Section { + ForEach(LicenseText.Document.allCases) { document in + NavigationLink(value: Route.document(document)) { + VStack(alignment: .leading, spacing: 2) { + Text(document.title) + Text(document.rawValue) + .font(.caption.monospaced()) + .foregroundStyle(Theme.fgMuted) + } + } + } + } header: { + Text(AboutWords.text("Licenses")) + } footer: { + Text(verbatim: "\(AboutWords.text("Third-party code")): \(AboutWords.text("None. FTW uses only Apple's own frameworks."))") + } + + if Self.canRemove(app) { + Section(AboutWords.text("This phone")) { + NavigationLink(value: Route.remove) { + Text(AboutWords.text("Remove this phone")) + .foregroundStyle(Theme.importing) + } + } + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(AboutWords.text("Source & licenses")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(role: .close) { dismiss() } + } + } + .navigationDestination(for: Route.self) { route in + switch route { + case .document(let document): + LicenseView(document: document, source: source) + case .remove: + RemovePhoneView(app: app) { dismiss() } + } + } + } + .tint(Theme.accent) + } + + /// The notices the AGPL asks an interactive program to show: who holds + /// the copyright, that there is no warranty, and where the license is. + private var header: some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: "bolt.fill") + .font(.title2) + .foregroundStyle(Theme.accent) + .frame(width: 52, height: 52) + .background(Color(white: 0.05), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text("FTW").font(.title2.weight(.bold)) + Text(AboutWords.text("© 2026 Sourceful Labs AB and contributors.")) + .font(.footnote) + Text(AboutWords.text("There is no warranty, to the extent the law allows.")) + .font(.footnote) + .foregroundStyle(Theme.fgDim) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } +} diff --git a/appleApp/FTW/About/LicenseView.swift b/appleApp/FTW/About/LicenseView.swift new file mode 100644 index 0000000..716eb53 --- /dev/null +++ b/appleApp/FTW/About/LicenseView.swift @@ -0,0 +1,65 @@ +import FTWKit +import SwiftUI + +/// One license file, as the build copied it into the app from the +/// repository root, with its lines joined for a phone's width. +struct LicenseView: View { + let document: LicenseText.Document + let source: BuildSource + private let raw: String? + private let blocks: [LicenseText.Block] + + init(document: LicenseText.Document, source: BuildSource) { + self.document = document + self.source = source + raw = Bundle.main.url(forResource: document.rawValue, withExtension: nil, subdirectory: "Licenses") + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } + blocks = raw.map { LicenseText.blocks($0, markdown: document.isMarkdown) } ?? [] + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + if raw == nil { + Text(AboutWords.text("This text is missing from this build.")) + .foregroundStyle(Theme.importing) + } + ForEach(blocks) { block in + row(block) + } + } + .textSelection(.enabled) + .padding(16) + .frame(maxWidth: 640, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + .navigationTitle(document.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let raw { + ToolbarItem(placement: .primaryAction) { + ShareLink(item: raw, preview: SharePreview(document.rawValue)) + } + } + } + } + + @ViewBuilder private func row(_ block: LicenseText.Block) -> some View { + switch block.kind { + case .heading(let level): + Text(block.text) + .font(level == 1 ? .title3.weight(.semibold) : .headline) + .foregroundStyle(Theme.fg) + .padding(.top, 8) + .accessibilityAddTraits(.isHeader) + case .rule: + Divider().overlay(Theme.line) + case .paragraph: + Text(document.isMarkdown ? LicenseText.inline(block.text, base: source.documentBaseURL) : AttributedString(block.text)) + .font(.callout) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/appleApp/FTW/About/RemovePhoneView.swift b/appleApp/FTW/About/RemovePhoneView.swift new file mode 100644 index 0000000..eb6a7cc --- /dev/null +++ b/appleApp/FTW/About/RemovePhoneView.swift @@ -0,0 +1,66 @@ +import FTWKit +import SwiftUI + +/// Removing this phone: what the app deletes here, and how the owner takes +/// the phone off the box, since no op lets a phone do that itself. +struct RemovePhoneView: View { + let app: AppModel + /// Closes Source & licenses, which has nothing left to show after. + let close: () -> Void + @State private var confirming = false + + var body: some View { + List { + Section { + Text(AboutWords.text("This deletes what FTW keeps on this phone: the key to your home, the pairing, the saved readings and what the widgets show. Your box and everything on it stay as they are.")) + Text(AboutWords.text("FTW has no account with Sourceful. Your box keeps a row for this phone until you remove it there.")) + .foregroundStyle(Theme.fgDim) + } + .font(.callout) + + if let id = app.vault.deviceIDOnBox { + Section { + LabeledContent(AboutWords.text("Your box calls this phone")) { + Text(PhoneRemoval.nameOnBox(id)) + .font(.body.monospaced()) + .foregroundStyle(Theme.fg) + .textSelection(.enabled) + } + ForEach(Array(PhoneRemoval.boxSteps(id).enumerated()), id: \.offset) { index, step in + Label { + Text(step) + } icon: { + Image(systemName: "\(index + 1).circle") + .foregroundStyle(Theme.accent) + } + } + } header: { + Text(AboutWords.text("On your box")) + } footer: { + Text(AboutWords.text("Once it is gone there, the box no longer lets this phone in. Do it before or after the step below.")) + } + } + + Section { + Button(AboutWords.text("Remove from this phone"), role: .destructive) { confirming = true } + } header: { + Text(AboutWords.text("On this phone")) + } footer: { + Text(AboutWords.text("If you made a passkey for FTW, it stays in the Passwords app, where you can delete it. Keep it if another device of yours still uses FTW.")) + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(AboutWords.text("Remove this phone")) + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog(AboutWords.text("Remove this phone?"), isPresented: $confirming, titleVisibility: .visible) { + Button(AboutWords.text("Remove"), role: .destructive) { + close() + app.leave() + } + Button(AboutWords.text("Cancel"), role: .cancel) {} + } message: { + Text(AboutWords.text("FTW forgets your home on this phone. To come back, scan a new pairing QR from your box.")) + } + } +} diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 78c2797..30148b7 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -42,6 +42,7 @@ struct NowView: View { home.prices.deactivate() home.savings.deactivate() } + AboutLink(app: app) } /// Paired, and not one reading has ever arrived. A hollow house would diff --git a/appleApp/FTW/UI/PairView.swift b/appleApp/FTW/UI/PairView.swift index 8942377..b1e1c67 100644 --- a/appleApp/FTW/UI/PairView.swift +++ b/appleApp/FTW/UI/PairView.swift @@ -22,6 +22,7 @@ struct PairView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { content + AboutLink(app: app) } .padding(20) .frame(maxWidth: 520, alignment: .leading) diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift new file mode 100644 index 0000000..1cdc90a --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift @@ -0,0 +1,87 @@ +import Foundation + +/// The words of Source & licenses and of removing this phone, in English or +/// Swedish as the phone prefers. Keyed by the English, so a missing entry +/// shows English rather than nothing; a test reads the views and fails on +/// any sentence without a Swedish entry. +public enum AboutWords { + public enum Language: String, Sendable { + case en, sv + + /// The phone's choice among the two the app speaks. + public static var preferred: Language { + Bundle.preferredLocalizations(from: ["en", "sv"]).first == "sv" ? .sv : .en + } + } + + @TaskLocal public static var language: Language = .preferred + + public static func text(_ en: String) -> String { + language == .sv ? (sv[en] ?? en) : en + } + + /// `%@` in order, replaced with ready text. + public static func format(_ en: String, _ args: String...) -> String { + var out = text(en) + for arg in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: arg) + } + return out + } + + static let sv: [String: String] = [ + // Source & licenses + "Source & licenses": "Källkod och licenser", + "Done": "Klar", + "This version": "Den här versionen", + "Version": "Version", + "Build": "Bygge", + "Commit": "Commit", + "Local build": "Lokalt bygge", + "Source code": "Källkod", + "Source code of this version": "Källkoden till den här versionen", + "Source code on GitHub": "Källkoden på GitHub", + "License at this commit": "Licensen i den här commiten", + "License on GitHub": "Licensen på GitHub", + "FTW is free software. You may run, study, change and share it under the GNU AGPL v3 with the two additional permissions in LICENSE. The link gives the complete source of this exact version, at no charge.": + "FTW är fri programvara. Du får köra, studera, ändra och dela den enligt GNU AGPL v3 med de två extra tillstånden i LICENSE. Länken ger hela källkoden till exakt den här versionen, utan kostnad.", + "Built from uncommitted changes on top of %@. The links go to the repository, which does not hold these changes.": + "Byggd från ändringar som inte är incheckade, ovanpå %@. Länkarna går till repot, som inte har de här ändringarna.", + "Built without a known commit. The links go to the repository, which may not hold this exact code.": + "Byggd utan känd commit. Länkarna går till repot, som kanske inte har exakt den här koden.", + "Licenses": "Licenser", + "License": "Licens", + "Notices": "Notiser", + "Licensing in plain words": "Licensen i klartext", + "Third-party code": "Tredjepartskod", + "None. FTW uses only Apple's own frameworks.": "Ingen. FTW använder bara Apples egna ramverk.", + "© 2026 Sourceful Labs AB and contributors.": "© 2026 Sourceful Labs AB och bidragsgivare.", + "There is no warranty, to the extent the law allows.": "Det finns ingen garanti, så långt lagen tillåter.", + "This text is missing from this build.": "Texten saknas i det här bygget.", + + // Removing this phone + "This phone": "Den här telefonen", + "Remove this phone": "Ta bort den här telefonen", + "This deletes what FTW keeps on this phone: the key to your home, the pairing, the saved readings and what the widgets show. Your box and everything on it stay as they are.": + "Det här raderar det FTW sparar på telefonen: nyckeln till ditt hem, parkopplingen, sparade mätvärden och det widgetarna visar. Boxen och allt på den blir kvar som det är.", + "FTW has no account with Sourceful. Your box keeps a row for this phone until you remove it there.": + "FTW har inget konto hos Sourceful. Boxen har kvar en rad för telefonen tills du tar bort den där.", + "On your box": "På boxen", + "Your box calls this phone": "Boxen kallar telefonen", + "Open your box's local dashboard while on your home network.": "Öppna boxens lokala sida medan du är på hemnätverket.", + "Go to Settings → FTW app.": "Gå till Settings → FTW app.", + "Choose Remove next to %@.": "Välj Remove bredvid %@.", + "Once it is gone there, the box no longer lets this phone in. Do it before or after the step below.": + "När den är borta där släpper boxen inte längre in telefonen. Gör det före eller efter steget nedan.", + "On this phone": "På telefonen", + "Remove from this phone": "Ta bort från telefonen", + "If you made a passkey for FTW, it stays in the Passwords app, where you can delete it. Keep it if another device of yours still uses FTW.": + "Om du skapade en lösennyckel för FTW finns den kvar i appen Lösenord, där du kan radera den. Behåll den om en annan av dina enheter fortfarande använder FTW.", + "Remove this phone?": "Ta bort den här telefonen?", + "FTW forgets your home on this phone. To come back, scan a new pairing QR from your box.": + "FTW glömmer ditt hem på telefonen. För att komma tillbaka skannar du en ny QR-kod för parkoppling från boxen.", + "Remove": "Ta bort", + "Cancel": "Avbryt", + ] +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift new file mode 100644 index 0000000..d50adcf --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift @@ -0,0 +1,93 @@ +import Foundation + +/// What this build was made from, and where its source is. +/// +/// The AGPL and the app store permission in LICENSE ask that the license and +/// an offer of the exact version's source be reachable in the app. The build +/// writes its commit into the bundle (the "Record the source revision" phase +/// in the Xcode project), and this turns that record into the links and the +/// words Source & licenses shows. +public struct BuildSource: Equatable, Sendable { + public enum Revision: Equatable, Sendable { + /// A clean tree at a known commit. The links pin it. + case commit(String) + /// Uncommitted changes on top of `base`, or no commit known at all. + /// The links go to the repository, which may not hold this code. + case local(base: String?) + } + + public let version: String + public let build: String + public let revision: Revision + + /// What the build phase wrote: 40 hex digits for a clean tree, the same + /// with `-dirty` for uncommitted changes, and nothing when no commit was + /// known. Anything else is a local build too. + public init(version: String, build: String, recorded: String?) { + self.version = version + self.build = build + let text = (recorded ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if Self.isCommit(text) { + revision = .commit(text) + } else if text.hasSuffix(Self.dirtySuffix), Self.isCommit(String(text.dropLast(Self.dirtySuffix.count))) { + revision = .local(base: String(text.dropLast(Self.dirtySuffix.count))) + } else { + revision = .local(base: nil) + } + } + + static let dirtySuffix = "-dirty" + + static func isCommit(_ text: String) -> Bool { + text.count == 40 && text.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } + + public var commit: String? { + if case .commit(let sha) = revision { return sha } + return nil + } + + public var isLocal: Bool { commit == nil } + + /// Seven characters, as GitHub shows a commit. + public static func short(_ sha: String) -> String { String(sha.prefix(7)) } + + /// The tree at this commit, whose Code menu offers the archive. A local + /// build gets the repository. + public var sourceURL: URL { + guard let commit else { return Origin.sourceURL } + return Origin.sourceURL.appending(path: "tree/\(commit)") + } + + /// The license as it stood at this commit, not on the moving branch. + public var licenseURL: URL { + Origin.sourceURL.appending(path: "blob/\(commit ?? "main")/LICENSE") + } + + /// Where a relative link in LICENSING.md points, at the same revision. + public var documentBaseURL: URL { + Origin.sourceURL.appending(path: "blob/\(commit ?? "main")/") + } + + /// "0.1.0 (1)", as Settings shows an app's version. + public var versionText: String { "\(version) (\(build))" } + + /// The commit as the screen names it. + public var revisionText: String { + switch revision { + case .commit(let sha): return Self.short(sha) + case .local: return AboutWords.text("Local build") + } + } + + /// Why the links do not pin this build, or nil when they do. + public var localNote: String? { + switch revision { + case .commit: return nil + case .local(let base?): + return AboutWords.format("Built from uncommitted changes on top of %@. The links go to the repository, which does not hold these changes.", Self.short(base)) + case .local(nil): + return AboutWords.text("Built without a known commit. The links go to the repository, which may not hold this exact code.") + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift new file mode 100644 index 0000000..d06bf7e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift @@ -0,0 +1,143 @@ +import Foundation + +/// The license files as a phone reads them. +/// +/// The files are wrapped at about 72 columns for a terminal. Shown as they +/// are, every line breaks twice on a phone: once where the file breaks it and +/// once where the screen does. This joins the lines of a paragraph and keeps +/// every word in order; it changes line breaks, never text. +public enum LicenseText { + /// The three files the build copies into the app from the repository + /// root. Nothing here is a copy that could drift from the original. + public enum Document: String, CaseIterable, Sendable, Identifiable { + case license = "LICENSE" + case notice = "NOTICE" + case licensing = "LICENSING.md" + + public var id: String { rawValue } + + public var isMarkdown: Bool { self == .licensing } + + public var title: String { + switch self { + case .license: return AboutWords.text("License") + case .notice: return AboutWords.text("Notices") + case .licensing: return AboutWords.text("Licensing in plain words") + } + } + } + + public struct Block: Equatable, Sendable, Identifiable { + public enum Kind: Equatable, Sendable { + /// 1 is a document's title, 2 a section. + case heading(Int) + case paragraph + /// A line of dashes or equals signs between parts. + case rule + } + + public let id: Int + public let kind: Kind + /// Lines that start a list item or end short in the file keep their + /// break as "\n". Every other break is a space. + public let text: String + } + + public static func blocks(_ text: String, markdown: Bool) -> [Block] { + let lines = text.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") + // The column the file wraps at, so a line that ends well short of it + // is known to end there on purpose. + let width = min(80, lines.map(\.count).max() ?? 0) + var out: [Block] = [] + var paragraph: [String] = [] + + func add(_ kind: Block.Kind, _ text: String) { + out.append(Block(id: out.count, kind: kind, text: text)) + } + + func flush() { + guard !paragraph.isEmpty else { return } + defer { paragraph = [] } + if !markdown, paragraph.count == 1, let level = plainHeading(paragraph[0]) { + add(.heading(level), paragraph[0].trimmingCharacters(in: .whitespaces)) + return + } + add(.paragraph, join(paragraph, width: width)) + } + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + flush() + } else if isRule(trimmed) { + flush() + add(.rule, "") + } else if markdown, let (level, title) = markdownHeading(trimmed) { + flush() + add(.heading(level), title) + } else { + paragraph.append(line) + } + } + flush() + return out + } + + /// A Markdown paragraph with its links made absolute against `base`, so + /// LICENSING.md's `[LICENSE](LICENSE)` opens the file at the build's + /// commit rather than nowhere. Plain text when it does not parse. + public static func inline(_ text: String, base: URL) -> AttributedString { + guard var out = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace), baseURL: base) else { + return AttributedString(text) + } + for run in out.runs { + if let link = run.link { + out[run.range].link = link.absoluteURL + } + } + return out + } + + /// Joins a paragraph's lines. A line keeps its break when the next one + /// starts a list item, or when it ends well short of the wrap column, + /// as an address or a title over a line does. + static func join(_ lines: [String], width: Int) -> String { + var out = "" + for (i, raw) in lines.enumerated() { + let line = raw.trimmingCharacters(in: .whitespaces) + if i > 0 { + let short = lines[i - 1].count < width - 20 + out += startsItem(line) || short ? "\n" : " " + } + out += line + } + return out + } + + static func startsItem(_ line: String) -> Bool { + line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("• ") + || line.range(of: #"^(\(?[a-z0-9]{1,3}\)|[0-9]{1,2}\.) "#, options: .regularExpression) != nil + } + + static func isRule(_ line: String) -> Bool { + line.count >= 3 && Set(line).count == 1 && (line.first == "-" || line.first == "=" || line.first == "_") + } + + static func markdownHeading(_ line: String) -> (Int, String)? { + let hashes = line.prefix(while: { $0 == "#" }).count + guard (1...6).contains(hashes), line.dropFirst(hashes).first == " " else { return nil } + return (min(hashes, 2), String(line.dropFirst(hashes + 1))) + } + + /// In plain text a title is a line on its own: set in capitals, centred + /// with spaces, or a numbered section such as "0. Definitions." + static func plainHeading(_ line: String) -> Int? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.count <= 60 else { return nil } + let letters = trimmed.filter(\.isLetter) + if !letters.isEmpty, letters == letters.uppercased(), letters.count >= 4 { return 1 } + if line.prefix(while: { $0 == " " }).count >= 8 { return 1 } + if trimmed.range(of: #"^[0-9]{1,2}\. [A-Z][^.]*\.$"#, options: .regularExpression) != nil { return 2 } + return nil + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift index 8afd8bc..83df598 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift @@ -15,7 +15,8 @@ public enum Origin { /// The relay. One value, no setting. public static let relayURL = URL(string: "wss://relay.ftw.energy")! - /// Source and licence, as the AGPL asks a network client to offer them. + /// The repository. A fork changes this line, as LICENSING.md asks: the + /// links in Source & licenses must lead to the fork's own source. + /// `BuildSource` pins them to the build's commit. public static let sourceURL = URL(string: "https://github.com/srcfl/ftw-app")! - public static let licenseURL = URL(string: "https://github.com/srcfl/ftw-app/blob/main/LICENSE")! } diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift new file mode 100644 index 0000000..de7b472 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Removing this phone, as far as the phone can reach. +/// +/// The registry has no op that lets a phone take itself off its box, so the +/// app does what is in its own hands, `AppModel.leave`: the key, the pairing, +/// the sealed readings and the widgets' records go. It then says how the +/// owner removes the phone on the box. That is also the one way that always +/// works: over a session the box refuses to remove its last owner, and on +/// its own page it allows it. +/// +/// FTW creates no account. A pairing is a key on this phone and a row on the +/// household's own box, and this is where both are dealt with. +public enum PhoneRemoval { + /// The row on the box's Settings → FTW app page, which names each phone + /// by the first eight characters of its key (`Vault.deviceIDOnBox`). + /// The box's page is in English, so the name is too. + public static func nameOnBox(_ deviceID: String) -> String { "Phone \(deviceID)" } + + public static func boxSteps(_ deviceID: String) -> [String] { + [ + AboutWords.text("Open your box's local dashboard while on your home network."), + AboutWords.text("Go to Settings → FTW app."), + AboutWords.format("Choose Remove next to %@.", nameOnBox(deviceID)), + ] + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift new file mode 100644 index 0000000..581d177 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift @@ -0,0 +1,189 @@ +import Foundation +import Testing +@testable import FTWKit + +/// Source & licenses and removing this phone: what the license asks the app +/// to offer, and the words it uses to offer it. +@Suite struct AboutTests { + static let sha = "5a14ff0960d99fa0ca982c82fb2b7c1d5e213d25" + + // MARK: The build's source + + @Test func aCleanCommitPinsBothLinks() { + let source = BuildSource(version: "0.1.0", build: "7", recorded: Self.sha + "\n") + #expect(source.revision == .commit(Self.sha)) + #expect(source.sourceURL.absoluteString == "https://github.com/srcfl/ftw-app/tree/\(Self.sha)") + #expect(source.licenseURL.absoluteString == "https://github.com/srcfl/ftw-app/blob/\(Self.sha)/LICENSE") + #expect(source.documentBaseURL.absoluteString == "https://github.com/srcfl/ftw-app/blob/\(Self.sha)/") + #expect(source.versionText == "0.1.0 (7)") + #expect(source.revisionText == "5a14ff0") + #expect(source.localNote == nil) + } + + @Test func uncommittedChangesAreALocalBuildOnTheirBase() { + let source = BuildSource(version: "0.1.0", build: "1", recorded: Self.sha + "-dirty") + #expect(source.revision == .local(base: Self.sha)) + #expect(source.isLocal) + #expect(source.sourceURL == Origin.sourceURL) + #expect(source.licenseURL.absoluteString == "https://github.com/srcfl/ftw-app/blob/main/LICENSE") + AboutWords.$language.withValue(.en) { + #expect(source.revisionText == "Local build") + #expect(source.localNote?.contains("5a14ff0") == true) + } + AboutWords.$language.withValue(.sv) { + #expect(source.revisionText == "Lokalt bygge") + } + } + + /// Only 40 lower-case hex digits name a commit. Anything else, including + /// nothing at all, links to the repository and says so. + @Test(arguments: [nil, "", "dev", "5a14ff0", Self.sha.uppercased(), Self.sha + "0", "g" + Self.sha.dropFirst(), "-dirty"]) + func anythingElseIsALocalBuild(_ recorded: String?) { + let source = BuildSource(version: "0.1.0", build: "1", recorded: recorded) + #expect(source.revision == .local(base: nil)) + #expect(source.sourceURL == Origin.sourceURL) + #expect(source.localNote != nil) + } + + // MARK: The license files + + /// Joining lines must never change the text: every word of every file, + /// in order, and nothing else. + @Test(arguments: LicenseText.Document.allCases) + func reflowKeepsEveryWord(_ document: LicenseText.Document) throws { + let text = try RepoFiles.read(document.rawValue) + let blocks = LicenseText.blocks(text, markdown: document.isMarkdown) + let original = text.split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !LicenseText.isRule($0) } + .map { document.isMarkdown ? $0.replacingOccurrences(of: #"^#{1,6} "#, with: "", options: .regularExpression) : $0 } + .flatMap { $0.split(whereSeparator: \.isWhitespace) } + let shown = blocks.flatMap { $0.text.split(whereSeparator: \.isWhitespace) } + #expect(shown == original) + #expect(!blocks.isEmpty) + } + + @Test func theLicenseReadsAsParagraphsAndSections() throws { + let blocks = LicenseText.blocks(try RepoFiles.read("LICENSE"), markdown: false) + // The heading over the file stays two lines. + #expect(blocks.first?.text == "FTW native app\nCopyright 2026 Sourceful Labs AB and contributors.") + // A wrapped paragraph becomes one line the screen wraps itself. + let opening = try #require(blocks.first { $0.text.hasPrefix("This version of the project, as a whole") }) + #expect(!opening.text.contains("\n")) + #expect(blocks.contains { $0.kind == .heading(2) && $0.text == "0. Definitions." }) + #expect(blocks.contains { $0.kind == .heading(1) && $0.text == "TERMS AND CONDITIONS" }) + #expect(blocks.contains { $0.kind == .rule }) + // Both of the license's own permissions are there. + #expect(blocks.contains { $0.text.contains("FTW app store distribution permission") } || blocks.contains { $0.text.contains("Energyplan") }) + // A list keeps one item to a line. + let items = blocks.filter { $0.text.contains("\n- ") } + for block in items { + for line in block.text.split(separator: "\n").dropFirst() { + #expect(!line.hasPrefix(" ")) + } + } + } + + @Test func licensingMarkdownHasItsHeadings() throws { + let blocks = LicenseText.blocks(try RepoFiles.read("LICENSING.md"), markdown: true) + #expect(blocks.first == LicenseText.Block(id: 0, kind: .heading(1), text: "FTW native app licensing")) + #expect(blocks.contains { $0.kind == .heading(2) && $0.text == "Source for a distributed version" }) + #expect(!blocks.contains { $0.text.hasPrefix("#") }) + } + + @Test func relativeLinksOpenTheFileAtTheBuildsCommit() throws { + let source = BuildSource(version: "0.1.0", build: "1", recorded: Self.sha) + let text = LicenseText.inline("See [LICENSE](LICENSE) and [the vision](https://github.com/srcfl/ftw/blob/master/VISION.md).", base: source.documentBaseURL) + let links = text.runs.compactMap(\.link).map(\.absoluteString) + #expect(links == ["https://github.com/srcfl/ftw-app/blob/\(Self.sha)/LICENSE", "https://github.com/srcfl/ftw/blob/master/VISION.md"]) + #expect(String(text.characters) == "See LICENSE and the vision.") + } + + @Test func listsAndShortLinesKeepTheirBreaks() { + let text = """ + All other conditions of the GNU AGPL remain in force. In particular: + + - Offer the Corresponding Source of the exact version you convey, under the + GNU AGPL and at no charge, outside the service and free of its terms. + - Make the license text and that source offer reachable from within the + program. + """ + let blocks = LicenseText.blocks(text, markdown: false) + #expect(blocks.count == 2) + #expect(blocks[1].text == "- Offer the Corresponding Source of the exact version you convey, under the GNU AGPL and at no charge, outside the service and free of its terms.\n- Make the license text and that source offer reachable from within the program.") + } + + // MARK: Third-party code + + /// Source & licenses says FTW carries no third-party code. That stays + /// true only while neither the package nor the project pulls any in; add + /// one and this fails until the screen lists it with its license. + @Test func thereIsNoThirdPartyCode() throws { + let package = try RepoFiles.read("appleApp/FTWKit/Package.swift") + #expect(!package.contains(".package(")) + let project = try RepoFiles.read("appleApp/FTW.xcodeproj/project.pbxproj") + #expect(!project.contains("XCRemoteSwiftPackageReference")) + } + + // MARK: Removing this phone + + @Test func theBoxNamesThePhoneByItsKey() throws { + let vectors = try IdentityVectors.load() + let name = PhoneRemoval.nameOnBox(vectors.enrollment.deviceIdOnBox) + #expect(name == "Phone \(vectors.enrollment.deviceIdOnBox)") + AboutWords.$language.withValue(.en) { + #expect(PhoneRemoval.boxSteps("abcd1234").last == "Choose Remove next to Phone abcd1234.") + } + AboutWords.$language.withValue(.sv) { + #expect(PhoneRemoval.boxSteps("abcd1234").last == "Välj Remove bredvid Phone abcd1234.") + } + } + + @MainActor + @Test func theNameOnTheBoxIsTheVaultsOwn() async throws { + let store = MemoryStore() + let vault = Vault(store: store) + #expect(vault.deviceIDOnBox == nil) + _ = try vault.deviceKey(vault.localWrappingKey()) + let id = try #require(vault.deviceIDOnBox) + #expect(id.count == Vault.boxDeviceIDChars) + #expect(PhoneRemoval.nameOnBox(id).hasSuffix(id)) + } + + // MARK: Words + + /// Every sentence the About screens hand to `AboutWords` has a Swedish + /// entry. Read from the source, so a new sentence cannot slip through. + @Test func everySentenceHasSwedish() throws { + let files = [ + "appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift", + "appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift", + "appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift", + ] + (try RepoFiles.list("appleApp/FTW/About")) + let pattern = #/AboutWords\.(?:text|format)\("((?:[^"\\]|\\.)*)"/# + var seen = 0 + for file in files { + let source = try RepoFiles.read(file) + for match in source.matches(of: pattern) { + let english = String(match.1).replacingOccurrences(of: "\\\"", with: "\"") + seen += 1 + #expect(AboutWords.sv[english] != nil, "no Swedish for \"\(english)\" in \(file)") + } + } + #expect(seen > 20) + for (english, swedish) in AboutWords.sv { + #expect(!swedish.isEmpty) + #expect(english.components(separatedBy: "%@").count == swedish.components(separatedBy: "%@").count, "\(english)") + } + } +} + +extension RepoFiles { + /// The Swift files in a folder of the repository, as paths from its root. + static func list(_ folder: String) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: root.appendingPathComponent(folder).path) + .filter { $0.hasSuffix(".swift") } + .sorted() + .map { "\(folder)/\($0)" } + } +} From 352e0a4adf04f484889a134f657d6a45dc5d5731 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:41:21 +0200 Subject: [PATCH 13/25] Give the app an iOS 26 icon and a launch screen that matches its first frame The icon is an Icon Composer document made from FTW's mark in ftw-webapp/public/icons/icon.svg: the amber bolt as a glass layer on the mark's near-black. iOS draws the light, dark, tinted and clear looks from it. The 1024 image and the macOS sizes left over from #5 are gone. The launch screen fills with the app's surface colour, light and dark, so nothing changes colour between the launch screen and the first frame. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW-Info.plist | 5 ++ appleApp/FTW/AppIcon.icon/Assets/bolt.svg | 4 + appleApp/FTW/AppIcon.icon/icon.json | 45 +++++++++++ .../AppIcon.appiconset/Contents.json | 74 ------------------ .../AppIcon.appiconset/icon-ios-1024.png | Bin 11194 -> 0 bytes .../AppIcon.appiconset/icon-mac-128@1x.png | Bin 3895 -> 0 bytes .../AppIcon.appiconset/icon-mac-128@2x.png | Bin 5938 -> 0 bytes .../AppIcon.appiconset/icon-mac-16@1x.png | Bin 553 -> 0 bytes .../AppIcon.appiconset/icon-mac-16@2x.png | Bin 1080 -> 0 bytes .../AppIcon.appiconset/icon-mac-256@1x.png | Bin 5938 -> 0 bytes .../AppIcon.appiconset/icon-mac-256@2x.png | Bin 11672 -> 0 bytes .../AppIcon.appiconset/icon-mac-32@1x.png | Bin 1080 -> 0 bytes .../AppIcon.appiconset/icon-mac-32@2x.png | Bin 2044 -> 0 bytes .../AppIcon.appiconset/icon-mac-512@1x.png | Bin 11672 -> 0 bytes .../AppIcon.appiconset/icon-mac-512@2x.png | Bin 31447 -> 0 bytes .../LaunchBackground.colorset/Contents.json | 38 +++++++++ 16 files changed, 92 insertions(+), 74 deletions(-) create mode 100644 appleApp/FTW/AppIcon.icon/Assets/bolt.svg create mode 100644 appleApp/FTW/AppIcon.icon/icon.json delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png delete mode 100644 appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png create mode 100644 appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist index 03bab63..e20acc0 100644 --- a/appleApp/FTW-Info.plist +++ b/appleApp/FTW-Info.plist @@ -9,5 +9,10 @@ en sv + UILaunchScreen + + UIColorName + LaunchBackground + diff --git a/appleApp/FTW/AppIcon.icon/Assets/bolt.svg b/appleApp/FTW/AppIcon.icon/Assets/bolt.svg new file mode 100644 index 0000000..54e730e --- /dev/null +++ b/appleApp/FTW/AppIcon.icon/Assets/bolt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/appleApp/FTW/AppIcon.icon/icon.json b/appleApp/FTW/AppIcon.icon/icon.json new file mode 100644 index 0000000..d018b86 --- /dev/null +++ b/appleApp/FTW/AppIcon.icon/icon.json @@ -0,0 +1,45 @@ +{ + "fill-specializations" : [ + { + "value" : { + "solid" : "srgb:0.05098,0.05098,0.05098,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "solid" : "srgb:0.00000,0.00000,0.00000,1.00000" + } + } + ], + "groups" : [ + { + "layers" : [ + { + "glass" : true, + "image-name" : "bolt.svg", + "name" : "bolt", + "position" : { + "scale" : 1.1, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.2 + } + } + ], + "supported-platforms" : { + "squares" : "shared" + } +} diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 592f5b9..0000000 --- a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "images": [ - { - "filename": "icon-ios-1024.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" - }, - { - "filename": "icon-mac-16@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "16x16" - }, - { - "filename": "icon-mac-16@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "16x16" - }, - { - "filename": "icon-mac-32@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "32x32" - }, - { - "filename": "icon-mac-32@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "32x32" - }, - { - "filename": "icon-mac-128@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "128x128" - }, - { - "filename": "icon-mac-128@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "128x128" - }, - { - "filename": "icon-mac-256@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "256x256" - }, - { - "filename": "icon-mac-256@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "256x256" - }, - { - "filename": "icon-mac-512@1x.png", - "idiom": "mac", - "scale": "1x", - "size": "512x512" - }, - { - "filename": "icon-mac-512@2x.png", - "idiom": "mac", - "scale": "2x", - "size": "512x512" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png deleted file mode 100644 index 91a152afe650065ad54ba85c8123bc094cc7b8ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11194 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 F{{tiiD*ONd diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png deleted file mode 100644 index e548c7ee15d7cc0389792482075b88bfd10ac926..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3895 zcmV-756JL|P) zdyFL2b%(!mZ*|Y3yJy&ChFSK(4hU8luR#P%lsLl1!NGQd!Aekwl7I~)Q5-pzu}z%F zRo`P>^FOL%`=^ja6zO8_Q1*NV}%Z{I#}@ZiC5YwZ^2+=aj< z5m8l5v^R*aQB@npm}h~5g+k$_si~=2(`HBAuQmHV>u`KSbRmU8;S6KUuLD;DmjK&D z#IEso*wCmdz#G8xz*D~O|Day4zZLy=+&!+DM_5Y%SPmI}w=w2(z||r$1}hg1>+JG2 z7;#G%kx zk=VEjcuit>6!>bnTz)3n&d}w?((RkS@&${<;w{FQJAgAH!}}ZxbTW>Q65c}*5u>U; za7VdZz5{5`uR;y6kQZSw~S%{ zH#|JN$vO8k5xGoN0}l0Z8J(j*AR?ZsPKn4h3kwVXO_xN_4dXN${}B;+zp6HXTo;a= zaR@&W!JLR()h!XMRsaH+v9YoKTCMhYWPBg+R(afvV-pY>ea;wj$^88MURD_xnpJ)w z0|eD-_3oyYpBaDcu!P;gCg!sUeB zKwI``R{$13M1Dg=M#x(KM!*4Lt^HIdJGU!9dw-R{+e-&7C76qjZ>d zY;>GL-V;VjIsD!cfdi~D<`<&(E!(wi0RUrB^q;scz*?bF54hp-QFd+VXK~pfy`caF z5RtRm-LY)}7-QboeA1*~1nNrx=bqWmXFoc|D^qp)a;8@UW*obt-lBI>kP`u}FuWiB95SR+(x0e^G- z4$eD!5K{@*Gg$}IbMC(*QeC`ZjZm)oeBgp1KK+p~v=-0+CT5oKJV{oNa~x9b0RpGw zbB23v9>?uE`Gru;*MHdXQT=gMu*tdU)xmti{ z)6oT_QUJyX)y06_XBYU(>vs^;0>lUyVb5e8^)-B-0#YdeP#V7Sts8ez9P*&y2<@%B z^3Ebk&kO;tn$!uvv%-8O;D!&5aK)vY2r53-N|^Ke%AU8=lL0`=1Rw&7%Z{y^bNt=! z??f98kT{r9uKWnmZ zSlKAN_*M<|9VR6i0Hj0!)(DlF&sRRVm2=N25Y&Rhj4V+Z&?}Sm1eUm*n8XXf8lhYZ zxcHm`U$}NFuI3-r!o>)#;du3(MI=S;A5tIyRmB*%>xP}=`Yjq9KFg;X4h+>QVFu9W z8C;4KkZ1vTR+umQ+;qh#AG&OF82BC0`W3(vCTEwKDf_7k03pEw2ur*iJIDIC{kM1E z>cKIGcjF9T-+?9O>kcomcjVY4_Hspp<%Z*%H;ywl){id7!@Y;23K-$VH)}}XQZok- zlSl!?C0*T}HPU7y++&g`9FT+bM2 zEIIZaSi+=e1TZFX0FMQ zn@`v~r#;xXc#h$Y8u*KXss>vqtx1syP8dG*x_zxyZ*xvP+!r8&q*HzQe9cf+X{TN96`{7&7U3@ zz)>Y$%dVs6`9xZL4p4+3P;x^#?t6HW$9_J~rcw^SM~x3Xo(5VG>Wl8E60fzwDVRRP zf$2qV|G`@f^qZDrhK)+k3cy-nuH4|ti-x)RV_RsH8z5m?=9Ck^oOTqjsVb5;eCazA zOwKmw&zqhM{F>>>Em3G#vQaas^NjD0V2vkDoli15eCw zTG8ui*B{f90swHT^ye*K`tBPv8m%woVnry|13r53Ft`2Y_LjO0RUl`m%s2Rphu$Qg z>)pKnNP1ELs^A%6w&L^SXJ(`25?kBX^8|mf!LPih1bqfq4^B8-3}KgHOCDc)aDta7 zYHS+r?ZB^@o)iE?Kd)tk(~9j~f;Di^!KLREAUOFV5Y8#K;PLa97r6J|rYR-F^~dzA z03iA?9oL>Q0tfk=uydp@6u?Q2uO?hO=uf`Ak7Zve=1kk?+{pB77SIXt^8q78kMYqy z@b!fGzY9XwfA__Bsk+6%$2f-e{`X`87 z{|}#<;U~|{B_`)T7O4_|Iz_Ev_l`k0&N6ULLGp&_=|w*Oz`m&EC)41#rYRfm3YJiii=GF%%s4Zqo#3YV506e{DdC#`~!!m#%2m`sCubNg+QjGl$08L12!G#B*tMk}kZ@!#XQ(Xr{OJP|m(!Q@f^d>Lvty1U@2Zqu};sp?A0GkFa<7f22vLm1160deifdIlHkh917 z*m8Ov4HVzku*9q3j#@P=Z6sd+gwGJ{9O)yUGsvLjpC6s(>6aHMr7Y(^7ReVtoB?bd zz!fd8?yd3X54}ktC1d?3Bk=-I4VQx5b!I=t2%mpoAMY+Cx5TSmIuSrmb(*S*82H6= zi#+!D0e<>?qD#Ei{)Zz)WVzjaP8A}s>?_+g=gE1(AK$-^d_K`7UTxc^9RVnS@B7bn zu#{{j+Ian_6U`&nB_NE54%tiT<{1o3@_}Qq@@?Xy+uS3RURO8(w*5 zkr(z=DGu14UCELqkI^tLhYC1KZGdGk$(>rrcoJ*EL-{;uK?nAP9n| zqW3K?cGqqfV5g_2tH5*7e0rO!>a9QFu)nPQI8*{rKvZ?QR4VOh`m80|b>ndxda~UU zol2A*`J$Z>h=_{Fqfz6utf2q`0N?k&@0?p`_hcDsf+6$(|Jv!!?Vy0I*XxtUn5QM| z2R3{+kYF4j&bimiKDOtDye(irngSy!+w zIKq2>z&UqewOXAf>XJJ-*lN0fIK6JD>b0u6pH(O^qa~V+d~IZW%PKOl)sE*oz*MW% z{UUNTFh?eWwZZ`+B1TpJs9Y}p7hSm5-I&mEfL&NvcveKN0%oI_!0*DbGeY9Yx4=2~ zhm}g@KEjE-RX4P5PLEh1D3wa*0pAso_p55`nl}8*;RZ!H6!1-{>UEV$O3xKe?4o;(;4W*xKXI_o(XkoOAb9tJVD+x<6eXY+LIa->g_WG&J;K;CEE@SAnzA zum(>nL=EB%RsDAn`BAxCegs&G{(DWcs`aoyvo`=>Y;3Ghuh%b7)!ia;ma1L|Y$6@r zATHo&h{&_RjEMYap-^~fYHF(1v|XIvujvr`dfx0<2#}2v9FKSubw3Sw^JUI*-@o%a_c_n~oaay+j#wjw<%IzN$U_G$j{*RL ziZCFAfd1UVN__#4Sv_QFPKdfQI~HSjebz!a9I9Ct@KfwGa~+qs@F@gcL~}=$4B?KB9oK12({Vd z-`aBIz2P&-*iQ}B7nlvh(edlvzOm<(Y-D8QJ*s;#)j7FKgs@x!3p_d?2XcJY z@!O5DVJNyeQ7NS>3H+u`PXOgP_8LxkEmXb!aUh1E3&s;jf$w_XzODVU&Lv{Q%BfsM z@*@x^vyVIITs}EtyJ@1Lfoef)0KWmv!*VXjAdlVC+q*_lITxBe`xt`hahq>(-6vk15*iXaXPGB$%k6_KPUr^=DDmSu#^ zz$s{Lg3`r2}HOVeL;k5NuNe0nqyIfh(hW}8I`Al5sk;J$0{m5C_rD2 zMzx6;sDWF#xz8;u%`~vVQdvjzrY5%M!Rmg7$7EOUSHn6CoJOzO1JdBk@*E|;$DA@K z)GZGXj*Ca^?F*TPJ%uSu}<31UWV2 z(de1FFCCidY7twXtp!;SiN1UqAX3f9_^OKzFW2|2`75zi0)wciyxpP#gRVq?qBm*f$PZ8g9StQgrE z)>u9`V=7bx^Sm{qW=ts-Btz&Q;qFhUJWcGmuhYj8(u;G;#2N4=7D-GXcvDK_;W<7` zbOAe$`p1LP!b(gkOnF5d0AzJ+;cU3kUatpPnv)lvWr_>;F@o#97^mwtNaV+7!BaIN z19R}i;H5a;0jcn94s8IV!uk2D?D1vn`-2zpWB#qhNNOztmoa|tsekG8LCeUPf;y2g z(IST#XDfM^1v6en29n!qa@^97o_@r1Waw-iF5!l^lki=Qq}QOb&!6`K7;HJKu4ycQ zN=^sA+B?&F__Xd z&_^YCIayPVN~wtGpt5p)$Gqsw>Ms%bRx9EO_-7;6*u^a#rT!N+PA7(#zdJTyuhw}o z!DQ|Oc<1z}b^x=~jD3n0Fmt-CVPzhU7>jt}&N@3;xg)bv6c0*uzwb)dWflRE&6fMn zz`uN50UYb{((+LP2s|TtzXuP0W1z$*C(=TXNde zG%?~4r7N#;@td$^O{?YTQI6Ba8=l)b;XY`8JdpR`Q3Sht#d(D}0FjjbrEHC-0EYP_+(P&U#@W=3# zKjij<-rue{Ql_F7Tvi`UJ(Wl$FC>ok9vFPStbW3^Q7pu&VR$4>)c5s&f@DycXAraq z+~ne#aLzoN!&^A}%g`4E1@hsH#i?h6P8%5*`N4w+srRb6QB%Q(DBvEx%7kzkU#|sp zI~uptyHCrJH#XF3RQv~7HqYiDKjx~Gs}a&|cOA?|G(pQv^5nx9g6FpWQ1o-vKfSH7 zYQp(OYKiSzQ^mQ9iU!XV)A+vW?KEB3bS^h7w+5~{GVURFaxw1M?yKk(5w*te-D2^} z-}2_?_oUwP7rJI*DxuBq9y|A(8FMhYZr>w;=XP?#;&v{c(stG%U(&x+@w!k~IB5B+ z_+0QGMR4tLt--Qn(o-SeO!O<}eHSgVbhBo#t!WDpu2t*ZV#-l9;#I<9eE84f#}bYE zmXG!T{;|Qh2;e49B*!wZU(`)(=>x_)1IN6ZBVj*P=@Y%$mzErxQvOT9?e2ATcW=@Q zb=5q|lMq9Xy~1tX0&ZSO!I54IOuaE6--G@B>A^2r!jn53_jTSiYRd45n&DT4@mU_F}~Ni0b@dPKuD=^TlKIP4cuZU z-KThgcbZqxqB4A(cBCw$Si}{heU<#MMY31hD;`BZ4R_cs3XD2*`A9mgJI;sCD-52s zdZ*HYfu%9B)>G*RH(%aCCbohiZ@x!o3`EVAuck5#YzeC{^0>8NhDD1Scyt+t2Tn%3 z(fY_8Pd@jV>j>3xv3zM#8740gMJX6iLtSeAaajbV#jn%nDQjJ6DD(*sq~K(y2=43< zLZ=2r5wkdd5sAN~3;cT`_^mmcN4V2(qh3DRt2Tcno0O2y9%cvwj)J8-M_E@S=wwe{%8%_J73~0AeR7Ez>vVwekGm4&tI@`4bgO z_NV?9Zy&E%Qt3n#DPw?swL24c6^S?10sfPg`Ml3i93^ z2ppoBPm)j||M3JL=&y`;p@CpJ3FZ)5@_scVRe@|OD3g!{>FFg(7B(K}A>9!I#Mlcg zvIo;G^j{N?*6hb0d8UdzbPsiew<8_q;8-%cdWG;Z_oW4nzOf?R`jxii?o)v9?nfl| zkT7`S$+N#1+NBZ-s~pDw>2RZb9*qWzTI{r$+IfN&dxaqHF_N-xxQFES75t3%MYke~ z79rfZ`h1=t8aP$?@vzsun>U)mw*902QdJ-uc>+RPE;PU^1mlnasHKC_;#VYcz7B_w=hX9W8~(c@9-`z#n|ps_LtufN15DD_xJp>5HUgjUVBqiYd-t*g z@c}l~OZG>>xUiVA@_3g88EKc0^|J{XC8ZO%*akE-I)!4mLWNOlmuOS1GKq7ObzI;{ z^pZz3u(dt8^N#dTV7$<9JdbbB9x=t26)L?V@FMd2pPWuPwC9z{zjEbFz1xK@IE00EsxXLV^6kAJehZnG<%g^K z9gpNjJA(J9ui=CJw^(9*ftZi+0xh8H_-*E@tT{?Uu_B@3>vp@0)fB! z-mDiFOa=0Y^*NME)69=~a$%#2RC((J2s@QV_YeyCvD&{fpV!-&jZ1XI@A+gTuhxb2 zdrUMeNX=`Wmx{#I(tDc87_iTgsHfE8H&UyV*Heh#pflT>nt`Ez!s%2!w*IHp95`H6 z5KCV?ST%bGF-Llw5o|JEk=8CH_yEnA%5IO?HI(e!NP{={9LwGGcwn_o;qiJ(GAt8L zEczi-V7j|aC7xSG5M0+p;8Bd}R6p|NO*0)*V)``+t+_#rXB9s?_nv1PF7MJo;Eza+ zHLW~MPq&@h#rcH0vnEcSSSQh27Vr#Tk#oy`(XX*nh&xa~{4x{00zq?ebZLZyR>UB0*_3-81&M&xBc0<+mb;bs(|zyBCD1o$oZtz|1B|B zb5tN#+%^m!6AtNtpPWaK34~tr2cK4*A;TEo*0&LGCjx)Tu6QG05ag42mKy;-Ljfb7{SD&{ zg~>I5$TJBr2QR<=4T>N{$^UNy2>G&fguw<7vSk9{D{zawy-|ySMu%#FqcPKeJE7`z zEXRzV6I2MB{2&iy1-VzLMy)Unk@GhH#73wBD3sJLP+&a47}r)0hLRkVg%>6A({&sI z^MgE>0^#y^Xd7ulCL}C@*2<8Q4a#l(l_rUx8*PI4BM^TJA6;`NE?7R~+eMLRC}qgw z&As5Nv|p00M?q`zg9WbDW0o9BR#vgsPkVx9Z~sQ4aak3U$@F zcRT3=1<`jpE8T-bt2u7dyG~5+0Yqt#Uc)Mx8%Hpn2K84eDE@+0)qHwl+bqaDQc|sf zT`N|5zv6C7@uw-wA9H=^>8lv~EA=PG<}fUox-+G#sV%7N;2t1F{`sXY7){?p`Jy2} z71mD-*!iC~OFZUNgt#$JLXvpW-yp9nBk!>cY;~t?)nZrd&(Ooxsg(D`V&S%v=VdctX@vy(8uF8YL<4(MKsL}Hpn*r3uA+_#X1 zPLs-}AyORx$^OtJdR=%nYwGET1|S0-*Jii|to5xgl;`phi-u4%F+tKRIba}^c%^uz ztm!s%X6DO;zH}tf(btKd^@NrdkF&ybmw-6*s(W~z-I7?f3p@m*Sx)BlEfYwaizQNJ zfeMSgW}UgnrzG+XBdIL7*X(u z%9A>|i}xG|kv6GM>#3xDgwYi6HMgy3Jl-wV+NhP`y7>F1{>LQ~g{eD_3rdvx-b}wKpG(Uc^glE>rVtep=VuszzPLj7c4m5X<#ZywcQj#&=P2?Nx&2M}=jtk<+^Y0iH zWz)mhadH5R;2}eH9O$%~yw(*mio3~ljAl6xpg##Y#FZsufXYl{gLURhmo7D+1v=U5 zrr>OP0Z_(RMtEDbQJlCe3&Z8!Sj9rHk0hx z0qw@{hT)gk#il75K!AbvgO+Z@_3-9V9-=r>D?>1wz4^HqnZDGxi_(=>tPhL#)+Oeq zi~U(OuO5p1#^G@ITGKst&Ui)(l2ti#d8DRi@o2p|MeTgT5D~$R?mD)5ai;S1u$NKYK3E)@wX;A+cFd|tlm_|nx`BT_y z!OL;H$css&>+B>=3joF>`{v_#OVpU^WIYG6`js;lm-`qzzftyRI`n292eigP(D57` zC}7S?WaYa_-4{--~#g=vH?1@DlClOaY(ci!5f3R zMdOR2_5jsbTZES>rk6i)>KWF|y|^|lcvbu!S+G$$2mu}wwr-GNUnR=#bks>o=VIMv z&l8tztdu$Us=CrnU%uJM3njX~IhRLcT6$4M#lpZ4n|P*{H{3USNs)us^#i(AD;Mk6 z<9P<4LNG-2HnMWt{bD&Qmfh%yhCT8eF=3OHgj2mBTM?K)8n}Hp|L2cx*agIw6qiZd+-yW*^UVD8XIQ2@s z8M?cvkj-L)D7#--HuS^yP*=>Qa8Vd$0 zKSshhLiD)4A`;l4aHDp_^?`FOIG9rM=h5%CM)^JaK7QU|HR(@$aoExwFfLK2VYha7 z)GS6-UPlsW!0K-DP2*EqiS}KzWbO-o1k- zTwHFQIB~+?-p=kth~Bt0nvgY9FOaYeVKyKaf#AJBZ$h@VdL zQQaaNR~kRgHs`pPa>y#qoxsH=43^n@sCKI?*I)k=CHepT)3E1>&*t2D(uT5Fgzmz@ NA*&;nPb@qW{|5&3xOe~n diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png deleted file mode 100644 index dd8790bd5ec621054fb30d5ae94212afbdb868c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 553 zcmV+^0@nSBP)>*(<;UWJOB#`#8K-si!Ww!U+28CU~?D;duLp_CH?F-9Du_|D2gT(7w}Kx|gp zAedPjz!jyMul@jWWOOsn?Mw44FQx2s`qX1Xm4?FCGZr+OfOD|_H)npf#=~1DdHdlv z`EY<_2ghXN1S3cgneqDW5{-pAXHFd9!Oc?~YsCz56>T5G1c3nqFOJV8tbfXQ`DTY3 z*A}^WI^}z3Kr$&~nm|Aj89%z7`!BZm`0Wq-yM3-d-Xzy__HhD@lbDI9max)3%Dv}b z+50)9F%vPiU!xdT=vG28M$Dk^#o}C@jm?Z_@Aqh>F+SJ9-~@`9BI$EUHk2?2^oM~b z?{;x+s%{U|fvu`<4v3r0=DKt4Lgf{L5XDXeO?Tyj)Uf*^Pbnu}3E z4{2Zh`cVLhmXuqOKlvpciv&P;bzJrCPGj_d49cZfbH=;?k{zwfFMcunx?0L zr&M*{O^E+3AaY$*zx3XpZMWOk*vzc&ecviRIXSssL_QIbLjbC!%QpxDRlP1ECt9u6 zMfx(40|+2-98Wptz88@Ls+usQcK;NBA)E?$5vi!^inaFf)z#HY*|@=EP8JBxxzl~( z)r?ezH4!9rD!RRo0V=BML?rg!zuO;F4&bwBPh_dFqoU3uPo1)>Q6VxiYOPsS4ZtID z9PiC^Odg@u>-D;--m9vn4{WG-;*`6ltGw{UPC8wWk)hR+&3-bTB*}yMyPlAUC?z%w zYlO~*3VIxxVU9pqnb8BtuljIo}n}JwHo*Dnf&DZ6zTdx6~&rV&KnCU?T~bf}3|?fXE6f z&6HP;&+ypc+o72vRycOB!7uL|L~4fR8wp3>oMXMC*r?c?CU9>TwUrCH!3(^tQLg;R5$D2PL(I$`WX$-+VH%8@;;t9at9I# z-~R9i=PxW#pNa~L7X|<@0vkzSt>d$2WlvlMBea?+r#`v@^#1t@1C(0@0=0@Dz@4{6 zJg~EdR1If7o9Cx18%&N{Ldj)Nej`$$>y(*Bg?M+3OP8CxcVUsK$#SW`X#f#$Ub%nw z7}ZEP`N0(u7qCUw-3Wk@A&3AjaAf}!AAhmHk8@3?CL(;f)c2fwlTcNK0+Z4Q7#p*+ z+MaV?EizVK996C4&v^!k7cyO+2h(pB z@Dmdghpe@41BXRqN8vaJn5sHey=aU%(`vQ8X2``Px89{8j^n#S2>YCK+xZUFj-m+f y{X$lAwu%q4{Iqw3Sw^JUI*-@o%a_c_n~oaay+j#wjw<%IzN$U_G$j{*RL ziZCFAfd1UVN__#4Sv_QFPKdfQI~HSjebz!a9I9Ct@KfwGa~+qs@F@gcL~}=$4B?KB9oK12({Vd z-`aBIz2P&-*iQ}B7nlvh(edlvzOm<(Y-D8QJ*s;#)j7FKgs@x!3p_d?2XcJY z@!O5DVJNyeQ7NS>3H+u`PXOgP_8LxkEmXb!aUh1E3&s;jf$w_XzODVU&Lv{Q%BfsM z@*@x^vyVIITs}EtyJ@1Lfoef)0KWmv!*VXjAdlVC+q*_lITxBe`xt`hahq>(-6vk15*iXaXPGB$%k6_KPUr^=DDmSu#^ zz$s{Lg3`r2}HOVeL;k5NuNe0nqyIfh(hW}8I`Al5sk;J$0{m5C_rD2 zMzx6;sDWF#xz8;u%`~vVQdvjzrY5%M!Rmg7$7EOUSHn6CoJOzO1JdBk@*E|;$DA@K z)GZGXj*Ca^?F*TPJ%uSu}<31UWV2 z(de1FFCCidY7twXtp!;SiN1UqAX3f9_^OKzFW2|2`75zi0)wciyxpP#gRVq?qBm*f$PZ8g9StQgrE z)>u9`V=7bx^Sm{qW=ts-Btz&Q;qFhUJWcGmuhYj8(u;G;#2N4=7D-GXcvDK_;W<7` zbOAe$`p1LP!b(gkOnF5d0AzJ+;cU3kUatpPnv)lvWr_>;F@o#97^mwtNaV+7!BaIN z19R}i;H5a;0jcn94s8IV!uk2D?D1vn`-2zpWB#qhNNOztmoa|tsekG8LCeUPf;y2g z(IST#XDfM^1v6en29n!qa@^97o_@r1Waw-iF5!l^lki=Qq}QOb&!6`K7;HJKu4ycQ zN=^sA+B?&F__Xd z&_^YCIayPVN~wtGpt5p)$Gqsw>Ms%bRx9EO_-7;6*u^a#rT!N+PA7(#zdJTyuhw}o z!DQ|Oc<1z}b^x=~jD3n0Fmt-CVPzhU7>jt}&N@3;xg)bv6c0*uzwb)dWflRE&6fMn zz`uN50UYb{((+LP2s|TtzXuP0W1z$*C(=TXNde zG%?~4r7N#;@td$^O{?YTQI6Ba8=l)b;XY`8JdpR`Q3Sht#d(D}0FjjbrEHC-0EYP_+(P&U#@W=3# zKjij<-rue{Ql_F7Tvi`UJ(Wl$FC>ok9vFPStbW3^Q7pu&VR$4>)c5s&f@DycXAraq z+~ne#aLzoN!&^A}%g`4E1@hsH#i?h6P8%5*`N4w+srRb6QB%Q(DBvEx%7kzkU#|sp zI~uptyHCrJH#XF3RQv~7HqYiDKjx~Gs}a&|cOA?|G(pQv^5nx9g6FpWQ1o-vKfSH7 zYQp(OYKiSzQ^mQ9iU!XV)A+vW?KEB3bS^h7w+5~{GVURFaxw1M?yKk(5w*te-D2^} z-}2_?_oUwP7rJI*DxuBq9y|A(8FMhYZr>w;=XP?#;&v{c(stG%U(&x+@w!k~IB5B+ z_+0QGMR4tLt--Qn(o-SeO!O<}eHSgVbhBo#t!WDpu2t*ZV#-l9;#I<9eE84f#}bYE zmXG!T{;|Qh2;e49B*!wZU(`)(=>x_)1IN6ZBVj*P=@Y%$mzErxQvOT9?e2ATcW=@Q zb=5q|lMq9Xy~1tX0&ZSO!I54IOuaE6--G@B>A^2r!jn53_jTSiYRd45n&DT4@mU_F}~Ni0b@dPKuD=^TlKIP4cuZU z-KThgcbZqxqB4A(cBCw$Si}{heU<#MMY31hD;`BZ4R_cs3XD2*`A9mgJI;sCD-52s zdZ*HYfu%9B)>G*RH(%aCCbohiZ@x!o3`EVAuck5#YzeC{^0>8NhDD1Scyt+t2Tn%3 z(fY_8Pd@jV>j>3xv3zM#8740gMJX6iLtSeAaajbV#jn%nDQjJ6DD(*sq~K(y2=43< zLZ=2r5wkdd5sAN~3;cT`_^mmcN4V2(qh3DRt2Tcno0O2y9%cvwj)J8-M_E@S=wwe{%8%_J73~0AeR7Ez>vVwekGm4&tI@`4bgO z_NV?9Zy&E%Qt3n#DPw?swL24c6^S?10sfPg`Ml3i93^ z2ppoBPm)j||M3JL=&y`;p@CpJ3FZ)5@_scVRe@|OD3g!{>FFg(7B(K}A>9!I#Mlcg zvIo;G^j{N?*6hb0d8UdzbPsiew<8_q;8-%cdWG;Z_oW4nzOf?R`jxii?o)v9?nfl| zkT7`S$+N#1+NBZ-s~pDw>2RZb9*qWzTI{r$+IfN&dxaqHF_N-xxQFES75t3%MYke~ z79rfZ`h1=t8aP$?@vzsun>U)mw*902QdJ-uc>+RPE;PU^1mlnasHKC_;#VYcz7B_w=hX9W8~(c@9-`z#n|ps_LtufN15DD_xJp>5HUgjUVBqiYd-t*g z@c}l~OZG>>xUiVA@_3g88EKc0^|J{XC8ZO%*akE-I)!4mLWNOlmuOS1GKq7ObzI;{ z^pZz3u(dt8^N#dTV7$<9JdbbB9x=t26)L?V@FMd2pPWuPwC9z{zjEbFz1xK@IE00EsxXLV^6kAJehZnG<%g^K z9gpNjJA(J9ui=CJw^(9*ftZi+0xh8H_-*E@tT{?Uu_B@3>vp@0)fB! z-mDiFOa=0Y^*NME)69=~a$%#2RC((J2s@QV_YeyCvD&{fpV!-&jZ1XI@A+gTuhxb2 zdrUMeNX=`Wmx{#I(tDc87_iTgsHfE8H&UyV*Heh#pflT>nt`Ez!s%2!w*IHp95`H6 z5KCV?ST%bGF-Llw5o|JEk=8CH_yEnA%5IO?HI(e!NP{={9LwGGcwn_o;qiJ(GAt8L zEczi-V7j|aC7xSG5M0+p;8Bd}R6p|NO*0)*V)``+t+_#rXB9s?_nv1PF7MJo;Eza+ zHLW~MPq&@h#rcH0vnEcSSSQh27Vr#Tk#oy`(XX*nh&xa~{4x{00zq?ebZLZyR>UB0*_3-81&M&xBc0<+mb;bs(|zyBCD1o$oZtz|1B|B zb5tN#+%^m!6AtNtpPWaK34~tr2cK4*A;TEo*0&LGCjx)Tu6QG05ag42mKy;-Ljfb7{SD&{ zg~>I5$TJBr2QR<=4T>N{$^UNy2>G&fguw<7vSk9{D{zawy-|ySMu%#FqcPKeJE7`z zEXRzV6I2MB{2&iy1-VzLMy)Unk@GhH#73wBD3sJLP+&a47}r)0hLRkVg%>6A({&sI z^MgE>0^#y^Xd7ulCL}C@*2<8Q4a#l(l_rUx8*PI4BM^TJA6;`NE?7R~+eMLRC}qgw z&As5Nv|p00M?q`zg9WbDW0o9BR#vgsPkVx9Z~sQ4aak3U$@F zcRT3=1<`jpE8T-bt2u7dyG~5+0Yqt#Uc)Mx8%Hpn2K84eDE@+0)qHwl+bqaDQc|sf zT`N|5zv6C7@uw-wA9H=^>8lv~EA=PG<}fUox-+G#sV%7N;2t1F{`sXY7){?p`Jy2} z71mD-*!iC~OFZUNgt#$JLXvpW-yp9nBk!>cY;~t?)nZrd&(Ooxsg(D`V&S%v=VdctX@vy(8uF8YL<4(MKsL}Hpn*r3uA+_#X1 zPLs-}AyORx$^OtJdR=%nYwGET1|S0-*Jii|to5xgl;`phi-u4%F+tKRIba}^c%^uz ztm!s%X6DO;zH}tf(btKd^@NrdkF&ybmw-6*s(W~z-I7?f3p@m*Sx)BlEfYwaizQNJ zfeMSgW}UgnrzG+XBdIL7*X(u z%9A>|i}xG|kv6GM>#3xDgwYi6HMgy3Jl-wV+NhP`y7>F1{>LQ~g{eD_3rdvx-b}wKpG(Uc^glE>rVtep=VuszzPLj7c4m5X<#ZywcQj#&=P2?Nx&2M}=jtk<+^Y0iH zWz)mhadH5R;2}eH9O$%~yw(*mio3~ljAl6xpg##Y#FZsufXYl{gLURhmo7D+1v=U5 zrr>OP0Z_(RMtEDbQJlCe3&Z8!Sj9rHk0hx z0qw@{hT)gk#il75K!AbvgO+Z@_3-9V9-=r>D?>1wz4^HqnZDGxi_(=>tPhL#)+Oeq zi~U(OuO5p1#^G@ITGKst&Ui)(l2ti#d8DRi@o2p|MeTgT5D~$R?mD)5ai;S1u$NKYK3E)@wX;A+cFd|tlm_|nx`BT_y z!OL;H$css&>+B>=3joF>`{v_#OVpU^WIYG6`js;lm-`qzzftyRI`n292eigP(D57` zC}7S?WaYa_-4{--~#g=vH?1@DlClOaY(ci!5f3R zMdOR2_5jsbTZES>rk6i)>KWF|y|^|lcvbu!S+G$$2mu}wwr-GNUnR=#bks>o=VIMv z&l8tztdu$Us=CrnU%uJM3njX~IhRLcT6$4M#lpZ4n|P*{H{3USNs)us^#i(AD;Mk6 z<9P<4LNG-2HnMWt{bD&Qmfh%yhCT8eF=3OHgj2mBTM?K)8n}Hp|L2cx*agIw6qiZd+-yW*^UVD8XIQ2@s z8M?cvkj-L)D7#--HuS^yP*=>Qa8Vd$0 zKSshhLiD)4A`;l4aHDp_^?`FOIG9rM=h5%CM)^JaK7QU|HR(@$aoExwFfLK2VYha7 z)GS6-UPlsW!0K-DP2*EqiS}KzWbO-o1k- zTwHFQIB~+?-p=kth~Bt0nvgY9FOaYeVKyKaf#AJBZ$h@VdL zQQaaNR~kRgHs`pPa>y#qoxsH=43^n@sCKI?*I)k=CHepT)3E1>&*t2D(uT5Fgzmz@ NA*&;nPb@qW{|5&3xOe~n diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png deleted file mode 100644 index 42deceacdc1dd49e4d9eed75f7adf9b183c4b949..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11672 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 a5B~+jNiku(f?u%!0000 diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png deleted file mode 100644 index 42deceacdc1dd49e4d9eed75f7adf9b183c4b949..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11672 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

bIY$l_&BjY+_re0J0g~UnbZ$jUw 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 o Date: Mon, 12 Oct 2026 18:43:21 +0200 Subject: [PATCH 14/25] Declare privacy and export compliance for the App Store Both the app and the widget extension carry a privacy manifest: no tracking, no tracking domains, nothing collected. Readings and commands travel end to end encrypted between the phone and the household's box; the relay forwards ciphertext and stores nothing. Required reasons, checked against the Release binaries: - File timestamp, C617.1, in both. FTWShared's CacheReader picks the newest home by the modification date of its App Group files. - UserDefaults, CA92.1, in the app. Only Debug builds read it today, for the screenshot launch arguments. ITSAppUsesNonExemptEncryption is NO. Every primitive comes from CryptoKit in iOS; the app implements no algorithm of its own. The pull request gives the reasoning and sources, and what France needs. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW-Info.plist | 2 ++ appleApp/FTW/PrivacyInfo.xcprivacy | 31 +++++++++++++++++++++++ appleApp/FTWWidgets/PrivacyInfo.xcprivacy | 23 +++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 appleApp/FTW/PrivacyInfo.xcprivacy create mode 100644 appleApp/FTWWidgets/PrivacyInfo.xcprivacy diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist index e20acc0..1bb587f 100644 --- a/appleApp/FTW-Info.plist +++ b/appleApp/FTW-Info.plist @@ -9,6 +9,8 @@ en sv + ITSAppUsesNonExemptEncryption + UILaunchScreen UIColorName diff --git a/appleApp/FTW/PrivacyInfo.xcprivacy b/appleApp/FTW/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..dba570d --- /dev/null +++ b/appleApp/FTW/PrivacyInfo.xcprivacy @@ -0,0 +1,31 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/appleApp/FTWWidgets/PrivacyInfo.xcprivacy b/appleApp/FTWWidgets/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..64e785c --- /dev/null +++ b/appleApp/FTWWidgets/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + From 98b463fdc9a171604d4053e248e2531ec137bd8c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:45:12 +0200 Subject: [PATCH 15/25] Keep the release key in a GitHub environment The upload needs an Admin API key, because only Account Holder and Admin may use cloud-managed distribution certificates. A repository secret would let any branch's workflow read it, so the job takes its secrets from the app-store environment, which only main and ios-v* tags may use. Pull requests run without the environment. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ios-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index 9b692d3..37b1531 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -30,6 +30,9 @@ jobs: # macos-26 image stops at Xcode 26.6. runs-on: xcode-27 timeout-minutes: 60 + # The key's secrets live in this environment, which only main and + # ios-v* tags may use (RELEASING.md). A pull request needs no key. + environment: ${{ github.event_name != 'pull_request' && 'app-store' || '' }} env: # The release, by path. Picking the newest Xcode on this image would # pick a beta. From 1eed13805067a854ad213eba758f047915074334 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:45:12 +0200 Subject: [PATCH 16/25] Write down the App Store steps and the store texts RELEASING.md lists, in order, what someone in team L66M368PZ6 does in App Store Connect and on GitHub before the first upload: app record and name, EU trader status, App Privacy, export, age rating, price, countries, the API key, TestFlight and the review. store/ holds the product page in English and Swedish, the App Review notes for the demo, and check.sh, which holds each text to Apple's limits. scripts/store-screenshots.sh takes the 6.9" pictures from the demo in its own simulator. Co-Authored-By: Claude Opus 5.5 --- appleApp/RELEASING.md | 321 ++++++++++++++++++++++ appleApp/scripts/store-screenshots.sh | 55 ++++ appleApp/store/README.md | 27 ++ appleApp/store/check.sh | 44 +++ appleApp/store/en-US/description.txt | 30 ++ appleApp/store/en-US/keywords.txt | 1 + appleApp/store/en-US/marketing-url.txt | 1 + appleApp/store/en-US/name.txt | 1 + appleApp/store/en-US/privacy-url.txt | 1 + appleApp/store/en-US/promotional-text.txt | 1 + appleApp/store/en-US/release-notes.txt | 1 + appleApp/store/en-US/subtitle.txt | 1 + appleApp/store/en-US/support-url.txt | 1 + appleApp/store/review-notes.txt | 20 ++ appleApp/store/sv-SE/description.txt | 30 ++ appleApp/store/sv-SE/keywords.txt | 1 + appleApp/store/sv-SE/marketing-url.txt | 1 + appleApp/store/sv-SE/name.txt | 1 + appleApp/store/sv-SE/privacy-url.txt | 1 + appleApp/store/sv-SE/promotional-text.txt | 1 + appleApp/store/sv-SE/release-notes.txt | 1 + appleApp/store/sv-SE/subtitle.txt | 1 + appleApp/store/sv-SE/support-url.txt | 1 + 23 files changed, 543 insertions(+) create mode 100644 appleApp/RELEASING.md create mode 100755 appleApp/scripts/store-screenshots.sh create mode 100644 appleApp/store/README.md create mode 100755 appleApp/store/check.sh create mode 100644 appleApp/store/en-US/description.txt create mode 100644 appleApp/store/en-US/keywords.txt create mode 100644 appleApp/store/en-US/marketing-url.txt create mode 100644 appleApp/store/en-US/name.txt create mode 100644 appleApp/store/en-US/privacy-url.txt create mode 100644 appleApp/store/en-US/promotional-text.txt create mode 100644 appleApp/store/en-US/release-notes.txt create mode 100644 appleApp/store/en-US/subtitle.txt create mode 100644 appleApp/store/en-US/support-url.txt create mode 100644 appleApp/store/review-notes.txt create mode 100644 appleApp/store/sv-SE/description.txt create mode 100644 appleApp/store/sv-SE/keywords.txt create mode 100644 appleApp/store/sv-SE/marketing-url.txt create mode 100644 appleApp/store/sv-SE/name.txt create mode 100644 appleApp/store/sv-SE/privacy-url.txt create mode 100644 appleApp/store/sv-SE/promotional-text.txt create mode 100644 appleApp/store/sv-SE/release-notes.txt create mode 100644 appleApp/store/sv-SE/subtitle.txt create mode 100644 appleApp/store/sv-SE/support-url.txt diff --git a/appleApp/RELEASING.md b/appleApp/RELEASING.md new file mode 100644 index 0000000..ca897c4 --- /dev/null +++ b/appleApp/RELEASING.md @@ -0,0 +1,321 @@ +# Releasing the iPhone app + +CI tests, archives, signs and uploads. A person sets up App Store Connect +once, then tags each release. This file lists that person's steps in order. +Checked against Apple's pages on 12 October 2026; Apple moves menus, so +trust the screen if it differs. + +| What | Value | +|---|---| +| Seller | Sourceful Labs AB (reg. no. 559382-0458), Kalmar, Sweden | +| Team ID | `L66M368PZ6` | +| Bundle IDs | `energy.ftw.app`, `energy.ftw.app.widgets` | +| App Group | `group.energy.ftw.app` | +| Workflow | [`.github/workflows/ios-release.yml`](../.github/workflows/ios-release.yml) | +| Store texts | [`store/`](store/) | +| Privacy and support | `https://ftw.energy/privacy`, `https://ftw.energy/support` ([srcfl/ftw-web](https://github.com/srcfl/ftw-web)) | + +## How a build travels + +1. A tag `ios-v1.2.3` (or **Run workflow** on the *iOS release* workflow) + starts the job on GitHub's `xcode-27` runner. +2. It runs `swift test`, then archives with Xcode 27.0 and the iOS 27 SDK. + The version comes from the tag (a manual run uses `MARKETING_VERSION` in + `FTW.xcconfig`); the build number is the run number, with `.2`, `.3` on a + re-run. +3. Xcode signs with the team through the API key and uploads with + [`ExportOptions.plist`](ExportOptions.plist) (`app-store-connect`, + `upload`). The build shows in TestFlight once Apple has processed it. + +Without the three secrets the job archives unsigned and reports +"Upload skipped". A pull request that changes the workflow or the export +options always takes that path, so it proves the archive without a key. + +Apple has required the iOS 26 SDK for uploads since 28 April 2026 and +requires the iOS 27 SDK from April 2027. Xcode 27 meets both. + +## Once, before the first upload + +### 1. Agreements, identifiers and links + +- The Account Holder accepts the latest Apple Developer Program License + Agreement under **Business**. Until then App Store Connect refuses new + apps, and the API key stops working. A free app needs no Paid Apps + Agreement. +- Register the App IDs, the App Group and Associated Domains as + [README](../README.md#status-iphone-2026-10-12) lists under "Manual + steps before a phone build". Building once in Xcode, signed in to the + team, does this too. +- `https://app.ftw.energy/.well-known/apple-app-site-association` must + answer before review: passkeys and pairing links need it + ([srcfl/ftw-webapp#77](https://github.com/srcfl/ftw-webapp/pull/77)). + On 12 October 2026 it answered 404. +- `ftw.energy` must serve the website. On 12 October 2026 it had no + address record, and the Cloudflare Pages project `ftw` served only + `ftw.sourceful.energy`. In Cloudflare: **Workers & Pages → ftw → Custom + domains → Set up a custom domain → `ftw.energy`**. Then check: + + ```bash + curl -sI https://ftw.energy/privacy + ``` + + If `ftw.energy` stays off, replace it with `ftw.sourceful.energy` in + `store/*/privacy-url.txt`, `support-url.txt` and `marketing-url.txt`. + +### 2. Create the app + +**App Store Connect → Apps → (+) → New App** + +| Field | Enter | +|---|---| +| Platforms | iOS | +| Name | `FTW` (see below) | +| Primary Language | English (U.S.) | +| Bundle ID | `energy.ftw.app` | +| SKU | `energy.ftw.app` (cannot change later) | +| User Access | Full Access | + +English is the primary language, so every country without its own text +shows English. Then add **Swedish** under the localization menu in App +Information. + +**The name.** The iTunes Search API found no live app named exactly "FTW" +in the Swedish or US store on 12 October 2026 (nearest: "FTW Fly", "FTW - +Step Competition", "FTW Fitness", "FTW Trading"). The search sees only +live apps, and Apple also holds names for apps not yet released, so the New +App dialog gives the final answer. If "FTW" is taken, try in this order; +none was in use: + +1. `FTW Energy` (Swedish page: `FTW Energi`) +2. `FTW – Home Energy` (`FTW – Hemmets energi`) +3. `FTW by Sourceful` + +The name under the icon stays "FTW" whatever the store name. + +### 3. App Information + +**Apps → FTW → App Information** (under General), for each language: + +| Field | Value | +|---|---| +| Subtitle | `store//subtitle.txt` | +| Category | Primary **Utilities**, no secondary. Not Health & Fitness: that asks for a medical device declaration. | +| Content Rights | No third-party content: the app shows only the user's own box. | +| Age Rating | **Set Up Age Ratings**. Answer No to every capability (web access, user-generated content, social media, messaging, advertising) and None to every content item. Age Categories and Override: Not Applicable. Result: 4+. | +| Copyright | `2026 Sourceful Labs AB` | +| Privacy Policy URL (App Privacy) | `store//privacy-url.txt` | + +### 4. Trader status under the EU Digital Services Act + +EU users see the trader's address, phone and email on the product page. +Apple removes apps without trader status from the EU stores. + +1. **Business → Agreements → Compliance → Digital Services Act → Complete + Compliance Requirements → This is a trader account.** +2. The address comes from Sourceful Labs AB's D-U-N-S Number. Enter a + phone number and an email address, and confirm each with the code Apple + sends. +3. Upload a document that proves the company name and address, such as a + registration certificate from Bolagsverket. +4. For the app: **App Information → App Store Regulations and Permits → + Digital Services Act → Edit → trader**. + +Sourceful Labs AB already sells "Sourceful Energy" in the EU stores, so +the account may already be a trader account; check step 1 first. If that +app lives under another team, this team needs its own. + +**Decide:** which email and phone EU users see. The only Sourceful address +published today is `support@sourceful.energy` on +[sourceful.energy/contact](https://www.sourceful.energy/contact), which +ftw-web and FTW's SUPPORT.md link to. No phone number is published. + +### 5. App Privacy + +**Apps → FTW → App Privacy → Get Started → "No, we do not collect data +from this app" → Save → Publish.** + +Apple's test: data is collected when it leaves the device "in a way that +allows you and/or your third-party partners to access it for a period +longer than what is necessary to service the transmitted request in real +time". In M1: + +- The app's only network peer is `wss://relay.ftw.energy`. Everything it + sends is end-to-end encrypted to the user's box; the relay has no key. +- The relay keeps no traffic. Its access log drops addresses and + rendezvous handles (`relay/deploy/Caddyfile` in ftw-webapp). +- No analytics, ads, third-party code or tracking. + +One edge: when the relay fails to proxy a connection, Caddy's error log +may still hold the client address and handle (rotated, 3 × 10 MB). +Filtering that log too would remove the doubt; that change belongs in +ftw-webapp. + +The privacy manifest (`PrivacyInfo.xcprivacy`) comes with the store-ready +branch. Both answers change when FTW Cloud ships: it will hold readable +data the user turns on. + +### 6. Encryption and export + +The store-ready branch sets `ITSAppUsesNonExemptEncryption` in +`FTW-Info.plist`; with the key set, App Store Connect asks nothing per +build. Without it, answer under **App Information → App Encryption +Documentation (+)**, or **Manage** next to a build in TestFlight. + +What to answer: FTWKit builds Noise IK from CryptoKit alone (X25519, +ChaCha20-Poly1305, SHA-256, HKDF). Apple's table says encryption "limited +to that within the Apple operating system" needs no documentation, and no +French declaration. + +**Decide with counsel:** Apple notes that an app using exempt encryption +"might alternatively be required to submit a year-end self-classification +report to the U.S. government", and that US export law applies wherever +the company sits. + +### 7. Price and countries + +**Monetization → Pricing and Availability** + +1. **Price Schedule → Add Pricing**: base country Sweden, price Free. +2. **App Availability → Set Up Availability → All Countries or Regions** + (175, and new ones as Apple adds them). +3. Remove **China mainland**. It needs an ICP Filing Number in App + Information that matches a filing with China's MIIT, which in practice + takes a Chinese entity or partner. Without it the version stops at + "ICP Filing Number Missing". (The iTunes lookup shows "Sourceful + Energy" in the Chinese store. If Sourceful holds a filing, enter it and + keep China.) +4. Untick **Apple silicon Mac** and **Apple Vision Pro**. Apple publishes + iPhone apps there by default; M1 is iPhone only. + +Other countries, checked for a free 4+ utility that is not a game: + +| Where | Rule | For FTW | +|---|---|---| +| South Korea, Vietnam | Game ratings and licences | Games only. Nothing to do. | +| Brazil | Tax forms | Only for developers in Brazil. | +| Australia, Brazil, Singapore | 18+ download checks | Not for a 4+ app. | +| Texas, Utah, Louisiana | Age assurance for new accounts (Declared Age Range API) | **Decide with counsel.** M1 has no accounts and no age-dependent content. | +| Russia | — | "Sourceful Energy" is not in the Russian store. **Decide** whether FTW follows. | + +### 8. API key and GitHub secrets + +**Users and Access → Integrations → App Store Connect API → Team Keys → +Generate API Key** + +- Name: `GitHub ftw-app release`. +- Access: **Admin**. App Manager is not enough: Apple's roles table gives + cloud-managed distribution certificates to Account Holder and Admin, and + other roles only through a per-user setting that team keys cannot have. + The runner has no certificate of its own, so the export needs one from + the cloud. Role and name cannot change after creation. +- Download the `.p8` file. Apple offers it once. +- The Key ID is in the key's row; the Issuer ID is at the top of the page. + +Keep the secrets in a GitHub environment that only `main` and release tags +can use, since an Admin key can do much more than upload builds: + +1. **srcfl/ftw-app → Settings → Environments → New environment → + `app-store`.** +2. **Deployment branches and tags → Selected branches and tags**: add + branch `main` and tag `ios-v*`. Optionally add yourself as a required + reviewer. +3. Add the secrets there: + + ```bash + gh secret set ASC_KEY_ID --env app-store --repo srcfl/ftw-app + ``` + + ```bash + gh secret set ASC_ISSUER_ID --env app-store --repo srcfl/ftw-app + ``` + + ```bash + gh secret set ASC_KEY_P8 --env app-store --repo srcfl/ftw-app < AuthKey_XXXXXXXXXX.p8 + ``` + +4. Delete the `.p8` from your disk, or keep it in a password manager. + +Each run on a clean runner may create an "Apple Development" certificate +marked "Created via API" for the archive. If **Certificates** reports a +limit, revoke the old ones. + +### 9. TestFlight + +After the first upload: **Apps → FTW → TestFlight**. + +1. **Additional → Test Information**, for each language: + - Beta App Description: the first paragraph of `store//description.txt`. + - Feedback Email: an inbox someone reads (see the decision in step 4). + - Marketing URL and Privacy Policy URL: from `store//`. + - Beta App Review Information: contact, "Sign-in required" off, and + `store/review-notes.txt` as notes. +2. **Internal Testing → (+)**: group `Sourceful`. Add App Store Connect + users (up to 100) and turn on automatic distribution. +3. **External Testing → (+)**: group `Beta`. Add the build, then **Submit + for Review**. Apple reviews the first build; later builds of the same + version usually go out at once. Invite by email or turn on a public link + (Open to Anyone, or Filter by Criteria, with a tester limit). Up to + 10,000 testers. +4. For each build, **What to Test**: `store//release-notes.txt`. + +A build expires after 90 days. + +### 10. The App Store version + +**Apps → FTW → App Store → iOS App → the version** + +| Field | Source | +|---|---| +| iPhone screenshots | `scripts/store-screenshots.sh` (6.9", 1320 × 2868) | +| Promotional Text | `store//promotional-text.txt` | +| Description | `store//description.txt` | +| Keywords | `store//keywords.txt` | +| Support URL | `store//support-url.txt` | +| Marketing URL | `store//marketing-url.txt` | +| What's New | `store//release-notes.txt` (not shown for the first version) | +| Build | The one TestFlight tested | +| App Review Information | Sign-in required: off. Contact: name, email, phone with `+46`. Notes: `store/review-notes.txt` | +| Version Release | Manually release this version | + +Then **Add for Review → Submit**. + +Guideline 2.1(a) allows a demo mode "in lieu of a demo account with prior +approval by Apple". FTW has no login; the demo stands in for the box. The +notes say so. If review asks for a video of a real box, record pairing +and the car view. + +`store/check.sh` checks every text against Apple's limits. + +## Each release + +1. Set `MARKETING_VERSION` in `FTW.xcconfig` to the new version on `main`. +2. Tag that commit and push the tag: + + ```bash + git tag ios-v1.0.0 && git push origin ios-v1.0.0 + ``` + +3. Watch the *iOS release* run. When it reports the upload, the build + appears in TestFlight within minutes to an hour. +4. Write What to Test, then hand the build to the groups in step 9. + +## When the upload fails + +The run keeps Xcode's export logs as the `export-logs` artifact. + +| Message | Cause | +|---|---| +| Cloud signing permission error | The key is not Admin. | +| No profiles for 'energy.ftw.app' | App ID not registered, or no app record yet. | +| Private key not installed for an Apple Development certificate | Old API certificates; revoke them under Certificates. | +| The bundle version must be higher | A build number was used; run again or tag again. | +| Agreement missing or expired | The Account Holder must accept it under Business. | + +## Later + +- **M3, push:** an APNs key (**Certificates, Identifiers & Profiles → Keys + → (+) → Apple Push Notification service**). Not before. +- **FTW Cloud:** update the privacy pages, App Privacy, `review-notes.txt` + and the description before it ships. +- **April 2027:** apps on the iOS 27.1 SDK or later also need iPhone Duo + screenshots. diff --git a/appleApp/scripts/store-screenshots.sh b/appleApp/scripts/store-screenshots.sh new file mode 100755 index 0000000..e860bc3 --- /dev/null +++ b/appleApp/scripts/store-screenshots.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# App Store screenshots: the demo on a 6.9" iPhone (1320 x 2868), in Swedish +# and English, ready for App Store Connect (RELEASING.md, step 10). +# +# Makes its own simulator and deletes it afterwards, so it leaves anyone +# else's alone. Needs a Debug build in build/ios, as for demo-screenshots.sh: +# xcodebuild build -project FTW.xcodeproj -scheme FTW \ +# -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios +# +# scripts/store-screenshots.sh [out] default build/store-screenshots +# FTW_STORE_DEVICE=iPhone-17-Pro another size (6.3", 1206 x 2622) +# FTW_STORE_LOOK=light light instead of dark +set -euo pipefail +cd "$(dirname "$0")/.." +app=build/ios/Build/Products/Debug-iphonesimulator/FTW.app +out=${1:-build/store-screenshots} +device=com.apple.CoreSimulator.SimDeviceType.${FTW_STORE_DEVICE:-iPhone-17-Pro-Max} +look=${FTW_STORE_LOOK:-dark} + +runtime=$(xcrun simctl list runtimes available --json | python3 -c ' +import json, sys +ios = [r for r in json.load(sys.stdin)["runtimes"] if r["platform"] == "iOS"] +print(max(ios, key=lambda r: [int(p) for p in r["version"].split(".")])["identifier"])') + +udid=$(xcrun simctl create "FTW store screenshots" "$device" "$runtime") +trap 'xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true; xcrun simctl delete "$udid"' EXIT +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b >/dev/null +xcrun simctl ui "$udid" appearance "$look" +# The status bar Apple's own pictures show. +xcrun simctl status_bar "$udid" override --time 9:41 --dataNetwork wifi \ + --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 \ + --batteryState charged --batteryLevel 100 +xcrun simctl install "$udid" "$app" + +# folder, language, name, then the app's launch arguments +shoot() { + local dir=$1 lang=$2 name=$3 + shift 3 + mkdir -p "$out/$dir" + xcrun simctl launch --terminate-running-process "$udid" energy.ftw.app \ + -AppleLanguages "($lang)" -AppleLocale "${dir/-/_}" "$@" >/dev/null + sleep 8 + xcrun simctl io "$udid" screenshot "$out/$dir/$name.png" >/dev/null + echo "$out/$dir/$name.png $(sips -g pixelWidth -g pixelHeight "$out/$dir/$name.png" | awk '/pixel/ {printf "%s ", $2}')" +} + +# -FTWTab and -FTWDemoCar only work in a Debug build with the car view. +for pair in sv-SE:sv en-US:en; do + dir=${pair%%:*} lang=${pair##*:} + shoot "$dir" "$lang" 1-now -FTWDemo YES + shoot "$dir" "$lang" 2-car -FTWDemo YES -FTWTab car + shoot "$dir" "$lang" 3-charging -FTWDemo YES -FTWTab car -FTWDemoCar charging + shoot "$dir" "$lang" 4-pair +done diff --git a/appleApp/store/README.md b/appleApp/store/README.md new file mode 100644 index 0000000..bd33905 --- /dev/null +++ b/appleApp/store/README.md @@ -0,0 +1,27 @@ +# App Store texts + +What the App Store page and TestFlight say about FTW, one file per field, +pasted into App Store Connect by hand ([RELEASING.md](../RELEASING.md)). + +| File | App Store Connect field | Limit | +|---|---|---| +| `name.txt` | Name | 30 characters | +| `subtitle.txt` | Subtitle | 30 characters | +| `promotional-text.txt` | Promotional Text | 170 characters | +| `description.txt` | Description | 4000 characters | +| `keywords.txt` | Keywords, comma-separated, each three or more characters | 100 bytes | +| `release-notes.txt` | What's New, and What to Test in TestFlight | 4000 characters | +| `privacy-url.txt` | Privacy Policy URL | | +| `support-url.txt` | Support URL | | +| `marketing-url.txt` | Marketing URL | | +| `review-notes.txt` | App Review Information → Notes, one for all languages | 4000 bytes | + +`en-US` is the primary language, so countries without their own text show +it. `sv-SE` is Swedish. + +Claim only what the app on `main` does. Write plainly: short words, active +voice, no stock phrases, nothing left to cut. Then run: + +```bash +appleApp/store/check.sh +``` diff --git a/appleApp/store/check.sh b/appleApp/store/check.sh new file mode 100755 index 0000000..e1b0f10 --- /dev/null +++ b/appleApp/store/check.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Fails when a store text breaks one of Apple's limits for App Store Connect. +# Run it after editing anything in this folder. +set -euo pipefail +cd "$(dirname "$0")" +python3 - <<'PY' +import pathlib, sys + +# file: (limit, unit). Keywords and review notes count bytes; the rest count +# characters. +limits = { + "name.txt": (30, "chars"), + "subtitle.txt": (30, "chars"), + "promotional-text.txt": (170, "chars"), + "description.txt": (4000, "chars"), + "keywords.txt": (100, "bytes"), + "release-notes.txt": (4000, "chars"), + "review-notes.txt": (4000, "bytes"), +} +bad = 0 +for path in sorted(pathlib.Path(".").rglob("*.txt")): + text = path.read_text(encoding="utf-8").rstrip("\n") + if path.name.endswith("-url.txt"): + ok = text.startswith("https://") and "\n" not in text + print(f"{'ok ' if ok else 'BAD'} {path} {text}") + bad += not ok + continue + if path.name not in limits: + print(f"BAD {path} not a field this script knows") + bad += 1 + continue + limit, unit = limits[path.name] + size = len(text.encode()) if unit == "bytes" else len(text) + ok = 0 < size <= limit + if path.name == "keywords.txt": + words = text.split(",") + short = [w for w in words if len(w.strip()) <= 2] + if short: + ok = False + print(f" {path}: every keyword needs three or more characters: {short}") + print(f"{'ok ' if ok else 'BAD'} {path} {size}/{limit} {unit}") + bad += not ok +sys.exit(1 if bad else 0) +PY diff --git a/appleApp/store/en-US/description.txt b/appleApp/store/en-US/description.txt new file mode 100644 index 0000000..7453255 --- /dev/null +++ b/appleApp/store/en-US/description.txt @@ -0,0 +1,30 @@ +See your home's energy on your iPhone, straight from the FTW box at home. Steer the car's charging and check electricity prices. + +FTW is open-source home energy software from Sourceful. It runs on a box in your home, reads your meters and devices, plans the battery and the car, and controls them on site. This app shows you that box. You say what you want; the box decides how. + +NOW +• Is FTW in control? One line tells you, and why not when it isn't. +• Live power between solar, battery, home, grid and car. +• How much of today's power your home made itself. +• The electricity price now, the cheapest two hours and the hours ahead. +• Every reading shows its age. When the link drops, the app says so and reconnects on its own. + +YOUR CAR +• The car's level, and where it came from: the car, you or FTW's estimate. +• Correct it with 20, 40, 60 or 80 %, or by 5 % up or down. +• Set a goal: how full and by when, once or on chosen days. +• See whether the plan will reach it. +• Charge now, Pause or Back to the plan. Each command shows when it was sent, when the box got it and when it was done. + +PRICE WIDGETS +• The electricity price on your Home Screen, Lock Screen and in StandBy. +• Widgets show what the app last read from your box, and how old it is. + +PRIVACY +• No account and no password. Scan the pairing code your box shows and confirm with a passkey. +• Your data stays on your box and your phone. The app reaches the box through Sourceful's relay, which carries encrypted traffic it cannot read. +• No ads, no analytics, no tracking. + +WHAT YOU NEED +• An FTW box at home with the FTW app turned on in its settings. FTW runs on a Raspberry Pi or another Linux computer. Learn more at ftw.energy. +• No box? Try the demo, a simulated home that runs inside the app. diff --git a/appleApp/store/en-US/keywords.txt b/appleApp/store/en-US/keywords.txt new file mode 100644 index 0000000..7db399f --- /dev/null +++ b/appleApp/store/en-US/keywords.txt @@ -0,0 +1 @@ +solar,battery,charging,electricity,price,spot price,home energy,HEMS,grid,kWh,widget,smart home diff --git a/appleApp/store/en-US/marketing-url.txt b/appleApp/store/en-US/marketing-url.txt new file mode 100644 index 0000000..7c53c28 --- /dev/null +++ b/appleApp/store/en-US/marketing-url.txt @@ -0,0 +1 @@ +https://ftw.energy/ diff --git a/appleApp/store/en-US/name.txt b/appleApp/store/en-US/name.txt new file mode 100644 index 0000000..fbbe77b --- /dev/null +++ b/appleApp/store/en-US/name.txt @@ -0,0 +1 @@ +FTW diff --git a/appleApp/store/en-US/privacy-url.txt b/appleApp/store/en-US/privacy-url.txt new file mode 100644 index 0000000..851268c --- /dev/null +++ b/appleApp/store/en-US/privacy-url.txt @@ -0,0 +1 @@ +https://ftw.energy/privacy diff --git a/appleApp/store/en-US/promotional-text.txt b/appleApp/store/en-US/promotional-text.txt new file mode 100644 index 0000000..3659018 --- /dev/null +++ b/appleApp/store/en-US/promotional-text.txt @@ -0,0 +1 @@ +See what your FTW box sees, with the age of every reading. Set the car's level and goal, or charge now. Electricity prices on your Home Screen and Lock Screen. diff --git a/appleApp/store/en-US/release-notes.txt b/appleApp/store/en-US/release-notes.txt new file mode 100644 index 0000000..ba26095 --- /dev/null +++ b/appleApp/store/en-US/release-notes.txt @@ -0,0 +1 @@ +The first FTW app for iPhone. Pair with your box, see your home's energy now, steer the car's charging and put electricity prices on your Home Screen and Lock Screen. diff --git a/appleApp/store/en-US/subtitle.txt b/appleApp/store/en-US/subtitle.txt new file mode 100644 index 0000000..f3cee54 --- /dev/null +++ b/appleApp/store/en-US/subtitle.txt @@ -0,0 +1 @@ +Your home energy, on the phone diff --git a/appleApp/store/en-US/support-url.txt b/appleApp/store/en-US/support-url.txt new file mode 100644 index 0000000..464405d --- /dev/null +++ b/appleApp/store/en-US/support-url.txt @@ -0,0 +1 @@ +https://ftw.energy/support diff --git a/appleApp/store/review-notes.txt b/appleApp/store/review-notes.txt new file mode 100644 index 0000000..6a7b371 --- /dev/null +++ b/appleApp/store/review-notes.txt @@ -0,0 +1,20 @@ +FTW is the phone app for FTW, open-source home energy software that runs on a box in the user's home (a Raspberry Pi or another Linux computer). The app talks only to that box, through an end-to-end encrypted relay. There is no account and nothing to sign in to. You do not have a box, so the app has a built-in demo for you. + +THE DEMO +1. Open the app. On the first screen, tap "Try the live demo". +2. The demo runs a simulated FTW box inside the app. It needs no network and no passkey, and it saves nothing. +3. Now shows "Is FTW in control?", live power between solar, battery, home, grid and car, and today's electricity prices. +4. Tap the car to open the car view. Try the level buttons (20/40/60/80 % and ±5), set a goal, then tap Charge now, Pause and Back to the plan. The simulated box answers each command, and the app shows when it was sent, received and done. +5. "Exit demo" at the top returns to the first screen. + +WITH A REAL BOX (needs the hardware) +The owner opens the box's local dashboard, chooses Settings → FTW app → Show pairing code and scans the QR code with this app or the iPhone camera (links to https://app.ftw.energy/p). The app then creates a passkey for app.ftw.energy and connects through wss://relay.ftw.energy. + +WIDGETS +The price widgets show prices the app saved from a real box. The demo saves nothing, so in review a widget says "Open FTW to see prices". + +OTHER +- Camera: used only to read the pairing QR code. +- No sign-in, no in-app purchases, no ads, no analytics, no tracking. +- Encryption: Noise protocol built on Apple's CryptoKit only. +- The demo stands in for the hardware, not for a login: the app has no login. diff --git a/appleApp/store/sv-SE/description.txt b/appleApp/store/sv-SE/description.txt new file mode 100644 index 0000000..5e11860 --- /dev/null +++ b/appleApp/store/sv-SE/description.txt @@ -0,0 +1,30 @@ +Se hemmets energi i din iPhone, direkt från FTW-boxen hemma. Styr bilens laddning och se elpriset. + +FTW är hemenergi med öppen källkod från Sourceful. Det körs på en box hemma hos dig, läser av mätare och enheter, planerar batteriet och bilen och styr dem på plats. Appen visar dig boxen. Du säger vad du vill, och boxen bestämmer hur. + +NU +• Styr FTW? En rad svarar, och säger varför när svaret är nej. +• Effekten just nu mellan sol, batteri, hem, elnät och bil. +• Hur stor del av dagens el hemmet gjorde själv. +• Elpriset nu, de två billigaste timmarna och timmarna framåt. +• Varje värde visar sin ålder. Bryts kontakten säger appen det och ansluter igen av sig själv. + +BILEN +• Bilens nivå, och var den kommer ifrån: bilen, du eller FTW:s uppskattning. +• Rätta den med 20, 40, 60 eller 80 %, eller 5 % upp eller ner. +• Sätt ett mål: hur full och till när, en gång eller vissa dagar. +• Se om planen hinner. +• Ladda nu, Pausa eller Tillbaka till planen. Varje kommando visar när det skickades, när boxen fick det och när det var utfört. + +PRISWIDGETAR +• Elpriset på hemskärmen, låsskärmen och i Vänteläge. +• Widgetarna visar det appen senast läste från boxen, och hur gammalt det är. + +INTEGRITET +• Inget konto och inget lösenord. Skanna parkopplingskoden som boxen visar och bekräfta med en lösenordsnyckel. +• Din data stannar i boxen och i telefonen. Appen når boxen genom Sourcefuls relä, som bär krypterad trafik det inte kan läsa. +• Ingen reklam, ingen analys, ingen spårning. + +DET HÄR BEHÖVER DU +• En FTW-box hemma med FTW-appen påslagen i boxens inställningar. FTW körs på en Raspberry Pi eller en annan Linuxdator. Läs mer på ftw.energy. +• Ingen box? Prova demon, ett simulerat hem som körs i appen. diff --git a/appleApp/store/sv-SE/keywords.txt b/appleApp/store/sv-SE/keywords.txt new file mode 100644 index 0000000..b163969 --- /dev/null +++ b/appleApp/store/sv-SE/keywords.txt @@ -0,0 +1 @@ +solceller,batteri,elbil,laddning,elpris,spotpris,energi,hemenergi,elnät,kWh,widget,smarta hem diff --git a/appleApp/store/sv-SE/marketing-url.txt b/appleApp/store/sv-SE/marketing-url.txt new file mode 100644 index 0000000..7c53c28 --- /dev/null +++ b/appleApp/store/sv-SE/marketing-url.txt @@ -0,0 +1 @@ +https://ftw.energy/ diff --git a/appleApp/store/sv-SE/name.txt b/appleApp/store/sv-SE/name.txt new file mode 100644 index 0000000..fbbe77b --- /dev/null +++ b/appleApp/store/sv-SE/name.txt @@ -0,0 +1 @@ +FTW diff --git a/appleApp/store/sv-SE/privacy-url.txt b/appleApp/store/sv-SE/privacy-url.txt new file mode 100644 index 0000000..dabb852 --- /dev/null +++ b/appleApp/store/sv-SE/privacy-url.txt @@ -0,0 +1 @@ +https://ftw.energy/sv/privacy diff --git a/appleApp/store/sv-SE/promotional-text.txt b/appleApp/store/sv-SE/promotional-text.txt new file mode 100644 index 0000000..9d31321 --- /dev/null +++ b/appleApp/store/sv-SE/promotional-text.txt @@ -0,0 +1 @@ +Se det din FTW-box ser, med åldern på varje värde. Ange bilens nivå och mål, eller ladda nu. Elpriset på hemskärmen och låsskärmen. diff --git a/appleApp/store/sv-SE/release-notes.txt b/appleApp/store/sv-SE/release-notes.txt new file mode 100644 index 0000000..cb6ef2e --- /dev/null +++ b/appleApp/store/sv-SE/release-notes.txt @@ -0,0 +1 @@ +Den första FTW-appen för iPhone. Parkoppla med din box, se hemmets energi just nu, styr bilens laddning och lägg elpriset på hemskärmen och låsskärmen. diff --git a/appleApp/store/sv-SE/subtitle.txt b/appleApp/store/sv-SE/subtitle.txt new file mode 100644 index 0000000..495aeda --- /dev/null +++ b/appleApp/store/sv-SE/subtitle.txt @@ -0,0 +1 @@ +Hemmets energi i telefonen diff --git a/appleApp/store/sv-SE/support-url.txt b/appleApp/store/sv-SE/support-url.txt new file mode 100644 index 0000000..7b6156c --- /dev/null +++ b/appleApp/store/sv-SE/support-url.txt @@ -0,0 +1 @@ +https://ftw.energy/sv/support From f8b75d3a58c4bdcc5585190c910eda0a94fa9ca9 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:46:22 +0200 Subject: [PATCH 17/25] Make the car view and Now fast, native and timer-free Nothing asks the box on a timer any more. The chargers are read when a screen that shows them appears, after a command, when the stream says the car started or stopped drawing, and when the stream comes back. The plan's shortfall is read when the car view opens and when the car's plan changes. "Is FTW in control?" now comes from lane 0 alone (the dispatch block and each source's state), so Now no longer polls /api/status. A cold start paints the car from the App Group cache the app writes for its widgets, dated and marked as not live, before the box answers. While nothing at all is known the screens show their shape, not a spinner. LaunchClock logs process start to first frame and first content. A tapped level shows as the chosen chip and the receipt; the number only moves once the box confirms it. Goals send time_of_day_min and today's time_of_day_min_utc, built from nothing read; tests cover a Core with srcfl/ftw#1586 and one without. The car view is a system List with system buttons, haptics on commands and outcomes, symbol effects and numeric transitions. The Now car row zooms into it. Now is split into small views that redraw only what a frame changed. Theme colours move to an asset catalog, so no UIKit is left outside the camera. CLAUDE.md gains "Feel and speed". Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 37 ++ appleApp/FTW/Car/CarView.swift | 482 ++++++++++++------ appleApp/FTW/UI/FlowDiagram.swift | 10 +- appleApp/FTW/UI/LaunchClock.swift | 36 ++ appleApp/FTW/UI/NowView.swift | 285 ++++++----- appleApp/FTW/UI/PriceChart.swift | 2 +- appleApp/FTW/UI/RootView.swift | 26 +- appleApp/FTW/UI/Theme.swift | 46 +- appleApp/FTW/UI/Theme.xcassets/Contents.json | 6 + .../ftw.accent.colorset/Contents.json | 38 ++ .../ftw.exporting.colorset/Contents.json | 38 ++ .../ftw.fg.colorset/Contents.json | 38 ++ .../ftw.fgDim.colorset/Contents.json | 38 ++ .../ftw.fgMuted.colorset/Contents.json | 38 ++ .../ftw.generation.colorset/Contents.json | 38 ++ .../ftw.importing.colorset/Contents.json | 38 ++ .../ftw.line.colorset/Contents.json | 38 ++ .../ftw.mobility.colorset/Contents.json | 38 ++ .../ftw.onAccent.colorset/Contents.json | 38 ++ .../ftw.storage.colorset/Contents.json | 38 ++ .../ftw.surface.colorset/Contents.json | 38 ++ .../Contents.json | 38 ++ .../ftw.surfaceRaised.colorset/Contents.json | 38 ++ .../ftw.surfaceSunken.colorset/Contents.json | 38 ++ .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 28 +- .../FTWKit/Sources/FTWKit/Format/EV.swift | 32 +- .../FTWKit/State/LoadpointsModel.swift | 78 ++- .../Sources/FTWKit/State/NowModels.swift | 86 +--- .../FTWKit/Tests/FTWKitTests/CarTests.swift | 165 ++++-- 29 files changed, 1424 insertions(+), 465 deletions(-) create mode 100644 appleApp/FTW/UI/LaunchClock.swift create mode 100644 appleApp/FTW/UI/Theme.xcassets/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json diff --git a/CLAUDE.md b/CLAUDE.md index 15f07f7..5942a40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,43 @@ The protocol, the QR, the relay and the identity model are specified in - Errors say what happens now, not what broke inside. - Least code that does the whole job. +## Feel and speed + +Fredrik's bar for the iPhone app (2026-10-12): modern, very fast, truly +native, and smooth enough to impress. + +- **First frame from the cache.** No network wait and no spinner before + it. While nothing is known, show the shape of the screen + (`.redacted(reason: .placeholder)`), not a spinner. `LaunchClock` logs + the time from process start to the first frame and the first content; + quote the numbers in any PR that touches launch. +- **The handshake runs beside the first frame, never ahead of it.** +- **No timer loops.** Lane 0 drives Now. Until the box streams charger + state (M5, or FTW Cloud C3), the chargers are read when a screen that + shows them appears, after a command, and when the stream says the car + started or stopped drawing. The plan's shortfall is read when the car + view opens and when the car's plan changes. +- **A frame redraws what moved.** Small views that read only what they + show, value inputs, and `Equatable` with `.equatable()` where a frame + would otherwise redraw a whole section. A clock on screen ticks by the + minute unless the second matters. +- **Modern Swift.** Swift 6 strict concurrency, `@Observable`, + async/await and structured concurrency. No Combine, no GCD, no + `ObservableObject`, and no UIKit except the camera. Colours live in + asset catalogs, not `UIColor`. +- **iOS 26's design.** System components (`List`, `Form`, + `NavigationStack`, system button styles), so Liquid Glass appears where + the system puts it. SF Symbols with symbol effects. +- **Feel.** Numbers change with `.contentTransition(.numericText())`. + Motion uses one spring, `Theme.spring`. The car row on Now zooms into + the car view (`navigationTransition(.zoom)`). `.sensoryFeedback` marks + commands and their outcomes. +- **A command answers at once.** Tap, haptic and "Sent" in under 100 ms, + then the box's receipt (`cmd.ack`), then the outcome (`cmd.result`). + Show the intent at once; never show a result the box has not confirmed. +- **The largest text size and dark mode** look as good as the default. + Review all three in the simulator. + ## Non-negotiable invariants - **Never fake live.** Every reading carries its age. diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift index 9c0c501..720f85e 100644 --- a/appleApp/FTW/Car/CarView.swift +++ b/appleApp/FTW/Car/CarView.swift @@ -3,49 +3,50 @@ import FTWKit import SwiftUI /// The car: how full it is and who says so, the goal and whether it makes -/// it, and Charge now, Pause and Back to the plan. Every tap is followed to -/// the box's answer, on screen. +/// it, and Charge now, Pause and Back to the plan. Every tap answers at +/// once (a haptic and "Sent"), then the box's receipt, then the outcome; +/// the screen shows intent at once and a result only once the box has +/// confirmed it. struct CarView: View { let app: AppModel let home: HomeModels let id: String? - @State private var correcting = false @State private var editingGoal = false private var model: LoadpointsModel { home.loadpoints } - private var site: SiteModel { home.site } - private var canControl: Bool { site.canConfigure } private var lp: Loadpoint? { id.flatMap { want in model.points.first { $0.id == want } } ?? model.points.first } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - if let lp { - status(lp) - level(lp) - goal(lp) - if lp.pluggedIn, canControl { controls(lp) } - if app.isDemo { demo(lp) } - } else if model.loaded { - Hint(L("This box has no charger.")) - } else { - ProgressView().frame(maxWidth: .infinity) + List { + if let lp { + StatusSection(lp: lp, model: model, site: home.site) + LevelSection(lp: lp, model: model, canControl: home.site.canConfigure) + GoalSection(lp: lp, model: model, site: home.site, canControl: home.site.canConfigure, editing: $editingGoal) + if lp.pluggedIn, home.site.canConfigure { + ControlSection(lp: lp, model: model) } + if app.isDemo { DemoSection(lp: lp, model: model) } + } else if model.loaded { + Section { Text(L("This box has no charger.")) } + .listRowBackground(Theme.surfaceRaised) + } else { + PlaceholderSections() } - .padding(16) - .frame(maxWidth: 640, alignment: .leading) - .frame(maxWidth: .infinity) } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) .background(Theme.surface) + .animation(Theme.spring, value: model.points) .navigationTitle(L("Car")) .navigationBarTitleDisplayMode(.inline) .refreshable { try? await model.loadChargers() } .onAppear { model.activate() model.watchPlan() + LaunchClock.mark("first content") } .onDisappear { model.unwatchPlan() @@ -55,46 +56,79 @@ struct CarView: View { if let lp { GoalEditor(model: model, lp: lp) } } } +} - // MARK: What it does now +// MARK: What it does now - @ViewBuilder private func status(_ lp: Loadpoint) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(EVText.status(lp, zone: model.zone)) - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - if let session = EVText.session(lp) { Hint(session) } - if model.stale { - Hint(model.error ?? model.readAtMs.map { AppWords.format("Last known %@.", AppWords.clock($0, zone: model.zone)) } ?? L("Waiting for your box."), tone: Theme.generation) +private struct StatusSection: View { + let lp: Loadpoint + let model: LoadpointsModel + let site: SiteModel + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 4) { + Text(EVText.status(lp, zone: model.zone)) + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.fg) + .contentTransition(.numericText()) + .fixedSize(horizontal: false, vertical: true) + if let session = EVText.session(lp) { + Hint(session).contentTransition(.numericText()) + } + if let note { + Hint(note.text, tone: note.warn ? Theme.generation : Theme.fgMuted) + } } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) } - .accessibilityElement(children: .combine) + .listRowBackground(Theme.surfaceRaised) } - // MARK: How full is it? + /// When the box said it: plainly when out of date, quietly when it is + /// only a while ago. The chargers are not read on a timer. + private var note: (text: String, warn: Bool)? { + guard let at = model.readAtMs else { return model.stale ? (L("Waiting for your box."), true) : nil } + if model.stale { return (model.error ?? AppWords.format("Last known %@.", AppWords.clock(at, zone: model.zone)), true) } + if site.nowMs - at > 60_000 { return (AppWords.format("Read at %@", AppWords.clock(at, zone: model.zone)), false) } + return nil + } +} - @ViewBuilder private func level(_ lp: Loadpoint) -> some View { +// MARK: How full is it? + +private struct LevelSection: View { + let lp: Loadpoint + let model: LoadpointsModel + let canControl: Bool + @State private var correcting = false + + var body: some View { let level = model.level(lp) let assumed = level?.origin == .assumed || level?.origin == .unconfirmed - Card { - Kicker(L("How full is the car?")) + Section { if let level { - ViewThatFits(in: .horizontal) { - HStack(alignment: .firstTextBaseline, spacing: 10) { levelFigure(level, assumed: assumed) } - VStack(alignment: .leading, spacing: 6) { levelFigure(level, assumed: assumed) } + VStack(alignment: .leading, spacing: 6) { + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 10) { figure(level, assumed: assumed) } + VStack(alignment: .leading, spacing: 6) { figure(level, assumed: assumed) } + } + // The badge says FTW assumes; the line says what to do. + Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) + .font(.subheadline) + .foregroundStyle(assumed ? Theme.fg : Theme.fgDim) + .fixedSize(horizontal: false, vertical: true) } - // The badge says FTW assumes; the line says what to do. - Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) - .font(.subheadline) - .foregroundStyle(assumed ? Theme.fg : Theme.fgDim) - .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 4) + .accessibilityElement(children: .combine) if canControl { if level.origin == .car, !correcting { - Button(L("Correct the level")) { correcting = true }.buttonStyle(.quiet) - Hint(L("Only correct it if the car shows something else.")) + Button(L("Correct the level")) { + withAnimation(Theme.spring) { correcting = true } + } } else { - LevelChips(current: level.pct) { pct in + LevelChips(current: model.chosenLevel(lp) ?? level.pct) { pct in Task { await model.setLevel(lp, pct: pct) } } } @@ -103,28 +137,30 @@ struct CarView: View { Text(L("Plug in the car to tell FTW its level.")) .font(.subheadline) .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) } else { Text(L("Level unknown")).font(.headline) } if let outcome = model.outcome(level: lp) { ReceiptView(outcome: outcome) } - } - .overlay(alignment: .leading) { - if assumed { - UnevenRoundedRectangle(topLeadingRadius: Theme.radius, bottomLeadingRadius: Theme.radius) - .fill(Theme.generation) - .frame(width: 4) + } header: { + Text(L("How full is the car?")) + } footer: { + if level?.origin == .car, !correcting, canControl { + Text(L("Only correct it if the car shows something else.")) } } + .listRowBackground(Theme.surfaceRaised) + .sensoryFeedback(.selection, trigger: model.chosenLevel(lp)) + .sensoryFeedback(trigger: model.outcome(level: lp)?.tone) { _, tone in Self.feedback(tone) } } - @ViewBuilder private func levelFigure(_ level: EVText.Level, assumed: Bool) -> some View { + @ViewBuilder private func figure(_ level: EVText.Level, assumed: Bool) -> some View { Text(AppWords.percent(level.pct)) .font(Theme.percent(.largeTitle, weight: .bold)) .foregroundStyle(assumed ? Theme.fgMuted : Theme.fg) - .contentTransition(.numericText()) + .contentTransition(.numericText(value: level.pct)) + .animation(Theme.spring, value: level.pct) if assumed { Text(L("FTW assumes")) .font(.caption.weight(.semibold)) @@ -136,29 +172,101 @@ struct CarView: View { } } - // MARK: The goal + static func feedback(_ tone: EVText.Outcome.Tone?) -> SensoryFeedback? { + switch tone { + case .done?: return .success + case .warning?: return .warning + case .failed?: return .error + default: return nil + } + } +} + +/// 20, 40, 60, 80 and a step of five either way. Each tap goes to the box +/// at once; there is no Save. +struct LevelChips: View { + let current: Double + let choose: (Double) -> Void + @Environment(\.dynamicTypeSize) private var textSize - @ViewBuilder private func goal(_ lp: Loadpoint) -> some View { - Card { - Kicker(L("Goal")) - if let s = lp.schedule { - Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: model.zone)) - .font(.headline) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - if let next = EVText.nextGoal(lp, nowMs: site.nowMs, zone: model.zone) { - Hint(next) + var body: some View { + VStack(spacing: 8) { + // Four across, two at the accessibility sizes: a chip never wraps. + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: textSize.isAccessibilitySize ? 2 : 4), spacing: 8) { + ForEach(EVText.levelPresets, id: \.self) { pct in + chip(AppWords.percent(pct), selected: current.rounded() == pct, label: AppWords.percent(pct)) { choose(pct) } } - if lp.pluggedIn { - if let r = model.readiness(lp) { - Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.nowMs, zone: model.zone)) - } - if let plan = EVText.plan(lp, windows: model.windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: model.zone) { - Hint(plan) + } + HStack(spacing: 8) { + let down = EVText.stepped(current, up: false) + let up = EVText.stepped(current, up: true) + chip("−5", selected: false, label: AppWords.format("Set %@", AppWords.percent(down))) { choose(down) } + chip("+5", selected: false, label: AppWords.format("Set %@", AppWords.percent(up))) { choose(up) } + } + } + .padding(.vertical, 4) + .animation(Theme.spring, value: current) + } + + @ViewBuilder private func chip(_ text: String, selected: Bool, label: String, action: @escaping () -> Void) -> some View { + let title = Text(text) + .font(Theme.percent(.body)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + Group { + if selected { + Button(action: action) { title.foregroundStyle(Theme.onAccent) } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + } else { + Button(action: action) { title.foregroundStyle(Theme.fg) } + .buttonStyle(.bordered) + .tint(Theme.fgMuted) + } + } + .buttonBorderShape(.roundedRectangle(radius: Theme.radiusSmall)) + .accessibilityLabel(label) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} + +// MARK: The goal + +private struct GoalSection: View { + let lp: Loadpoint + let model: LoadpointsModel + let site: SiteModel + let canControl: Bool + @Binding var editing: Bool + + var body: some View { + Section { + if model.fromCache { + // The cache keeps no goal; say nothing rather than "none". + Text(EVText.suggestedGoalText()) + .font(.headline) + .redacted(reason: .placeholder) + } else if let s = lp.schedule { + VStack(alignment: .leading, spacing: 4) { + Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: model.zone)) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let next = EVText.nextGoal(lp, nowMs: site.nowMs, zone: model.zone) { + Hint(next) } } + .padding(.vertical, 2) + if lp.pluggedIn, let r = model.readiness(lp) { + Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.nowMs, zone: model.zone)) + } + if lp.pluggedIn, let plan = EVText.plan(lp, windows: model.windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: model.zone) { + Hint(plan) + } if canControl { - Button(L("Change")) { editingGoal = true }.buttonStyle(.outline) + Button(L("Change")) { editing = true } } } else { Text(L("No goal yet. Without one, FTW only charges from spare solar.")) @@ -166,120 +274,153 @@ struct CarView: View { .foregroundStyle(Theme.fg) .fixedSize(horizontal: false, vertical: true) if canControl { - Button(AppWords.format("Use %@", EVText.suggestedGoalText())) { + Button { let g = EVText.suggestedGoal Task { await model.saveGoal(lp, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days, zone: model.zone) } + } label: { + Text(AppWords.format("Use %@", EVText.suggestedGoalText())) + .frame(maxWidth: .infinity) + .foregroundStyle(Theme.onAccent) } - .buttonStyle(.primary) - Button(L("Change")) { editingGoal = true }.buttonStyle(.quiet) + .buttonStyle(.borderedProminent) + .controlSize(.large) + Button(L("Change")) { editing = true } } } - if let save = model.goalSave[lp.id], !editingGoal { + if let save = model.goalSave[lp.id], !editing { Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.importing }; return Theme.fgDim }()) } + } header: { + Text(L("Goal")) + } + .listRowBackground(Theme.surfaceRaised) + .sensoryFeedback(trigger: model.goalSave[lp.id]) { _, save in + switch save { + case .saved?, .removed?: return .success + case .failed?: return .error + default: return nil + } + } + } +} + +/// Whether the car makes its goal, in the plan's own numbers. +struct Readiness: View { + let readiness: EVText.Readiness + let text: String + + var body: some View { + Label { + Text(text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") + .foregroundStyle(short ? Theme.generation : Theme.exporting) + .contentTransition(.symbolEffect(.replace)) } + .accessibilityElement(children: .combine) } - // MARK: Charge now, Pause, Back to the plan + private var short: Bool { + if case .short = readiness { return true } + return false + } +} + +// MARK: Charge now, Pause, Back to the plan + +private struct ControlSection: View { + let lp: Loadpoint + let model: LoadpointsModel + @State private var taps = 0 - @ViewBuilder private func controls(_ lp: Loadpoint) -> some View { + var body: some View { let paused = EVText.isPaused(lp) let holding = lp.manualActive && !paused let busy = model.holdSending(lp) - Card { - if holding || paused { - Button(L("Back to the plan")) { Task { await model.backToPlan(lp) } } - .buttonStyle(.primary) - .disabled(busy) - } - if !holding { - Button(L("Charge now")) { Task { await model.chargeNow(lp) } } - .buttonStyle(paused ? .secondary : .primary) - .disabled(busy) - } - if !paused { - Button(L("Pause")) { Task { await model.pause(lp) } } - .buttonStyle(.secondary) - .disabled(busy) + Section { + VStack(spacing: 10) { + if holding || paused { + action(L("Back to the plan"), symbol: "calendar.badge.clock", prominent: true, busy: busy) { await model.backToPlan(lp) } + } + if !holding { + action(L("Charge now"), symbol: "bolt.fill", prominent: !paused, busy: busy) { await model.chargeNow(lp) } + } + if !paused { + action(L("Pause"), symbol: "pause.fill", prominent: false, busy: busy) { await model.pause(lp) } + } } - Hint(L("Charge now runs until you go back to the plan or unplug.")) + .padding(.vertical, 6) if let outcome = model.outcome(hold: lp) { ReceiptView(outcome: outcome) } + } footer: { + Text(L("Charge now runs until you go back to the plan or unplug.")) } + .listRowBackground(Theme.surfaceRaised) + .sensoryFeedback(.impact(weight: .medium), trigger: taps) + .sensoryFeedback(trigger: model.outcome(hold: lp)?.tone) { _, tone in LevelSection.feedback(tone) } } - // MARK: The demo's own cable - - @ViewBuilder private func demo(_ lp: Loadpoint) -> some View { - Card { - Kicker(L("Demo")) - Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { - SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) - model.reread() + @ViewBuilder private func action(_ title: String, symbol: String, prominent: Bool, busy: Bool, _ run: @escaping () async -> Void) -> some View { + let label = Label(title, systemImage: symbol) + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + Group { + if prominent { + Button { + taps += 1 + Task { await run() } + } label: { label.foregroundStyle(Theme.onAccent) } + .buttonStyle(.borderedProminent) + } else { + Button { + taps += 1 + Task { await run() } + } label: { label.foregroundStyle(Theme.fg) } + .buttonStyle(.bordered) + .tint(Theme.fgMuted) } - .buttonStyle(.outline) - Hint(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) } + .controlSize(.large) + .disabled(busy) } } -/// 20, 40, 60, 80 and a step of five either way. Each tap goes to the box -/// at once; there is no Save. -struct LevelChips: View { - let current: Double - let choose: (Double) -> Void +// MARK: The demo's own cable + +private struct DemoSection: View { + let lp: Loadpoint + let model: LoadpointsModel var body: some View { - VStack(alignment: .leading, spacing: 8) { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 68), spacing: 8)], spacing: 8) { - ForEach(EVText.levelPresets, id: \.self) { pct in - chip(AppWords.percent(pct), selected: current.rounded() == pct, label: AppWords.percent(pct)) { choose(pct) } - } - } - HStack(spacing: 8) { - chip("−5", selected: false, label: AppWords.format("Set %@", AppWords.percent(EVText.stepped(current, up: false)))) { choose(EVText.stepped(current, up: false)) } - chip("+5", selected: false, label: AppWords.format("Set %@", AppWords.percent(EVText.stepped(current, up: true)))) { choose(EVText.stepped(current, up: true)) } + Section { + Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { + SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) + model.reread() } + } header: { + Text(L("Demo")) + } footer: { + Text(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) } - } - - private func chip(_ text: String, selected: Bool, label: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(text) - .font(Theme.percent(.body)) - .frame(maxWidth: .infinity, minHeight: 44) - .foregroundStyle(selected ? Theme.onAccent : Theme.fg) - .background(selected ? Theme.accent : Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) - .overlay(RoundedRectangle(cornerRadius: Theme.radiusSmall).strokeBorder(Theme.line, lineWidth: selected ? 0 : 1)) - } - .buttonStyle(.plain) - .accessibilityLabel(label) - .accessibilityAddTraits(selected ? .isSelected : []) + .listRowBackground(Theme.surfaceRaised) } } -/// Whether the car makes its goal, in the plan's own numbers. -struct Readiness: View { - let readiness: EVText.Readiness - let text: String - +/// Before the first answer and with nothing cached: the shape of the +/// screen, not a spinner. +private struct PlaceholderSections: View { var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") - .foregroundStyle(short ? Theme.generation : Theme.exporting) - .accessibilityHidden(true) - Text(text) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) + Group { + Section { Text("Plugged in · charges at 01:00").font(.title3.weight(.semibold)) } + Section { Text("20 %").font(Theme.percent(.largeTitle, weight: .bold)) } + Section { Text("80 % by 07:00 on weekdays").font(.headline) } } - .accessibilityElement(children: .combine) - } - - private var short: Bool { - if case .short = readiness { return true } - return false + .listRowBackground(Theme.surfaceRaised) + .redacted(reason: .placeholder) } } @@ -290,19 +431,18 @@ struct ReceiptView: View { var body: some View { VStack(alignment: .leading, spacing: 6) { ViewThatFits(in: .horizontal) { - HStack(spacing: 10) { marks } + HStack(spacing: 12) { marks } VStack(alignment: .leading, spacing: 4) { marks } } Text(outcome.text) .font(.footnote.weight(.medium)) .foregroundStyle(color) + .contentTransition(.opacity) .fixedSize(horizontal: false, vertical: true) } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .padding(.vertical, 4) + .animation(Theme.spring, value: outcome) .accessibilityElement(children: .combine) - .animation(.default, value: outcome) } @ViewBuilder private var marks: some View { @@ -313,18 +453,34 @@ struct ReceiptView: View { private func mark(_ label: String, _ state: EVText.Outcome.Mark) -> some View { HStack(spacing: 4) { - switch state { - case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(Theme.exporting) - case .active: ProgressView().controlSize(.mini) - case .waiting: Image(systemName: "circle").foregroundStyle(Theme.fgMuted) - case .failed: Image(systemName: "xmark.circle.fill").foregroundStyle(Theme.importing) - } + Image(systemName: symbol(state)) + .foregroundStyle(tint(state)) + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.pulse, options: .repeating, isActive: state == .active) Text(label) .font(.caption.weight(.semibold)) .foregroundStyle(state == .waiting ? Theme.fgMuted : Theme.fg) } } + private func symbol(_ state: EVText.Outcome.Mark) -> String { + switch state { + case .done: return "checkmark.circle.fill" + case .active: return "circle.dotted.circle" + case .waiting: return "circle" + case .failed: return "xmark.circle.fill" + } + } + + private func tint(_ state: EVText.Outcome.Mark) -> Color { + switch state { + case .done: return Theme.exporting + case .active: return Theme.accent + case .waiting: return Theme.fgMuted + case .failed: return Theme.importing + } + } + private var color: Color { switch outcome.tone { case .working: return Theme.fgDim diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift index 36c6882..cd9e8a6 100644 --- a/appleApp/FTW/UI/FlowDiagram.swift +++ b/appleApp/FTW/UI/FlowDiagram.swift @@ -7,10 +7,9 @@ import SwiftUI /// /// Dots move along a line only while readings are live. A cached or quiet /// view holds still, because motion says power is flowing right now. -struct FlowDiagram: View { +struct FlowDiagram: View, Equatable { let readings: Flow.Readings let moving: Bool - let tap: (Flow.Role) -> Void private static let height: CGFloat = 340 @@ -27,12 +26,10 @@ struct FlowDiagram: View { Hub(loadKw: readings.loadKw, selfPoweredPct: readings.selfPoweredPctToday) .position(layout.hub) - .onTapGesture { tap(.load) } ForEach(readings.nodes) { node in Bubble(node: node) .position(layout.points[node.id] ?? layout.hub) - .onTapGesture { if node.tappable { tap(node.role) } } } } } @@ -105,6 +102,8 @@ private struct Hub: View { .foregroundStyle(Theme.accent) Text("\(Text(AppWords.decimal(parts.text)).font(Theme.number(22, weight: .bold)))\(Text(" \(parts.unit)").font(.caption))") .foregroundStyle(Theme.fg) + .contentTransition(.numericText(value: loadKw)) + .animation(Theme.spring, value: loadKw) Text(L("HOME")).font(.caption2.weight(.semibold)).tracking(0.8).foregroundStyle(Theme.fgDim) if let pct = selfPoweredPct { Text(AppWords.format("%@ self-powered today", AppWords.percent(pct))) @@ -139,6 +138,8 @@ private struct Bubble: View { } Text("\(Text(AppWords.decimal(parts.text)).font(Theme.number(17, weight: .bold)))\(Text(" \(parts.unit)").font(.caption2))") .foregroundStyle(color) + .contentTransition(.numericText(value: node.kw)) + .animation(Theme.spring, value: node.kw) if let soc = node.socPct { SocBar(pct: soc) } @@ -158,7 +159,6 @@ private struct Bubble: View { .background(RoundedRectangle(cornerRadius: 16).fill(Theme.surfaceRaised)) .overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(color.opacity(0.5), lineWidth: 1.5)) .accessibilityElement(children: .combine) - .accessibilityAddTraits(node.tappable ? .isButton : []) } } diff --git a/appleApp/FTW/UI/LaunchClock.swift b/appleApp/FTW/UI/LaunchClock.swift new file mode 100644 index 0000000..27ef7a0 --- /dev/null +++ b/appleApp/FTW/UI/LaunchClock.swift @@ -0,0 +1,36 @@ +import Darwin +import os + +/// How long from the process starting to each first thing on screen, +/// logged once each and marked for Instruments. Read them with: +/// +/// xcrun simctl spawn log show --last 2m \ +/// --predicate 'subsystem == "energy.ftw.app" AND category == "launch"' +@MainActor +enum LaunchClock { + static let log = Logger(subsystem: "energy.ftw.app", category: "launch") + static let signposter = OSSignposter(logger: log) + private static var seen: Set = [] + + /// Once per event: the first frame, the first content, the stream. + static func mark(_ event: StaticString) { + let name = "\(event)" + guard seen.insert(name).inserted else { return } + signposter.emitEvent(event) + if let ms = sinceProcessStartMs() { + log.notice("\(name, privacy: .public) \(Int(ms.rounded()), privacy: .public) ms after process start") + } + } + + /// The kernel's record of when this process started. + static func sinceProcessStartMs() -> Double? { + var info = kinfo_proc() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { return nil } + let start = info.kp_proc.p_starttime + var now = timeval() + gettimeofday(&now, nil) + return Double(now.tv_sec - start.tv_sec) * 1000 + Double(now.tv_usec - start.tv_usec) / 1000 + } +} diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index c80442a..035e2a1 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -3,43 +3,56 @@ import SwiftUI /// Now, the first screen, kept to what is looked at every day: whether FTW /// is in control, the house, the car and the price. +/// +/// Lane 0 drives it. Nothing here asks the box on a timer: the readings +/// arrive on the stream, the price when the day or the published window +/// turns, and the chargers when the screen appears. Each part is its own +/// small view reading only what it shows, so a frame redraws the numbers +/// that moved and nothing else. struct NowView: View { let app: AppModel let home: HomeModels - let openCar: (String) -> Void + let zoom: Namespace.ID + + var body: some View { + NowBody(app: app, home: home, zoom: zoom) + .onAppear { home.prices.activate() } + .onDisappear { home.prices.deactivate() } + } +} + +private struct NowBody: View { + let app: AppModel + let home: HomeModels + let zoom: Namespace.ID private var site: SiteModel { home.site } var body: some View { - Group { - switch site.session.phase { - case .booting: - Title(L("Your box is starting")) - Text(L("This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.")) - if let boot = site.session.boot { - Hint("\(Self.bootWords(boot.phase)) · \(boot.pct)%") - } - case .terminated: - Title(L("Access ended")) - Text(site.session.terminated == .revoked ? L("Your access to this home was withdrawn by its owner.") : L("This session ended.")) - if site.session.terminated == .revoked { - Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) - } - default: - if site.session.fields.isEmpty { - nothingYet - } else { - house - } + switch site.session.phase { + case .booting: + Title(L("Your box is starting")) + Text(L("This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.")) + if let boot = site.session.boot { + Hint("\(boot.pct)%") + } + case .terminated: + Title(L("Access ended")) + Text(site.session.terminated == .revoked ? L("Your access to this home was withdrawn by its owner.") : L("This session ended.")) + if site.session.terminated == .revoked { + Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) + } + default: + if !site.session.fields.isEmpty { + house + } else if app.isDemo { + // The demo's first reading is a moment away: the shape of the + // screen, not a sentence about waiting. + InControlRow(answer: InControl.Answer(tone: .neutral, title: L("Starting the demo"), detail: L("Live demo · simulated home"))) + .redacted(reason: .placeholder) + } else { + nothingYet } - } - .onAppear { - home.status.activate() - home.prices.activate() - } - .onDisappear { - home.status.deactivate() - home.prices.deactivate() } } @@ -55,27 +68,6 @@ struct NowView: View { } } - // MARK: The house - - private var live: Bool { site.isLive } - - private var chargeW: Double { - home.loadpoints.stale ? 0 : Flow.loadpointChargeW(home.loadpoints.points) - } - - private var watchingStatus: Bool { - site.documentVisible && site.session.phase == .streaming && site.hasPassthrough - } - - private var statusLive: Bool { home.status.fresh && live && watchingStatus } - - private var readings: Flow.Readings { - if let status = home.status.status, statusLive || !live { - return Flow.readings(status: status) - } - return Flow.readings(fields: Flow.withLoadpointEV(site.session.fields, evW: chargeW)) - } - @ViewBuilder private var house: some View { if let help = app.connectHelp { Card { @@ -86,51 +78,35 @@ struct NowView: View { if site.session.needsUpdate { Card { Text(L("This app is older than your box. Some things are hidden until it updates.")) } } - InControlRow(answer: InControl.answer( - live: live, - lastKnownMs: site.cachedAtMs ?? site.lastFrameAtMs, - dispatchBlockedBy: site.session.dispatchBlockedBy, - status: home.status.status, - statusFresh: statusLive - )) - - // Motion claims power is flowing right now, so a cached view holds still. - FlowDiagram(readings: readings, moving: live) { _ in } - if home.status.status != nil, !statusLive { - Hint(L(live ? "Device details are out of date. Showing live totals." : "Device details are out of date.")) + InControlSection(site: site) + FlowSection(site: site, loadpoints: home.loadpoints) + if site.heardFromBox || home.loadpoints.fromCache { + CarSection(loadpoints: home.loadpoints, zoom: zoom) } + PriceSection(site: site, prices: home.prices) + } +} - if site.heardFromBox { - ForEach(home.loadpoints.points) { lp in - CarRow(lp: lp, loadpoints: home.loadpoints) { openCar(lp.id) } - } - } +// MARK: Is FTW in control? - if let prices = home.prices.prices { - Card { - PriceChart(prices: prices, nowMs: site.nowMs, compact: true) - if home.prices.hasHole { - Hint(L("Some hours are missing their price.")) - } else if prices.stale { - Hint(L("Tomorrow's rates aren't published yet.")) - } - } - } - } +private struct InControlSection: View { + let site: SiteModel - static func bootWords(_ phase: String) -> String { - switch phase { - case "vacuum": return "tidying its records" - case "migrate": return "bringing its records up to date" - case "drivers": return "waking the equipment" - default: return "getting ready" - } + var body: some View { + InControlRow(answer: InControl.answer( + streaming: site.isLive, + lastKnownMs: site.cachedAtMs ?? site.lastFrameAtMs, + dispatchBlockedBy: site.session.dispatchBlockedBy, + sources: site.session.sources + )) + .equatable() + .onAppear { LaunchClock.mark("first content") } } } -/// "Is FTW in control?" in one calm line. Green only when Core says yes -/// over a live stream; a cache is grey and dated. -struct InControlRow: View { +/// "Is FTW in control?" in one calm line. Green only while frames arrive +/// and the box says nothing blocks it; a cache is grey and dated. +struct InControlRow: View, Equatable { let answer: InControl.Answer var body: some View { @@ -138,6 +114,8 @@ struct InControlRow: View { Image(systemName: symbol) .font(.body.weight(.semibold)) .foregroundStyle(color) + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.bounce, value: answer.tone) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Kicker(L("Is FTW in control?")) @@ -161,6 +139,7 @@ struct InControlRow: View { .fill(color) .frame(width: 4) } + .animation(Theme.spring, value: answer) .accessibilityElement(children: .combine) } @@ -168,7 +147,6 @@ struct InControlRow: View { switch answer.tone { case .ok: return "checkmark.circle.fill" case .warning: return "exclamationmark.triangle.fill" - case .alarm: return "exclamationmark.octagon.fill" case .neutral: return "circle.dotted" case .stale: return "clock" } @@ -178,51 +156,85 @@ struct InControlRow: View { switch answer.tone { case .ok: return Theme.exporting case .warning: return Theme.generation - case .alarm: return Theme.importing case .neutral, .stale: return Theme.fgMuted } } } +// MARK: The house + +private struct FlowSection: View { + let site: SiteModel + let loadpoints: LoadpointsModel + + var body: some View { + // The stream carries the car's watts; a box that leaves them out + // gets the charger's last answer. + let evW = loadpoints.stale ? 0 : Flow.loadpointChargeW(loadpoints.points) + // Motion claims power is flowing right now, so a cached view holds still. + FlowDiagram(readings: Flow.readings(fields: Flow.withLoadpointEV(site.session.fields, evW: evW)), moving: site.isLive) + .equatable() + } +} + +// MARK: The car + +private struct CarSection: View { + let loadpoints: LoadpointsModel + let zoom: Namespace.ID + + var body: some View { + ForEach(loadpoints.points) { lp in + NavigationLink(value: HomeRoute.car(lp.id)) { + CarRow(lp: lp, loadpoints: loadpoints) + } + .buttonStyle(.plain) + .matchedTransitionSource(id: lp.id, in: zoom) + } + } +} + /// The car on Now: one row that opens the car view. struct CarRow: View { + static let firstID = "car" + let lp: Loadpoint let loadpoints: LoadpointsModel - let open: () -> Void var body: some View { - Button(action: open) { - HStack(spacing: 12) { - Image(systemName: lp.pluggedIn ? "bolt.car.fill" : "car.side") - .font(.title3) - .foregroundStyle(Theme.mobility) - .frame(width: 32) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - // One text, so it wraps as one line at large sizes. - heading - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - Text(line) - .font(.subheadline) - .foregroundStyle(Theme.fgDim) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 4) - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(Theme.fgMuted) - .accessibilityHidden(true) + HStack(spacing: 12) { + Image(systemName: lp.pluggedIn ? "bolt.car.fill" : "car.side") + .font(.title3) + .foregroundStyle(Theme.mobility) + .symbolEffect(.pulse, options: .repeating, isActive: lp.powerW >= 100 && !loadpoints.stale) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 32) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + // One text, so it wraps as one line at large sizes. + heading + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + Text(line) + .font(.subheadline) + .foregroundStyle(Theme.fgDim) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentTransition(.numericText()) } - .padding(.vertical, 12) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) - .contentShape(Rectangle()) + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.fgMuted) + .accessibilityHidden(true) } - .buttonStyle(.plain) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + .contentShape(RoundedRectangle(cornerRadius: Theme.radius)) + .animation(Theme.spring, value: lp) .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) } @@ -231,12 +243,13 @@ struct CarRow: View { private var heading: Text { let car = Text(L("Car")).font(.headline) guard let level = loadpoints.level(lp) else { return car } - let pct = Text(AppWords.percent(level.pct)).font(Theme.percent(.headline)).foregroundColor(level.origin == .assumed ? Theme.fgMuted : Theme.fg) - let origin = Text(EVText.originShort(level, zone: loadpoints.zone)).font(.caption).foregroundColor(Theme.fgDim) + let pct = Text(AppWords.percent(level.pct)).font(Theme.percent(.headline)).foregroundStyle(level.origin == .assumed ? Theme.fgMuted : Theme.fg) + let origin = Text(EVText.originShort(level, zone: loadpoints.zone)).font(.caption).foregroundStyle(Theme.fgDim) return Text("\(car) \(pct) \(origin)") } - /// What it does now; unplugged, the goal it will meet next time. + /// What it does now; unplugged, the goal it will meet next time. From + /// the cache or a lost stream, when the box last said so. private var line: String { if loadpoints.stale, let at = loadpoints.readAtMs { return AppWords.format("Last known %@.", AppWords.clock(at, zone: loadpoints.zone)) @@ -247,3 +260,27 @@ struct CarRow: View { return EVText.status(lp, zone: loadpoints.zone) } } + +// MARK: The price + +private struct PriceSection: View { + let site: SiteModel + let prices: PriceModel + + var body: some View { + if let window = prices.prices { + // The chart marks the minute, not the second: it redraws once a + // minute however often the clock ticks. + let minute = (site.nowMs / 60_000).rounded(.down) * 60_000 + Card { + PriceChart(prices: window, nowMs: minute, compact: true) + .equatable() + if prices.hasHole { + Hint(L("Some hours are missing their price.")) + } else if window.stale { + Hint(L("Tomorrow's rates aren't published yet.")) + } + } + } + } +} diff --git a/appleApp/FTW/UI/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift index 34fa97d..fc743c8 100644 --- a/appleApp/FTW/UI/PriceChart.swift +++ b/appleApp/FTW/UI/PriceChart.swift @@ -5,7 +5,7 @@ import SwiftUI /// Prices ahead, as bars from zero. Compact on Now: the price this moment /// and the cheapest two hours. Full on Plan: every slot of today and /// tomorrow with the moment marked. -struct PriceChart: View { +struct PriceChart: View, Equatable { let prices: Prices let nowMs: Double var compact = false diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 7739268..99158c1 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -18,6 +18,7 @@ struct RootView: View { } .background(Theme.surface.ignoresSafeArea()) .tint(Theme.accent) + .onAppear { LaunchClock.mark("first frame") } } } @@ -41,33 +42,48 @@ struct HomeView: View { let app: AppModel let home: HomeModels @State private var path: [HomeRoute] = HomeRoute.initial + /// The car row on Now grows into the car view. + @Namespace private var zoom var body: some View { VStack(spacing: 0) { if app.isDemo { DemoBand(site: home.site) { app.exitDemo() } } else { - FreshnessBandView(band: home.site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: home.site.lastFrameAtMs) + Band(app: app, site: home.site) } NavigationStack(path: $path) { Screen(app: app) { - NowView(app: app, home: home) { path.append(.car($0)) } + NowView(app: app, home: home, zoom: zoom) } .toolbar(.hidden, for: .navigationBar) .navigationDestination(for: HomeRoute.self) { route in switch route { - case .car(let id): CarView(app: app, home: home, id: id) + case .car(let id): + CarView(app: app, home: home, id: id) + .navigationTransition(.zoom(sourceID: id ?? CarRow.firstID, in: zoom)) } } } } - // Now and the car read the chargers from one model, so the box is - // asked once every five seconds, not twice. + // Now and the car read the chargers from one model, so a screen + // appearing asks the box once. .onAppear { home.loadpoints.activate() } .onDisappear { home.loadpoints.deactivate() } } } +/// The freshness band, in its own view so a frame redraws the band and not +/// the screen under it. +private struct Band: View { + let app: AppModel + let site: SiteModel + + var body: some View { + FreshnessBandView(band: site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: site.lastFrameAtMs) + } +} + /// One scrolling page, with pull to refresh. struct Screen: View { let app: AppModel diff --git a/appleApp/FTW/UI/Theme.swift b/appleApp/FTW/UI/Theme.swift index 83c0d91..59d5261 100644 --- a/appleApp/FTW/UI/Theme.swift +++ b/appleApp/FTW/UI/Theme.swift @@ -3,24 +3,25 @@ import SwiftUI /// The web app's design roles (src/styles/tokens.css), light and dark, so the /// native app and the box's own page look like one product. Views read -/// roles, never raw colours. +/// roles, never raw colours. The values live in `Theme.xcassets`, one colour +/// set per role with its dark variant, so the system switches them. enum Theme { - static let surface = pair(0xF4F4F2, 0x0D0D0D) - static let surfaceSunken = pair(0xECECE8, 0x101010) - static let surfaceRaised = pair(0xFAFAF8, 0x161616) - static let surfaceElevated = pair(0xFFFFFF, 0x1E1E1E) - static let line = pair(0xCECEC7, 0x2A2A2A) - static let fg = pair(0x191919, 0xE8E8E8) - static let fgDim = pair(0x4F4F4B, 0xA0A0A0) - static let fgMuted = pair(0x686862, 0x858585) - static let onAccent = pair(0x0A0A0A, 0x0A0A0A) - - static let accent = pair(0xDA7F00, 0xFFAC41) - static let importing = pair(0xCC243D, 0xFF6E74) - static let exporting = pair(0x009639, 0x5FD37F) - static let generation = pair(0xD48500, 0xFFB000) - static let storage = pair(0x008FA8, 0x13DCF6) - static let mobility = pair(0x7F5BB6, 0xCCA8FF) + static let surface = Color("ftw.surface") + static let surfaceSunken = Color("ftw.surfaceSunken") + static let surfaceRaised = Color("ftw.surfaceRaised") + static let surfaceElevated = Color("ftw.surfaceElevated") + static let line = Color("ftw.line") + static let fg = Color("ftw.fg") + static let fgDim = Color("ftw.fgDim") + static let fgMuted = Color("ftw.fgMuted") + static let onAccent = Color("ftw.onAccent") + + static let accent = Color("ftw.accent") + static let importing = Color("ftw.importing") + static let exporting = Color("ftw.exporting") + static let generation = Color("ftw.generation") + static let storage = Color("ftw.storage") + static let mobility = Color("ftw.mobility") static let freshLive = exporting static let freshStale = generation @@ -53,15 +54,8 @@ enum Theme { .system(style, weight: weight).monospacedDigit() } - private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { - return Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) - } -} - -private extension UIColor { - convenience init(rgb: UInt32) { - self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) - } + /// One spring for everything that moves, so the app moves as one thing. + static let spring = Animation.spring(duration: 0.35, bounce: 0.15) } /// A raised card, the web app's `.card`. diff --git a/appleApp/FTW/UI/Theme.xcassets/Contents.json b/appleApp/FTW/UI/Theme.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json new file mode 100644 index 0000000..f2cfe90 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xDA", + "green": "0x7F", + "blue": "0x00" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xAC", + "blue": "0x41" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json new file mode 100644 index 0000000..cfe7820 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x00", + "green": "0x96", + "blue": "0x39" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x5F", + "green": "0xD3", + "blue": "0x7F" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json new file mode 100644 index 0000000..990e793 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x19", + "green": "0x19", + "blue": "0x19" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xE8", + "green": "0xE8", + "blue": "0xE8" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json new file mode 100644 index 0000000..ef51122 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x4F", + "green": "0x4F", + "blue": "0x4B" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xA0", + "green": "0xA0", + "blue": "0xA0" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json new file mode 100644 index 0000000..12c1c27 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x68", + "green": "0x68", + "blue": "0x62" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x85", + "green": "0x85", + "blue": "0x85" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json new file mode 100644 index 0000000..b37c49c --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xD4", + "green": "0x85", + "blue": "0x00" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xB0", + "blue": "0x00" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json new file mode 100644 index 0000000..866f7d5 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xCC", + "green": "0x24", + "blue": "0x3D" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0x6E", + "blue": "0x74" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json new file mode 100644 index 0000000..2776b7d --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xCE", + "green": "0xCE", + "blue": "0xC7" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x2A", + "green": "0x2A", + "blue": "0x2A" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json new file mode 100644 index 0000000..771e0e2 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x7F", + "green": "0x5B", + "blue": "0xB6" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xCC", + "green": "0xA8", + "blue": "0xFF" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json new file mode 100644 index 0000000..d67944e --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x0A", + "green": "0x0A", + "blue": "0x0A" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x0A", + "green": "0x0A", + "blue": "0x0A" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json new file mode 100644 index 0000000..25f9aac --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x00", + "green": "0x8F", + "blue": "0xA8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x13", + "green": "0xDC", + "blue": "0xF6" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json new file mode 100644 index 0000000..b9bc37b --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xF4", + "green": "0xF4", + "blue": "0xF2" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x0D", + "green": "0x0D", + "blue": "0x0D" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json new file mode 100644 index 0000000..8da8c03 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xFF", + "blue": "0xFF" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x1E", + "green": "0x1E", + "blue": "0x1E" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json new file mode 100644 index 0000000..13843e1 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFA", + "green": "0xFA", + "blue": "0xF8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x16", + "green": "0x16", + "blue": "0x16" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json new file mode 100644 index 0000000..3829a78 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xEC", + "green": "0xEC", + "blue": "0xE8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x10", + "green": "0x10", + "blue": "0x10" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index ba0c9e4..a3467df 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -30,10 +30,18 @@ public final class SimulatedBox { /// How long the box takes from ack to result: the demo waits as a /// charger write does, so the receipt has a moment to show. public var resultDelayMs: Double = 0 + /// A Core with srcfl/ftw#1586 reads and sends the ready time as + /// `time_of_day_min` on the household clock. Off, it is a Core that + /// knows only `time_of_day_min_utc`. + public var readsHouseholdClock = true + /// The last schedule body the app sent, as sent. + public private(set) var lastScheduleBody: JSON? /// The box the demo is showing, for the demo's own controls (plug the /// car in or out). Nil outside the demo. public private(set) static weak var current: SimulatedBox? public private(set) var receivedCommands: [String] = [] + /// Every API request, as "METHOD /path", so a test can count reads. + public private(set) var receivedRequests: [String] = [] public private(set) var stepUpsSeen = 0 public nonisolated static let modes: [ModeInfo] = [ @@ -350,6 +358,7 @@ public final class SimulatedBox { var query = [String: String]() for e in body["query"]?.entries ?? [] { if let k = e.key.string, let v = e.value.string { query[k] = v } } let requestBody = body["body"]?.byteString.flatMap { try? JSON(parsing: $0) } + receivedRequests.append("\(method) \(path)") guard path.hasPrefix("/api/"), let route = Self.match(method, path) else { refuse(id, "E_UNKNOWN_OP", []) @@ -393,14 +402,6 @@ public final class SimulatedBox { "easee": ["status": "ok", "ev_w": .number(house.evWatts)], ], "fuse": ["max_amps": .number(house.fuseAmps), "phases": 3, "voltage": 230], - // Core's answer to "Are we in control?", one row per device. - "control_feedback": [ - ["driver": "sungrow", "kind": "battery", "mode": "plan", "status": "following", "reason": "plan", "severity": "info", "evidence": "measured", "readings_fresh": true, "actual_w": .number(house.batteryW)], - ["driver": "easee", "kind": "ev", "mode": house.manualHoldW == nil ? "plan" : "manual", - "status": house.evWatts > 100 || !house.carPluggedIn ? "following" : "waiting", - "reason": .string(!house.carPluggedIn ? "not_connected" : house.manualHoldW != nil ? "manual_hold" : house.evWatts > 100 ? "plan" : "no_plan_budget"), - "severity": "info", "evidence": "measured", "readings_fresh": true, "actual_w": .number(house.evWatts)], - ], "phase_amps": .array((0..<3).map { .number(((perPhase + Double($0) * 90) / 230 * 10).rounded() / 10) }), "phase_powers": .array((0..<3).map { .number((perPhase + Double($0) * 90).rounded()) }), ]) @@ -433,9 +434,10 @@ public final class SimulatedBox { case ("PUT", "/api/loadpoints/{id}/schedule"): // The household clock when sent, else the UTC minute read on // today's date: what Core does. + lastScheduleBody = body let today = Date(timeIntervalSince1970: now / 1000) - let clock = body?["time_of_day_min"]?.number.map { Int($0) } - ?? EVText.clockFromUTC(Int(body?["time_of_day_min_utc"]?.number ?? 300), at: today, zone: house.timeZone) + let fromUTC = EVText.clockFromUTC(Int(body?["time_of_day_min_utc"]?.number ?? 300), at: today, zone: house.timeZone) + let clock = readsHouseholdClock ? body?["time_of_day_min"]?.number.map { Int($0) } ?? fromUTC : fromUTC house.schedule = ( body?["soc"]?.number ?? 0.8, clock, @@ -518,12 +520,14 @@ public final class SimulatedBox { ]) } if let s = h.schedule { - lp = lp.setting("schedule", [ + var schedule: JSON = [ "soc": .number(s.socFraction), // Today's UTC minute of the household clock, as Core answers. "time_of_day_min_utc": .number(Double(EVText.minuteUTC(clock: s.clockMin, at: Date(timeIntervalSince1970: now / 1000), zone: h.timeZone))), "recurring": .bool(s.recurring), "days": .number(Double(s.days)), "surplus_unlock_bat_soc": .number(s.surplusUnlock), - ]) + ] + if readsHouseholdClock { schedule = schedule.setting("time_of_day_min", .number(Double(s.clockMin))) } + lp = lp.setting("schedule", schedule) lp = lp.setting("target_soc", .number(s.socFraction)) if let deadline = h.deadlineMs(now) { lp = lp.setting("target_time", .string(ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: deadline / 1000)))) diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 36dbf23..2c91992 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -488,40 +488,20 @@ public enum AppWords { // Is FTW in control? "Is FTW in control?": "Styr FTW?", "Yes, FTW is in control": "Ja, FTW styr", - "Every device follows FTW.": "Alla enheter följer FTW.", + "Nothing blocks it, and every device reports.": "Inget stoppar FTW, och alla enheter rapporterar.", + "%@ is not reporting": "%@ rapporterar inte", + "%@ devices are not reporting": "%@ enheter rapporterar inte", + "The meter": "Elmätaren", + "The inverter": "Växelriktaren", + "Read at %@": "Läst kl. %@", "Not live right now": "Inte live just nu", "Last known %@.": "Senast känt %@.", "Waiting for your box.": "Väntar på din box.", "FTW is not in control right now": "FTW styr inte just nu", "A meter stopped reporting. Your home runs on grid power as usual.": "En mätare har slutat rapportera. Hemmet går på nätet som vanligt.", - "Checking…": "Kontrollerar…", - "Asking your box how its devices respond.": "Frågar boxen hur enheterna svarar.", - "Connected and live": "Ansluten och live", - "This box does not report whether devices follow it.": "Den här boxen rapporterar inte om enheterna följer den.", - "FTW controls no device right now": "FTW styr ingen enhet just nu", - "%@ needs attention now": "%@ behöver åtgärd nu", - "%@ devices need attention now": "%@ enheter behöver åtgärd nu", - "%@ needs a look": "%@ behöver en titt", - "%@ devices need a look": "%@ enheter behöver en titt", "The battery": "Batteriet", "The charger": "Laddaren", - "The solar inverter": "Solväxelriktaren", "A device": "En enhet", - "%1$@: %2$@": "%1$@: %2$@", - - // Core's control reasons and statuses - "Following FTW": "Följer FTW", "Waiting": "Väntar", "Limited": "Begränsad", "Not following": "Följer inte", - "No contact": "Ingen kontakt", "Not controlled by FTW": "Styrs inte av FTW", - "Adjusting": "Justerar", "No command yet": "Inget kommando än", "No car connected": "Ingen bil inkopplad", - "Waiting for the plan": "Väntar på planen", "Waiting for spare solar": "Väntar på solöverskott", "Starting the charger": "Startar laddaren", - "Car is full": "Bilen är full", "Car reached its limit": "Bilen nådde sin gräns", "Car not taking power": "Bilen tar ingen ström", - "Battery full": "Batteriet är fullt", "Battery nearly full": "Batteriet är nästan fullt", "Battery nearly empty": "Batteriet är nästan tomt", - "Limited by FTW": "Begränsad av FTW", "Limited by the main fuse": "Begränsad av huvudsäkringen", "Waiting after a fuse limit": "Väntar efter säkringsgräns", - "Limited by FTW's charger setting": "Begränsad av FTW:s laddarinställning", "Charger limit": "Laddarens gräns", "Charger offers less current": "Laddaren ger mindre ström", - "Device fault": "Fel på enheten", "No fresh readings": "Inga färska mätvärden", "Lost control": "Tappade styrningen", - "Commands failing": "Kommandon misslyckas", "Command not confirmed": "Kommandot är inte bekräftat", "Cannot hand back control": "Kan inte lämna tillbaka styrningen", - "No power reading": "Ingen effektmätning", "Monitoring only": "Bara övervakning", "Disabled": "Avstängd", "Own control": "Egen styrning", - "Paused for safety": "Pausad för säkerhets skull", "Checking": "Kontrollerar", // Now "Now": "Nu", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 0ba7887..8633717 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -7,6 +7,11 @@ import Observation /// tap to the box's answer and stays on screen, and after any outcome the /// charger is read again, because the box's account is the truth to /// repaint from. +/// +/// No timer asks the box. The chargers are read when a screen that shows +/// them appears, after a command, when the stream says the car started or +/// stopped drawing power, and when the stream comes back. Until the box +/// streams charger state (M5), that is all. @Observable @MainActor public final class LoadpointsModel: Activatable { @@ -39,6 +44,9 @@ public final class LoadpointsModel: Activatable { /// Levels set from this phone, kept while the car stays plugged in, so /// the view can say "you set it". public private(set) var levelSet: [String: EVText.LevelSet] = [:] + /// What is on screen came from the phone's own cache, not the box. True + /// from a cold start until the first read. + public private(set) var fromCache = false @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private let charging: ChargingWatch? @@ -51,23 +59,55 @@ public final class LoadpointsModel: Activatable { @ObservationIgnored private var reading: Task? @ObservationIgnored private var levelSending: Set = [] @ObservationIgnored private var clearing: [String: Cancellable] = [:] + /// Bumped by each appearance and by `reread`: a new reason to ask. + @ObservationIgnored private var visits = 0 - public init(site: SiteModel, charging: ChargingWatch?) { + /// `cache` is what the app left for its widgets; a cold start paints the + /// car from it before the box has answered. Nil in the demo and tests. + public init(site: SiteModel, charging: ChargingWatch?, cache: CacheReader? = CacheReader.shared()) { self.site = site self.charging = charging + if let siteId = site.siteId, let read = cache?.read(CarEntry.self, siteId: siteId) { + points = read.value.cars.map { Self.loadpoint($0, readAtMs: read.record.readAtMs) } + readAtMs = read.record.readAtMs + fromCache = true + } + } + + /// A charger as the cache kept it: enough for the level, its source and + /// age, the cable and the power. The goal and the plan wait for the box. + static func loadpoint(_ car: CarEntry.Car, readAtMs: Double) -> Loadpoint { + var w: [(String, JSON)] = [ + ("id", .string(car.loadpointID)), + ("plugged_in", .bool(car.pluggedIn)), + ("soc_source", .string(car.socSource)), + ("current_power_w", .number(car.powerW)), + ] + if let pct = car.socPct { w.append(("current_soc", .number(pct / 100))) } + if let pct = car.targetSocPct { w.append(("target_soc", .number(pct / 100))) } + if car.socSource == "vehicle", let at = car.socReadAtMs { w.append(("vehicle_soc_age_s", .number(max(0, readAtMs - at) / 1000))) } + return Loadpoint(.object(w)) } public func activate() { active += 1 + visits += 1 if ask == nil { ask = LiveAsk(site: site, want: { [weak self] in guard let self, self.active > 0, self.site.documentVisible, self.site.hasPassthrough else { return nil } - return "loadpoints \(Int(self.site.nowMs / 5_000))" + return "loadpoints \(self.visits) \(self.drawing)" }, ask: { [weak self] in try await self?.load() }) } ask?.evaluate() } + /// Whether the stream says a car is drawing power. A change is a reason + /// to read the chargers; nothing else on lane 0 is. + var drawing: String { + guard let w = site.session.fields[Contract.FID.evW] else { return "-" } + return w >= 100 ? "on" : "off" + } + public func deactivate() { active = max(0, active - 1) ask?.evaluate() @@ -82,7 +122,7 @@ public final class LoadpointsModel: Activatable { guard let self, self.planWatchers > 0, self.site.documentVisible, self.site.hasPassthrough, self.inlinePlan else { return nil } let goals = self.points.filter { $0.pluggedIn && $0.schedule != nil && !$0.planPending && !$0.planOutdated } guard !goals.isEmpty else { return nil } - return "plan \(goals.map(Self.planKey).joined(separator: ",")) \(Int(self.site.nowMs / 60_000))" + return "plan \(goals.map(Self.planKey).joined(separator: ","))" }, ask: { [weak self] in try await self?.readShortfall() }) } planAsk?.evaluate() @@ -99,14 +139,17 @@ public final class LoadpointsModel: Activatable { return "\(lp.id):\(lp.socPct ?? -1):\(s?.socPct ?? -1):\(s?.timeOfDayMinUTC ?? -1):\(lp.planWindows.count):\(Int(lp.planWindows.first?.fromMs ?? 0)):\(Int(lp.planTotalWh ?? 0))" } - /// Out of date: the read failed, the stream stopped, or it is old. + /// Out of date: from the cache, the read failed, or the stream stopped. + /// An answer is not out of date for being minutes old: the stream would + /// have said if the car started or stopped drawing. public var stale: Bool { - error != nil || site.session.phase != .streaming || (readAtMs.map { site.nowMs - $0 >= 15_000 } ?? false) + fromCache || error != nil || site.session.phase != .streaming } - /// Read now rather than at the next tick. + /// Ask again now: pull to refresh, or the demo's cable. public func reread() { - Task { try? await self.loadChargers() } + visits += 1 + ask?.evaluate() } // MARK: Reads @@ -144,6 +187,7 @@ public final class LoadpointsModel: Activatable { planMissing = false } loaded = true + fromCache = false readAtMs = site.scheduler.nowMs error = nil site.shareCars(points, fresh: true) @@ -247,18 +291,24 @@ public final class LoadpointsModel: Activatable { } } - /// The level to show: a tap on its way, else where the car stands. + /// The level to show: the box's account. A tap on its way shows as the + /// chosen chip and the receipt, never as the level: only the box's + /// answer moves the number. Between its answer and the next read, the + /// level it confirmed is the newest fact. public func level(_ lp: Loadpoint) -> EVText.Level? { - guard var level = EVText.level(lp, answeredAtMs: readAtMs ?? site.nowMs, nowMs: site.nowMs, set: levelSet[lp.id]) else { return nil } - if let want = levelWanted[lp.id] { - level.pct = want - level.origin = .you - level.atMs = site.nowMs - level.setPct = nil + let set = levelSet[lp.id] + guard var level = EVText.level(lp, answeredAtMs: readAtMs ?? site.nowMs, nowMs: site.nowMs, set: set) else { return nil } + if let set, (readAtMs ?? 0) < set.atMs { + level = EVText.Level(pct: set.pct, origin: .you, atMs: set.atMs, setPct: nil) } return level } + /// The chip to show as chosen: the tap on its way, else the level. + public func chosenLevel(_ lp: Loadpoint) -> Double? { + levelWanted[lp.id] ?? level(lp)?.pct + } + // MARK: Charge now, Pause, Back to the plan /// Charge now: the charger's full current, held until Back to the plan diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index 5828d29..ee71249 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -297,84 +297,50 @@ public final class SavingsModel: Activatable { } } -/// "Is FTW in control?" in one calm line. Core owns every verdict: the -/// stream's dispatch block and each device's control feedback with its -/// severity. This only picks words, and never says yes from readings that -/// are not live: a cache shows when it was last known, in grey. +/// "Is FTW in control?" in one calm line, from the stream alone: lane 0 +/// carries the box's dispatch block and each source's state, so the line +/// needs no request and no timer. It is green only while frames arrive +/// and the box says nothing blocks it; from a cache it is grey and dated. +/// Core's per-device control feedback joins it when lane 0 carries it (M5). public enum InControl { - public enum Tone: Equatable, Sendable { case ok, warning, alarm, neutral, stale } + public enum Tone: Equatable, Sendable { case ok, warning, neutral, stale } public struct Answer: Equatable, Sendable { public let tone: Tone public let title: String public let detail: String? + + public init(tone: Tone, title: String, detail: String?) { + self.tone = tone + self.title = title + self.detail = detail + } } - /// - live: the stream is streaming, not the cache, every source live. - /// - status: GET /api/status, nil until it has answered. - /// - statusFresh: that answer is current. - public static func answer(live: Bool, lastKnownMs: Double?, dispatchBlockedBy: [String], status: JSON?, statusFresh: Bool, zone: TimeZone = .current) -> Answer { - guard live else { + /// - streaming: frames are arriving now over a live carrier. + /// - sources: the box's own account of each device it reads. + public static func answer(streaming: Bool, lastKnownMs: Double?, dispatchBlockedBy: [String], sources: [String: Source], zone: TimeZone = .current) -> Answer { + guard streaming else { return Answer(tone: .stale, title: AppWords.text("Not live right now"), detail: lastKnownMs.map { AppWords.format("Last known %@.", AppWords.clock($0, zone: zone)) } ?? AppWords.text("Waiting for your box.")) } if !dispatchBlockedBy.isEmpty { return Answer(tone: .warning, title: AppWords.text("FTW is not in control right now"), detail: AppWords.text("A meter stopped reporting. Your home runs on grid power as usual.")) } - guard let status, statusFresh else { - return Answer(tone: .neutral, title: AppWords.text("Checking…"), detail: AppWords.text("Asking your box how its devices respond.")) - } - guard let rows = status["control_feedback"]?.array else { - // A box from before control feedback: live and not blocked is - // all it says, so that is all this says. - return Answer(tone: .neutral, title: AppWords.text("Connected and live"), detail: AppWords.text("This box does not report whether devices follow it.")) - } - let valid = rows.filter { $0["driver"]?.string != nil && $0["status"]?.string != nil } - let alarms = valid.filter { $0["severity"]?.string == "alarm" } - let warnings = valid.filter { $0["severity"]?.string == "warning" } - if let first = alarms.first { - let title = alarms.count == 1 ? AppWords.format("%@ needs attention now", noun(first)) : AppWords.format("%@ devices need attention now", String(alarms.count)) - return Answer(tone: .alarm, title: title, detail: reasonTitle(first)) + let quiet = sources.values.filter { $0.state == .stale || $0.state == .down }.sorted { $0.name < $1.name } + if let first = quiet.first { + let title = quiet.count == 1 ? AppWords.format("%@ is not reporting", noun(first.kind)) : AppWords.format("%@ devices are not reporting", String(quiet.count)) + return Answer(tone: .warning, title: title, detail: first.name.isEmpty ? nil : first.name) } - if let first = warnings.first { - let title = warnings.count == 1 ? AppWords.format("%@ needs a look", noun(first)) : AppWords.format("%@ devices need a look", String(warnings.count)) - return Answer(tone: .warning, title: title, detail: reasonTitle(first)) - } - if !valid.contains(where: { $0["status"]?.string != "not_controlled" }) { - return Answer(tone: .neutral, title: AppWords.text("FTW controls no device right now"), detail: nil) - } - return Answer(tone: .ok, title: AppWords.text("Yes, FTW is in control"), detail: AppWords.text("Every device follows FTW.")) + return Answer(tone: .ok, title: AppWords.text("Yes, FTW is in control"), detail: AppWords.text("Nothing blocks it, and every device reports.")) } - static func noun(_ row: JSON) -> String { - switch row["kind"]?.string { + static func noun(_ kind: String) -> String { + switch kind { + case "meter": return AppWords.text("The meter") + case "inverter": return AppWords.text("The inverter") case "battery": return AppWords.text("The battery") - case "ev", "v2x_charger": return AppWords.text("The charger") - case "pv": return AppWords.text("The solar inverter") + case "charger", "ev": return AppWords.text("The charger") default: return AppWords.text("A device") } } - - /// Core's reason titles, the box page's words (web/control-feedback.js). - static let reasonTitles: [String: String] = [ - "waiting_response": "Adjusting", "no_command": "No command yet", "not_connected": "No car connected", - "no_plan_budget": "Waiting for the plan", "pv_surplus_pause": "Waiting for spare solar", "wake_kick": "Starting the charger", - "vehicle_complete": "Car is full", "vehicle_limit_completion": "Car reached its limit", "vehicle_not_requesting": "Car not taking power", - "battery_full": "Battery full", "battery_nearly_full": "Battery nearly full", "battery_nearly_empty": "Battery nearly empty", - "core_limit": "Limited by FTW", "fuse_limit": "Limited by the main fuse", "fuse_cooldown": "Waiting after a fuse limit", - "charger_limit": "Limited by FTW's charger setting", "device_limit": "Charger limit", "offered_current_lower": "Charger offers less current", - "device_fault": "Device fault", "telemetry_stale": "No fresh readings", "readings_lost": "Lost control", - "command_failed": "Commands failing", "command_unconfirmed": "Command not confirmed", "default_failed": "Cannot hand back control", - "response_unknown": "No power reading", "observe_only": "Monitoring only", "disabled": "Disabled", "device_control": "Own control", - "site_meter_stale": "Paused for safety", "site_phase_currents_stale": "Paused for safety", - ] - - static let statusTitles: [String: String] = [ - "following": "Following FTW", "waiting": "Waiting", "limited": "Limited", "not_following": "Not following", - "no_contact": "No contact", "not_controlled": "Not controlled by FTW", - ] - - static func reasonTitle(_ row: JSON) -> String { - let title = reasonTitles[row["reason"]?.string ?? ""] ?? statusTitles[row["status"]?.string ?? ""] ?? "Checking" - return AppWords.text(title) - } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift index b352ea9..5c106a8 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -270,36 +270,36 @@ import FTWDemo // MARK: Is FTW in control? - @Test func inControlIsNeverGreenFromACache() throws { - try AppWords.$language.withValue(.en) { - let following = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) - let ok = InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: true) + static func source(_ kind: String, _ name: String, _ state: String) -> Source { + Source(.map([("kind", .text(kind)), ("name", .text(name)), ("lastOkMs", .int(0)), ("staleAfterMs", .int(10_000)), ("state", .text(state))]))! + } + + @Test func inControlComesFromTheStreamAndIsNeverGreenFromACache() { + AppWords.$language.withValue(.en) { + let live = ["meter": Self.source("meter", "sdm630", "live"), "inverter": Self.source("inverter", "sungrow", "live")] + let ok = InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: live) #expect(ok.tone == .ok) #expect(ok.title == "Yes, FTW is in control") - // The same verdict from a cache, or from an old status read, is - // not a yes. - let cached = InControl.answer(live: false, lastKnownMs: Self.t, dispatchBlockedBy: [], status: following, statusFresh: true, zone: Self.utc) + // The same facts from a cache, or from a stream gone quiet, are not a yes. + let cached = InControl.answer(streaming: false, lastKnownMs: Self.t, dispatchBlockedBy: [], sources: live, zone: Self.utc) #expect(cached.tone == .stale) + #expect(cached.title == "Not live right now") #expect(cached.detail == "Last known 10:00.") - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: false).tone == .neutral) - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: ["meter"], status: following, statusFresh: true).tone == .warning) - let old = try JSON(parsing: Array(#"{"grid_w":100}"#.utf8)) - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: old, statusFresh: true).title == "Connected and live") + #expect(InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: ["meter"], sources: live).tone == .warning) - let alarm = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"no_contact","reason":"telemetry_stale","severity":"alarm"},{"driver":"sungrow","kind":"battery","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) - let a = InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: alarm, statusFresh: true) - #expect(a.tone == .alarm) - #expect(a.title == "The charger needs attention now") - #expect(a.detail == "No fresh readings") - - let warnings = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"limited","reason":"fuse_limit","severity":"warning"},{"driver":"sungrow","kind":"battery","status":"limited","reason":"battery_full","severity":"warning"}]}"#.utf8)) - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: warnings, statusFresh: true).title == "2 devices need a look") + var quiet = live + quiet["inverter"] = Self.source("inverter", "sungrow", "down") + let one = InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: quiet) + #expect(one.tone == .warning) + #expect(one.title == "The inverter is not reporting") + #expect(one.detail == "sungrow") + quiet["meter"] = Self.source("meter", "sdm630", "stale") + #expect(InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: quiet).title == "2 devices are not reporting") } - try AppWords.$language.withValue(.sv) { - let following = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: true).title == "Ja, FTW styr") + AppWords.$language.withValue(.sv) { + #expect(InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: [:]).title == "Ja, FTW styr") #expect(AppWords.text("Is FTW in control?") == "Styr FTW?") } } @@ -311,11 +311,12 @@ import FTWDemo @Suite struct CarFlowTests { @MainActor final class Rig { - let scheduler = ManualScheduler(nowMs: CarTextTests.t) + let scheduler: ManualScheduler var box: SimulatedBox? var app: AppModel! - init() { + init(nowMs: Double = CarTextTests.t) { + scheduler = ManualScheduler(nowMs: nowMs) app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { [unowned self] s in let box = SimulatedBox(scheduler: s, house: SimulatedHouse(timeZone: CarTextTests.stockholm)) box.resultDelayMs = 900 @@ -354,7 +355,11 @@ import FTWDemo let level = Task { await home.loadpoints.setLevel(car, pct: 40) } await rig.run(100) #expect(home.loadpoints.levelStep[car.id] == EVText.Step(ask: .level(40))) - #expect(home.loadpoints.level(car)?.pct == 40) + // The tap shows as the chosen chip, never as the level: only the + // box's answer moves the number. + #expect(home.loadpoints.chosenLevel(car) == 40) + #expect(home.loadpoints.level(car)?.pct == 20) + #expect(home.loadpoints.level(car)?.origin == .assumed) await rig.run(300) #expect(home.loadpoints.levelStep[car.id]?.acked == true) #expect(home.loadpoints.levelStep[car.id]?.result == nil) @@ -461,17 +466,119 @@ import FTWDemo } } - /// The demo answers "Is FTW in control?" as Core does: from live - /// control feedback, and only while the stream is live. + /// The demo answers "Is FTW in control?" from the stream alone. @Test func theDemoSaysFTWIsInControl() async throws { let rig = Rig() rig.app.startDemo() let home = try #require(rig.app.home) - home.status.activate() await rig.run(3_000) let site = home.site - let answer = InControl.answer(live: site.isLive, lastKnownMs: site.lastFrameAtMs, dispatchBlockedBy: site.session.dispatchBlockedBy, status: home.status.status, statusFresh: home.status.fresh) + let answer = InControl.answer(streaming: site.isLive, lastKnownMs: site.lastFrameAtMs, dispatchBlockedBy: site.session.dispatchBlockedBy, sources: site.session.sources) #expect(answer.tone == .ok) + // No request was needed for it. + #expect(try #require(rig.box).receivedRequests.isEmpty) + } + + /// No timer reads the chargers. A screen appearing does, a command + /// does, and the stream saying the car started drawing does. + @Test func theChargersAreReadOnEventsNotOnATimer() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + func reads() -> Int { box.receivedRequests.filter { $0 == "GET /api/loadpoints" }.count } + home.loadpoints.activate() + await rig.run(1_000) + #expect(reads() == 1) + await rig.run(60_000, step: 500) + #expect(reads() == 1) + + // A second screen appearing is a reason to look again. + home.loadpoints.activate() + await rig.run(1_000) + #expect(reads() == 2) + home.loadpoints.deactivate() + + // Charge now: one read after the box's answer, one when the stream + // shows the car drawing power. + let car = try #require(home.loadpoints.points.first) + let charge = Task { await home.loadpoints.chargeNow(car) } + await rig.run(2_000) + await charge.value + #expect(reads() == 3) + #expect(home.loadpoints.points.first?.powerW == 0) + await rig.run(5_000, step: 250) + #expect(reads() == 4) + #expect(home.loadpoints.points.first?.powerW == 11_000) + await rig.run(60_000, step: 500) + #expect(reads() == 4) + + // The plan's shortfall: read when the car view opens and when the + // car's plan changes, not once a minute. + let back = Task { await home.loadpoints.backToPlan(car) } + await rig.run(3_000) + await back.value + func plans() -> Int { box.receivedRequests.filter { $0 == "GET /api/mpc/plan" }.count } + home.loadpoints.watchPlan() + await rig.run(1_000) + #expect(plans() == 1) + await rig.run(180_000, step: 1_000) + #expect(plans() == 1) + } + + /// A cold start paints the car the app last saw, from the cache it + /// writes for its widgets, dated and marked as not live. + @Test func aColdStartPaintsTheCarFromTheCache() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-car-\(UUID().uuidString)") + let shared = AppGroupFiles(directory: dir) + let readAt = CarTextTests.t - 600_000 + let car = CarEntry.Car(loadpointID: "garage", pluggedIn: true, socPct: 64, socSource: "vehicle", socReadAtMs: readAt - 120_000, powerW: 0, targetSocPct: 80) + try shared.write(CacheRecord(siteId: "site-1", carrier: .relay, srcState: .live, readAtMs: readAt, value: CarEntry(cars: [car]))) + let scheduler = ManualScheduler(nowMs: CarTextTests.t) + let site = SiteModel(siteId: "site-1", build: "test", ua: "test", scheduler: scheduler, files: nil) + let model = LoadpointsModel(site: site, charging: nil, cache: CacheReader(sources: [shared])) + let lp = try #require(model.points.first) + #expect(model.fromCache) + #expect(model.stale) + #expect(model.readAtMs == readAt) + #expect(lp.pluggedIn) + #expect(lp.targetSocPct == 80) + let level = try #require(model.level(lp)) + #expect(level.pct == 64) + // The car's report was two minutes old when cached, ten minutes ago. + #expect(level.origin == .carEarlier) + #expect(level.atMs == readAt - 120_000) + // Another home's cache paints nothing. + let other = SiteModel(siteId: "site-2", build: "test", ua: "test", scheduler: scheduler, files: nil) + #expect(LoadpointsModel(site: other, charging: nil, cache: CacheReader(sources: [shared])).points.isEmpty) + } + + /// A save sends the household clock and today's UTC minute, built from + /// nothing read. A Core with srcfl/ftw#1586 takes the clock; an older + /// one reads the minute on today's date. Either way 07:00 is kept. + @Test func aGoalSavesBothClocksAndEitherCoreKeepsTheTime() async throws { + for (day, utc) in [(24, 300), (26, 360)] { + for household in [true, false] { + let rig = Rig(nowMs: CarTextTests.noon(day).timeIntervalSince1970 * 1000) + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.readsHouseholdClock = household + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + await home.loadpoints.saveGoalAndWait(car, rig, socPct: 80, clock: 7 * 60, recurring: true, days: 0b0011111) + let body = try #require(box.lastScheduleBody) + #expect(body["time_of_day_min"]?.number == 420) + #expect(body["time_of_day_min_utc"]?.number == Double(utc)) + #expect(box.house.schedule?.clockMin == 420) + // Read back: the clock when the box sends it, the minute otherwise. + let back = try #require(home.loadpoints.points.first?.schedule) + #expect(back.clockMin == (household ? 420 : nil)) + #expect(back.timeOfDayMinUTC == utc) + #expect(EVText.goalClock(back, at: CarTextTests.noon(day), zone: home.loadpoints.zone) == 420) + } + } } /// Every demo situation opens, so each screen can be shown without a box. From 58c106c618de9cbd842e8e018ba81aa3359b54ff Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:53:19 +0200 Subject: [PATCH 18/25] Leave the pairing screen out of the store pictures In the simulator it shows a button a phone does not have. Wait 12 s so the charging picture shows the car charging, not starting. Co-Authored-By: Claude Opus 5.5 --- appleApp/scripts/store-screenshots.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/appleApp/scripts/store-screenshots.sh b/appleApp/scripts/store-screenshots.sh index e860bc3..ee9967f 100755 --- a/appleApp/scripts/store-screenshots.sh +++ b/appleApp/scripts/store-screenshots.sh @@ -40,16 +40,16 @@ shoot() { mkdir -p "$out/$dir" xcrun simctl launch --terminate-running-process "$udid" energy.ftw.app \ -AppleLanguages "($lang)" -AppleLocale "${dir/-/_}" "$@" >/dev/null - sleep 8 - xcrun simctl io "$udid" screenshot "$out/$dir/$name.png" >/dev/null + sleep 12 + xcrun simctl io "$udid" screenshot "$out/$dir/$name.png" >/dev/null 2>&1 echo "$out/$dir/$name.png $(sips -g pixelWidth -g pixelHeight "$out/$dir/$name.png" | awk '/pixel/ {printf "%s ", $2}')" } -# -FTWTab and -FTWDemoCar only work in a Debug build with the car view. +# -FTWTab and -FTWDemoCar only work in a Debug build with the car view. No +# pairing screen: in the simulator it shows a button a phone does not have. for pair in sv-SE:sv en-US:en; do dir=${pair%%:*} lang=${pair##*:} shoot "$dir" "$lang" 1-now -FTWDemo YES shoot "$dir" "$lang" 2-car -FTWDemo YES -FTWTab car shoot "$dir" "$lang" 3-charging -FTWDemo YES -FTWTab car -FTWDemoCar charging - shoot "$dir" "$lang" 4-pair done From e76660d1a79213ca8165ee78a93e5a72ad04d97f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:57:30 +0200 Subject: [PATCH 19/25] Tidy Source & licenses after a look in the simulator - The close button is the neutral glass one; as a confirmation action it turned amber. - The removal question points at the button that asked it, not at the top of the list. - The mark in the header grows with the text, and the header stacks at accessibility sizes. - A title in capitals over a short line, such as the license's name over its version, reads as one heading. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/About/AboutView.swift | 12 ++++++++---- appleApp/FTW/About/RemovePhoneView.swift | 19 ++++++++++--------- .../Sources/FTWKit/Identity/LicenseText.swift | 14 +++++++++++++- .../FTWKit/Tests/FTWKitTests/AboutTests.swift | 1 + 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/appleApp/FTW/About/AboutView.swift b/appleApp/FTW/About/AboutView.swift index fb167e1..7c52993 100644 --- a/appleApp/FTW/About/AboutView.swift +++ b/appleApp/FTW/About/AboutView.swift @@ -29,6 +29,8 @@ struct AboutView: View { let app: AppModel @State private var path: [Route] @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var typeSize + @ScaledMetric(relativeTo: .title2) private var markSize: CGFloat = 52 private let source = BuildSource.bundled init(app: AppModel, start: Start = .about) { @@ -109,8 +111,9 @@ struct AboutView: View { .navigationTitle(AboutWords.text("Source & licenses")) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .confirmationAction) { + ToolbarItem(placement: .topBarTrailing) { Button(role: .close) { dismiss() } + .tint(nil) } } .navigationDestination(for: Route.self) { route in @@ -128,12 +131,13 @@ struct AboutView: View { /// The notices the AGPL asks an interactive program to show: who holds /// the copyright, that there is no warranty, and where the license is. private var header: some View { - HStack(alignment: .top, spacing: 14) { + let stack = typeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) : AnyLayout(HStackLayout(alignment: .top, spacing: 14)) + return stack { Image(systemName: "bolt.fill") .font(.title2) .foregroundStyle(Theme.accent) - .frame(width: 52, height: 52) - .background(Color(white: 0.05), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .frame(width: markSize, height: markSize) + .background(Color(white: 0.05), in: RoundedRectangle(cornerRadius: markSize * 0.23, style: .continuous)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text("FTW").font(.title2.weight(.bold)) diff --git a/appleApp/FTW/About/RemovePhoneView.swift b/appleApp/FTW/About/RemovePhoneView.swift index eb6a7cc..2b95d95 100644 --- a/appleApp/FTW/About/RemovePhoneView.swift +++ b/appleApp/FTW/About/RemovePhoneView.swift @@ -43,6 +43,16 @@ struct RemovePhoneView: View { Section { Button(AboutWords.text("Remove from this phone"), role: .destructive) { confirming = true } + // On the button, so the question points at what asked it. + .confirmationDialog(AboutWords.text("Remove this phone?"), isPresented: $confirming, titleVisibility: .visible) { + Button(AboutWords.text("Remove"), role: .destructive) { + close() + app.leave() + } + Button(AboutWords.text("Cancel"), role: .cancel) {} + } message: { + Text(AboutWords.text("FTW forgets your home on this phone. To come back, scan a new pairing QR from your box.")) + } } header: { Text(AboutWords.text("On this phone")) } footer: { @@ -53,14 +63,5 @@ struct RemovePhoneView: View { .background(Theme.surface) .navigationTitle(AboutWords.text("Remove this phone")) .navigationBarTitleDisplayMode(.inline) - .confirmationDialog(AboutWords.text("Remove this phone?"), isPresented: $confirming, titleVisibility: .visible) { - Button(AboutWords.text("Remove"), role: .destructive) { - close() - app.leave() - } - Button(AboutWords.text("Cancel"), role: .cancel) {} - } message: { - Text(AboutWords.text("FTW forgets your home on this phone. To come back, scan a new pairing QR from your box.")) - } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift index d06bf7e..39fda9a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift @@ -62,6 +62,14 @@ public enum LicenseText { add(.heading(level), paragraph[0].trimmingCharacters(in: .whitespaces)) return } + // A title block: lines set in the middle of the page, or a + // name in capitals over a short line, such as the license's + // name over its version. + let titled = paragraph.count == 2 && plainHeading(paragraph[0]) == 1 && paragraph[1].trimmingCharacters(in: .whitespaces).count <= 40 + if !markdown, paragraph.count > 1, titled || paragraph.allSatisfy(isCentred) { + add(.heading(1), paragraph.map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n")) + return + } add(.paragraph, join(paragraph, width: width)) } @@ -129,6 +137,10 @@ public enum LicenseText { return (min(hashes, 2), String(line.dropFirst(hashes + 1))) } + static func isCentred(_ line: String) -> Bool { + line.prefix(while: { $0 == " " }).count >= 8 && line.trimmingCharacters(in: .whitespaces).count <= 60 + } + /// In plain text a title is a line on its own: set in capitals, centred /// with spaces, or a numbered section such as "0. Definitions." static func plainHeading(_ line: String) -> Int? { @@ -136,7 +148,7 @@ public enum LicenseText { guard trimmed.count <= 60 else { return nil } let letters = trimmed.filter(\.isLetter) if !letters.isEmpty, letters == letters.uppercased(), letters.count >= 4 { return 1 } - if line.prefix(while: { $0 == " " }).count >= 8 { return 1 } + if isCentred(line) { return 1 } if trimmed.range(of: #"^[0-9]{1,2}\. [A-Z][^.]*\.$"#, options: .regularExpression) != nil { return 2 } return nil } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift index 581d177..45de0f2 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift @@ -72,6 +72,7 @@ import Testing #expect(!opening.text.contains("\n")) #expect(blocks.contains { $0.kind == .heading(2) && $0.text == "0. Definitions." }) #expect(blocks.contains { $0.kind == .heading(1) && $0.text == "TERMS AND CONDITIONS" }) + #expect(blocks.contains { $0.kind == .heading(1) && $0.text == "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007" }) #expect(blocks.contains { $0.kind == .rule }) // Both of the license's own permissions are there. #expect(blocks.contains { $0.text.contains("FTW app store distribution permission") } || blocks.contains { $0.text.contains("Energyplan") }) From 833bcb90dc04b9da3447c44efca181efd97a1a24 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 19:00:10 +0200 Subject: [PATCH 20/25] Put the shortest way to an iPhone first, and add an upload fallback RELEASING.md now starts with installing from Xcode on a connected iPhone (Developer Mode, an Apple Account in Xcode, xcodebuild and devicectl), then internal TestFlight, which needs no Beta App Review: the agreement, the app record, an Admin key in plain repository secrets, a tag, and a tester group. External TestFlight and the App Store follow. The workflow accepts tags like ios-v0.1.0-1: text after a dash only tells tags apart. If Xcode's upload fails, it exports the .ipa and sends it with `altool --upload-package`, which Apple's "Upload builds" help still lists. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ios-release.yml | 50 +++++- appleApp/RELEASING.md | 276 +++++++++++++++++++----------- 2 files changed, 219 insertions(+), 107 deletions(-) diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index 37b1531..adc3752 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -1,9 +1,11 @@ name: iOS release # Archives the iPhone app and sends it to App Store Connect, where it lands -# in TestFlight. A tag `ios-v1.2.3` ships version 1.2.3; a manual run ships -# the version in appleApp/FTW.xcconfig. The build number is this workflow's -# run number. appleApp/RELEASING.md has the steps around it. +# in TestFlight. A tag `ios-v1.2.3` ships version 1.2.3, and so do +# `ios-v1.2.3-1`, `ios-v1.2.3-2`: anything after a dash only tells tags +# apart. A manual run ships the version in appleApp/FTW.xcconfig. The build +# number is this workflow's run number. appleApp/RELEASING.md has the steps +# around it. # # Without the three ASC_* secrets it builds an unsigned archive instead and # says that the upload was skipped. A pull request that changes this file or @@ -30,8 +32,9 @@ jobs: # macos-26 image stops at Xcode 26.6. runs-on: xcode-27 timeout-minutes: 60 - # The key's secrets live in this environment, which only main and - # ios-v* tags may use (RELEASING.md). A pull request needs no key. + # The key's secrets may live in the repository or, safer, in this + # environment, which only main and ios-v* tags may use (RELEASING.md). + # A pull request needs no key. environment: ${{ github.event_name != 'pull_request' && 'app-store' || '' }} env: # The release, by path. Picking the newest Xcode on this image would @@ -54,6 +57,7 @@ jobs: run: | if [[ $GITHUB_REF_TYPE == tag && $GITHUB_REF_NAME == ios-v* ]]; then version=${GITHUB_REF_NAME#ios-v} + version=${version%%-*} else version=$(sed -n 's/^MARKETING_VERSION *= *//p' appleApp/FTW.xcconfig) fi @@ -71,7 +75,8 @@ jobs: echo "Version $version, build $build" # Secrets cannot appear in an `if`, so this step turns them into a - # yes or no. It prints nothing about them. + # yes or no. It prints nothing about them. ASC_KEY_P8 is the .p8 file's + # PEM text as Apple gives it; the other two are plain text. - name: Check for the App Store Connect key id: key env: @@ -133,8 +138,12 @@ jobs: [[ $got == ${{ steps.version.outputs.version }}/${{ steps.version.outputs.build }} ]] || { echo "::error::$plist has $got"; exit 1; } done + # Xcode signs and uploads in one step. If that fails, the next step + # tries the other way Apple documents. - name: Upload to App Store Connect + id: upload if: steps.key.outputs.upload == 'true' + continue-on-error: true working-directory: appleApp env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} @@ -149,11 +158,36 @@ jobs: -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID" + # Apple's "Upload builds" help still lists altool beside Xcode and + # Transporter. Export the signed .ipa, then send it with the same key. + - name: Upload with altool instead + id: altool + if: steps.upload.outcome == 'failure' + working-directory: appleApp + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: | + echo "::warning title=Xcode upload failed::Exporting an .ipa and uploading it with altool instead." + plutil -replace destination -string export -o "$RUNNER_TEMP/ExportOptions-ipa.plist" ExportOptions.plist + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/FTW.xcarchive" \ + -exportOptionsPlist "$RUNNER_TEMP/ExportOptions-ipa.plist" \ + -exportPath "$RUNNER_TEMP/ipa" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "${{ steps.key.outputs.path }}" \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + xcrun altool --upload-package "$RUNNER_TEMP"/ipa/*.ipa \ + --api-key "$ASC_KEY_ID" --api-issuer "$ASC_ISSUER_ID" \ + --p8-file-path "${{ steps.key.outputs.path }}" + - name: Report run: | v="${{ steps.version.outputs.version }} (${{ steps.version.outputs.build }})" if [[ "${{ steps.key.outputs.upload }}" == true ]]; then - echo "FTW $v went to App Store Connect. It shows in TestFlight once Apple has processed it." >> "$GITHUB_STEP_SUMMARY" + how=Xcode; [[ "${{ steps.altool.outcome }}" == success ]] && how=altool + echo "FTW $v went to App Store Connect through $how. It shows in TestFlight once Apple has processed it." >> "$GITHUB_STEP_SUMMARY" else echo "::warning title=Upload skipped::Dry run: FTW $v was archived unsigned and not sent to App Store Connect. A pull request never uploads; otherwise set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 (appleApp/RELEASING.md)." echo "**Upload skipped.** Dry run: FTW $v was archived unsigned and not sent to App Store Connect." >> "$GITHUB_STEP_SUMMARY" @@ -165,7 +199,7 @@ jobs: # Xcode's own account of the export, for when App Store Connect says no. - name: Keep the export logs - if: failure() && steps.key.outputs.upload == 'true' + if: always() && steps.key.outputs.upload == 'true' && (failure() || steps.upload.outcome == 'failure') uses: actions/upload-artifact@v4 with: name: export-logs diff --git a/appleApp/RELEASING.md b/appleApp/RELEASING.md index ca897c4..5fd46f2 100644 --- a/appleApp/RELEASING.md +++ b/appleApp/RELEASING.md @@ -1,15 +1,16 @@ # Releasing the iPhone app CI tests, archives, signs and uploads. A person sets up App Store Connect -once, then tags each release. This file lists that person's steps in order. -Checked against Apple's pages on 12 October 2026; Apple moves menus, so -trust the screen if it differs. +once, then tags each release. This file lists that person's steps in order: +first the shortest way onto your own iPhone, then what external testers +and the App Store need. Checked against Apple's pages on 12 October 2026; +Apple moves menus, so trust the screen if it differs. | What | Value | |---|---| | Seller | Sourceful Labs AB (reg. no. 559382-0458), Kalmar, Sweden | | Team ID | `L66M368PZ6` | -| Bundle IDs | `energy.ftw.app`, `energy.ftw.app.widgets` | +| Bundle IDs | `energy.ftw.app` (registered), `energy.ftw.app.widgets` | | App Group | `group.energy.ftw.app` | | Workflow | [`.github/workflows/ios-release.yml`](../.github/workflows/ios-release.yml) | | Store texts | [`store/`](store/) | @@ -17,15 +18,19 @@ trust the screen if it differs. ## How a build travels -1. A tag `ios-v1.2.3` (or **Run workflow** on the *iOS release* workflow) - starts the job on GitHub's `xcode-27` runner. +1. A tag `ios-v1.2.3` starts the job on GitHub's `xcode-27` runner. The job + uses the workflow file in the tagged commit, so any branch that contains + it can be tagged. Text after a dash only tells tags apart: + `ios-v0.1.0-1` and `ios-v0.1.0-2` both ship version 0.1.0. Once the + workflow is on `main`, **Actions → iOS release → Run workflow** also + works; it ships `MARKETING_VERSION` from `FTW.xcconfig`. 2. It runs `swift test`, then archives with Xcode 27.0 and the iOS 27 SDK. - The version comes from the tag (a manual run uses `MARKETING_VERSION` in - `FTW.xcconfig`); the build number is the run number, with `.2`, `.3` on a - re-run. + The build number is the run number, with `.2`, `.3` on a re-run. 3. Xcode signs with the team through the API key and uploads with [`ExportOptions.plist`](ExportOptions.plist) (`app-store-connect`, - `upload`). The build shows in TestFlight once Apple has processed it. + `upload`). If that upload fails, the job exports the `.ipa` and sends it + with `xcrun altool --upload-package`, which Apple's "Upload builds" help + still lists. The build shows in TestFlight once Apple has processed it. Without the three secrets the job archives unsigned and reports "Upload skipped". A pull request that changes the workflow or the export @@ -34,33 +39,53 @@ options always takes that path, so it proves the archive without a key. Apple has required the iOS 26 SDK for uploads since 28 April 2026 and requires the iOS 27 SDK from April 2027. Xcode 27 meets both. -## Once, before the first upload +## Your own iPhone, from Xcode -### 1. Agreements, identifiers and links +No App Store Connect, no key. The app is signed for development and runs +until the profile expires. -- The Account Holder accepts the latest Apple Developer Program License - Agreement under **Business**. Until then App Store Connect refuses new - apps, and the API key stops working. A free app needs no Paid Apps - Agreement. -- Register the App IDs, the App Group and Associated Domains as - [README](../README.md#status-iphone-2026-10-12) lists under "Manual - steps before a phone build". Building once in Xcode, signed in to the - team, does this too. -- `https://app.ftw.energy/.well-known/apple-app-site-association` must - answer before review: passkeys and pairing links need it - ([srcfl/ftw-webapp#77](https://github.com/srcfl/ftw-webapp/pull/77)). - On 12 October 2026 it answered 404. -- `ftw.energy` must serve the website. On 12 October 2026 it had no - address record, and the Cloudflare Pages project `ftw` served only - `ftw.sourceful.energy`. In Cloudflare: **Workers & Pages → ftw → Custom - domains → Set up a custom domain → `ftw.energy`**. Then check: +1. On the iPhone, connect it to the Mac once, then **Settings → Privacy & + Security → Developer Mode → On**. The iPhone restarts; confirm. +2. In Xcode: **Settings → Accounts → (+) → Apple Account**, and sign in + with an Apple Account in team `L66M368PZ6`. +3. Unlock the iPhone and tap **Trust** when it asks. - ```bash - curl -sI https://ftw.energy/privacy - ``` +The simplest way: open `appleApp/FTW.xcodeproj`, pick the iPhone above the +editor, press **⌘R**. From the command line instead, find the iPhone's +UDID. Run these from the repository's root: - If `ftw.energy` stays off, replace it with `ftw.sourceful.energy` in - `store/*/privacy-url.txt`, `support-url.txt` and `marketing-url.txt`. +```bash +xcrun devicectl list devices +``` + +Build for it. Automatic signing registers the iPhone and the widget's App +ID in the team: + +```bash +xcodebuild build -project appleApp/FTW.xcodeproj -scheme FTW -configuration Debug -destination 'platform=iOS,id=' -derivedDataPath appleApp/build/device -allowProvisioningUpdates +``` + +Install and open it: + +```bash +xcrun devicectl device install app --device appleApp/build/device/Build/Products/Debug-iphoneos/FTW.app +``` + +```bash +xcrun devicectl device process launch --device energy.ftw.app +``` + +## Internal TestFlight, the shortest way + +Internal testers are people in the App Store Connect team, up to 100. +Their builds need no Beta App Review. + +### 1. Accept the agreement + +The Account Holder accepts the latest Apple Developer Program License +Agreement under **Business**. Until then App Store Connect refuses new +apps, and the API key stops working. A free app needs no Paid Apps +Agreement. ### 2. Create the app @@ -76,7 +101,7 @@ requires the iOS 27 SDK from April 2027. Xcode 27 meets both. | User Access | Full Access | English is the primary language, so every country without its own text -shows English. Then add **Swedish** under the localization menu in App +shows English. Swedish comes later, under the localization menu in App Information. **The name.** The iTunes Search API found no live app named exactly "FTW" @@ -92,9 +117,106 @@ none was in use: The name under the icon stays "FTW" whatever the store name. -### 3. App Information +### 3. Make the API key and add the secrets + +**Users and Access → Integrations → App Store Connect API → Team Keys → +Generate API Key** + +- Name: `GitHub ftw-app release`. +- Access: **Admin**. With an App Manager key, cloud signing fails + ("Cloud signing permission error"; [Apple forum + thread](https://developer.apple.com/forums/thread/698117)). The reason: + the runner has no certificate, so the export signs with a cloud-managed + distribution certificate. Apple's [roles + table](https://developer.apple.com/support/roles/) gives those to Account + Holder and Admin; other roles need a per-user permission, and a team key + cannot carry one. Role and name cannot change after creation. +- Download the `.p8` file. Apple offers it once. +- The Key ID is in the key's row; the Issuer ID is at the top of the page. + +`ASC_KEY_ID` and `ASC_ISSUER_ID` are plain text; `gh` asks for each. +`ASC_KEY_P8` is the `.p8` file's PEM text as it is, not base64: + +```bash +gh secret set ASC_KEY_ID --repo srcfl/ftw-app +``` + +```bash +gh secret set ASC_ISSUER_ID --repo srcfl/ftw-app +``` + +```bash +gh secret set ASC_KEY_P8 --repo srcfl/ftw-app < AuthKey_XXXXXXXXXX.p8 +``` + +Then delete the `.p8` from your disk, or keep it in a password manager. + +**Safer, when there is time.** An Admin key can do much more than upload +builds, and any branch's workflow can read a repository secret. The job +also reads secrets from an environment `app-store`: + +1. **srcfl/ftw-app → Settings → Environments → New environment → + `app-store`.** +2. **Deployment branches and tags → Selected branches and tags**: add + branch `main` and tag `ios-v*`. Optionally add yourself as a required + reviewer. +3. Set the same three secrets with `--env app-store` added, then delete + the repository ones. + +### 4. Tag a build + +Tag a commit that contains the workflow, for example the head of the +branch you want to test, and push the tag: + +```bash +git tag ios-v0.1.0-1 && git push origin ios-v0.1.0-1 +``` + +Follow the run under **Actions → iOS release**. Each run on a clean runner +may create an "Apple Development" certificate marked "Created via API" for +the archive; if **Certificates** reports a limit, revoke the old ones. + +### 5. Answer the encryption question, if asked + +If the build shows **Missing Compliance** in TestFlight, click **Manage** +next to it and answer as in [Encryption and export](#5-encryption-and-export) +below. The store-ready branch sets `ITSAppUsesNonExemptEncryption` in +`FTW-Info.plist`; builds that carry it skip this. + +### 6. Add yourself as a tester + +**Apps → FTW → TestFlight → Internal Testing → (+)**: group `Sourceful`. +Add yourself and others from the App Store Connect team, and turn on +automatic distribution so each new build reaches the group. On the iPhone, +install TestFlight from the App Store, signed in with the same Apple +Account, and accept the invitation. -**Apps → FTW → App Information** (under General), for each language: +A build expires after 90 days. + +## Before external testers and the App Store + +### 1. Links + +- `https://app.ftw.energy/.well-known/apple-app-site-association` must + answer before review: passkeys and pairing links need it + ([srcfl/ftw-webapp#77](https://github.com/srcfl/ftw-webapp/pull/77)). + On 12 October 2026 it answered 404. +- `ftw.energy` must serve the website. On 12 October 2026 it had no + address record, and the Cloudflare Pages project `ftw` served only + `ftw.sourceful.energy`. In Cloudflare: **Workers & Pages → ftw → Custom + domains → Set up a custom domain → `ftw.energy`**. Then check: + + ```bash + curl -sI https://ftw.energy/privacy + ``` + + If `ftw.energy` stays off, replace it with `ftw.sourceful.energy` in + `store/*/privacy-url.txt`, `support-url.txt` and `marketing-url.txt`. + +### 2. App Information + +**Apps → FTW → App Information** (under General), for each language. Add +**Swedish** from the localization menu first. | Field | Value | |---|---| @@ -105,7 +227,7 @@ The name under the icon stays "FTW" whatever the store name. | Copyright | `2026 Sourceful Labs AB` | | Privacy Policy URL (App Privacy) | `store//privacy-url.txt` | -### 4. Trader status under the EU Digital Services Act +### 3. Trader status under the EU Digital Services Act EU users see the trader's address, phone and email on the product page. Apple removes apps without trader status from the EU stores. @@ -129,7 +251,7 @@ published today is `support@sourceful.energy` on [sourceful.energy/contact](https://www.sourceful.energy/contact), which ftw-web and FTW's SUPPORT.md link to. No phone number is published. -### 5. App Privacy +### 4. App Privacy **Apps → FTW → App Privacy → Get Started → "No, we do not collect data from this app" → Save → Publish.** @@ -154,7 +276,7 @@ The privacy manifest (`PrivacyInfo.xcprivacy`) comes with the store-ready branch. Both answers change when FTW Cloud ships: it will hold readable data the user turns on. -### 6. Encryption and export +### 5. Encryption and export The store-ready branch sets `ITSAppUsesNonExemptEncryption` in `FTW-Info.plist`; with the key set, App Store Connect asks nothing per @@ -171,7 +293,7 @@ French declaration. report to the U.S. government", and that US export law applies wherever the company sits. -### 7. Price and countries +### 6. Price and countries **Monetization → Pricing and Availability** @@ -197,70 +319,24 @@ Other countries, checked for a free 4+ utility that is not a game: | Texas, Utah, Louisiana | Age assurance for new accounts (Declared Age Range API) | **Decide with counsel.** M1 has no accounts and no age-dependent content. | | Russia | — | "Sourceful Energy" is not in the Russian store. **Decide** whether FTW follows. | -### 8. API key and GitHub secrets +### 7. External TestFlight -**Users and Access → Integrations → App Store Connect API → Team Keys → -Generate API Key** - -- Name: `GitHub ftw-app release`. -- Access: **Admin**. App Manager is not enough: Apple's roles table gives - cloud-managed distribution certificates to Account Holder and Admin, and - other roles only through a per-user setting that team keys cannot have. - The runner has no certificate of its own, so the export needs one from - the cloud. Role and name cannot change after creation. -- Download the `.p8` file. Apple offers it once. -- The Key ID is in the key's row; the Issuer ID is at the top of the page. - -Keep the secrets in a GitHub environment that only `main` and release tags -can use, since an Admin key can do much more than upload builds: - -1. **srcfl/ftw-app → Settings → Environments → New environment → - `app-store`.** -2. **Deployment branches and tags → Selected branches and tags**: add - branch `main` and tag `ios-v*`. Optionally add yourself as a required - reviewer. -3. Add the secrets there: - - ```bash - gh secret set ASC_KEY_ID --env app-store --repo srcfl/ftw-app - ``` - - ```bash - gh secret set ASC_ISSUER_ID --env app-store --repo srcfl/ftw-app - ``` - - ```bash - gh secret set ASC_KEY_P8 --env app-store --repo srcfl/ftw-app < AuthKey_XXXXXXXXXX.p8 - ``` - -4. Delete the `.p8` from your disk, or keep it in a password manager. - -Each run on a clean runner may create an "Apple Development" certificate -marked "Created via API" for the archive. If **Certificates** reports a -limit, revoke the old ones. - -### 9. TestFlight - -After the first upload: **Apps → FTW → TestFlight**. +**Apps → FTW → TestFlight** 1. **Additional → Test Information**, for each language: - Beta App Description: the first paragraph of `store//description.txt`. - - Feedback Email: an inbox someone reads (see the decision in step 4). + - Feedback Email: an inbox someone reads (see the decision in step 3). - Marketing URL and Privacy Policy URL: from `store//`. - Beta App Review Information: contact, "Sign-in required" off, and `store/review-notes.txt` as notes. -2. **Internal Testing → (+)**: group `Sourceful`. Add App Store Connect - users (up to 100) and turn on automatic distribution. -3. **External Testing → (+)**: group `Beta`. Add the build, then **Submit - for Review**. Apple reviews the first build; later builds of the same - version usually go out at once. Invite by email or turn on a public link - (Open to Anyone, or Filter by Criteria, with a tester limit). Up to - 10,000 testers. -4. For each build, **What to Test**: `store//release-notes.txt`. - -A build expires after 90 days. +2. **External Testing → (+)**: group `Beta`. An internal group must exist + first. Add the build, then **Submit for Review**. Apple reviews the + first build; later builds of the same version usually go out at once. + Invite by email or turn on a public link (Open to Anyone, or Filter by + Criteria, with a tester limit). Up to 10,000 testers. +3. For each build, **What to Test**: `store//release-notes.txt`. -### 10. The App Store version +### 8. The App Store version **Apps → FTW → App Store → iOS App → the version** @@ -296,12 +372,13 @@ and the car view. ``` 3. Watch the *iOS release* run. When it reports the upload, the build - appears in TestFlight within minutes to an hour. -4. Write What to Test, then hand the build to the groups in step 9. + appears in TestFlight, usually within an hour. +4. Write What to Test, then hand the build to the groups. ## When the upload fails -The run keeps Xcode's export logs as the `export-logs` artifact. +The run keeps Xcode's export logs as the `export-logs` artifact. The +summary says whether Xcode or altool uploaded the build. | Message | Cause | |---|---| @@ -310,6 +387,7 @@ The run keeps Xcode's export logs as the `export-logs` artifact. | Private key not installed for an Apple Development certificate | Old API certificates; revoke them under Certificates. | | The bundle version must be higher | A build number was used; run again or tag again. | | Agreement missing or expired | The Account Holder must accept it under Business. | +| Xcode upload failed, then altool succeeded | Nothing to do; Apple got the build. | ## Later From 42b95dc5e91d26bd5c96d4b9acc21b31ed6dcbf7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 19:03:33 +0200 Subject: [PATCH 21/25] Link the privacy policy and support from inside the app App Review's 5.1.1(i) asks that the privacy policy be easy to find in the app. The foot of Now and of pairing links it beside Source & licenses, and Source & licenses has a Privacy and support section. Both pages are srcfl/ftw-web#18's, at ftw.energy, in Swedish when the app is. The footer's links stay on one line while they fit and stack when they do not. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/About/AboutLink.swift | 27 +++++++++++-------- appleApp/FTW/About/AboutView.swift | 13 +++++++++ .../Sources/FTWKit/Identity/AboutWords.swift | 6 +++++ .../Sources/FTWKit/Identity/Origin.swift | 14 ++++++++++ .../FTWKit/Tests/FTWKitTests/AboutTests.swift | 12 +++++++++ 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/appleApp/FTW/About/AboutLink.swift b/appleApp/FTW/About/AboutLink.swift index 8c03aa7..65d2de1 100644 --- a/appleApp/FTW/About/AboutLink.swift +++ b/appleApp/FTW/About/AboutLink.swift @@ -1,23 +1,20 @@ import FTWKit import SwiftUI -/// The way to Source & licenses and to removing this phone, at the foot of -/// Now and of pairing. The license asks that its text and the source offer -/// be reachable from inside the app, and App Review asks the same of -/// removing what the app set up. +/// The way to Source & licenses, the privacy policy and removing this phone, +/// at the foot of Now and of pairing. The license asks that its text and the +/// source offer be reachable from inside the app; App Review asks the same +/// of the privacy policy (5.1.1(i)) and of removing what the app set up. struct AboutLink: View { let app: AppModel @State private var open: AboutView.Start? - @Environment(\.dynamicTypeSize) private var typeSize var body: some View { - let stack = typeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 8)) : AnyLayout(HStackLayout(spacing: 20)) VStack(spacing: 6) { - stack { - Button(AboutWords.text("Source & licenses")) { open = .about } - if AboutView.canRemove(app) { - Button(AboutWords.text("Remove this phone")) { open = .remove } - } + // One line while it fits, one link to a line when it does not. + ViewThatFits(in: .horizontal) { + HStack(spacing: 20) { links } + VStack(spacing: 8) { links } } .font(.footnote.weight(.medium)) .foregroundStyle(Theme.accent) @@ -41,6 +38,14 @@ struct AboutLink: View { } #endif } + + @ViewBuilder private var links: some View { + Button(AboutWords.text("Source & licenses")) { open = .about } + Link(AboutWords.text("Privacy"), destination: Origin.privacyURL()) + if AboutView.canRemove(app) { + Button(AboutWords.text("Remove this phone")) { open = .remove } + } + } } extension BuildSource { diff --git a/appleApp/FTW/About/AboutView.swift b/appleApp/FTW/About/AboutView.swift index 7c52993..78906e1 100644 --- a/appleApp/FTW/About/AboutView.swift +++ b/appleApp/FTW/About/AboutView.swift @@ -80,6 +80,19 @@ struct AboutView: View { Text(AboutWords.text("FTW is free software. You may run, study, change and share it under the GNU AGPL v3 with the two additional permissions in LICENSE. The link gives the complete source of this exact version, at no charge.")) } + Section { + Link(destination: Origin.privacyURL()) { + Label(AboutWords.text("Privacy policy"), systemImage: "hand.raised") + } + Link(destination: Origin.supportURL()) { + Label(AboutWords.text("Support"), systemImage: "questionmark.circle") + } + } header: { + Text(AboutWords.text("Privacy and support")) + } footer: { + Text(AboutWords.text("FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them.")) + } + Section { ForEach(LicenseText.Document.allCases) { document in NavigationLink(value: Route.document(document)) { diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift index 1cdc90a..d369896 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift @@ -59,6 +59,12 @@ public enum AboutWords { "© 2026 Sourceful Labs AB and contributors.": "© 2026 Sourceful Labs AB och bidragsgivare.", "There is no warranty, to the extent the law allows.": "Det finns ingen garanti, så långt lagen tillåter.", "This text is missing from this build.": "Texten saknas i det här bygget.", + "Privacy": "Integritet", + "Privacy policy": "Integritetspolicy", + "Support": "Support", + "Privacy and support": "Integritet och support", + "FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them.": + "FTW samlar inte in något om dig. Mätvärden och kommandon går krypterade mellan telefonen och boxen, och Sourceful kan inte läsa dem.", // Removing this phone "This phone": "Den här telefonen", diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift index 83df598..937eec1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift @@ -19,4 +19,18 @@ public enum Origin { /// links in Source & licenses must lead to the fork's own source. /// `BuildSource` pins them to the build's commit. public static let sourceURL = URL(string: "https://github.com/srcfl/ftw-app")! + + /// The privacy policy, which App Review asks to be easy to find in the + /// app, and the support page beside it. Swedish where the app speaks it. + public static func privacyURL(_ language: AboutWords.Language = AboutWords.language) -> URL { + site(language, "privacy") + } + + public static func supportURL(_ language: AboutWords.Language = AboutWords.language) -> URL { + site(language, "support") + } + + private static func site(_ language: AboutWords.Language, _ page: String) -> URL { + URL(string: "https://ftw.energy/\(language == .sv ? "sv/" : "")\(page)")! + } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift index 45de0f2..a51a89c 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift @@ -45,6 +45,18 @@ import Testing #expect(source.localNote != nil) } + /// App Review asks for the privacy policy inside the app. The pages are + /// srcfl/ftw-web's, in English and Swedish. + @Test func privacyAndSupportFollowTheLanguage() { + #expect(Origin.privacyURL(.en).absoluteString == "https://ftw.energy/privacy") + #expect(Origin.privacyURL(.sv).absoluteString == "https://ftw.energy/sv/privacy") + #expect(Origin.supportURL(.en).absoluteString == "https://ftw.energy/support") + #expect(Origin.supportURL(.sv).absoluteString == "https://ftw.energy/sv/support") + AboutWords.$language.withValue(.sv) { + #expect(Origin.privacyURL() == Origin.privacyURL(.sv)) + } + } + // MARK: The license files /// Joining lines must never change the text: every word of every file, From 59cde99ecea988d023e9a39254f5702edabd8f2a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 19:06:43 +0200 Subject: [PATCH 22/25] Match the release guide to the store-ready branch's answers #13 sets ITSAppUsesNonExemptEncryption to NO and adds privacy manifests with no collected data. RELEASING.md now gives the matching App Store Connect answers ("None of the algorithms mentioned above", "Data Not Collected"), #13's reading of US and French encryption rules, and the choice about the relay's error log. The App Review notes say where the privacy link is and that there is no account to delete. Co-Authored-By: Claude Opus 5.5 --- appleApp/RELEASING.md | 71 ++++++++++++++++++++++----------- appleApp/store/review-notes.txt | 2 + 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/appleApp/RELEASING.md b/appleApp/RELEASING.md index 5fd46f2..ea2c4e8 100644 --- a/appleApp/RELEASING.md +++ b/appleApp/RELEASING.md @@ -178,10 +178,11 @@ the archive; if **Certificates** reports a limit, revoke the old ones. ### 5. Answer the encryption question, if asked -If the build shows **Missing Compliance** in TestFlight, click **Manage** -next to it and answer as in [Encryption and export](#5-encryption-and-export) -below. The store-ready branch sets `ITSAppUsesNonExemptEncryption` in -`FTW-Info.plist`; builds that carry it skip this. +The store-ready branch ([#13](https://github.com/srcfl/ftw-app/pull/13)) +sets `ITSAppUsesNonExemptEncryption` to `NO`, so builds that carry it skip +this. If a build shows **Missing Compliance** in TestFlight, click +**Manage** next to it and choose **None of the algorithms mentioned +above** ([Encryption and export](#5-encryption-and-export) says why). ### 6. Add yourself as a tester @@ -268,30 +269,53 @@ time". In M1: - No analytics, ads, third-party code or tracking. One edge: when the relay fails to proxy a connection, Caddy's error log -may still hold the client address and handle (rotated, 3 × 10 MB). -Filtering that log too would remove the doubt; that change belongs in -ftw-webapp. +may still hold the client address and request URI (rotated, 3 × 10 MB). +**Decide** before you publish the answer: -The privacy manifest (`PrivacyInfo.xcprivacy`) comes with the store-ready -branch. Both answers change when FTW Cloud ships: it will hold readable -data the user turns on. +- remove addresses from that log too, in ftw-webapp, so "Data Not + Collected" holds exactly; or +- declare **Other Diagnostic Data**: not linked to the user, not used for + tracking, purpose App Functionality. -### 5. Encryption and export +The privacy pages in srcfl/ftw-web#18 already mention the log. + +The privacy manifests from #13 match this answer. Both the app's and the +widget's declare no tracking, no tracking domains and no collected data. +Required-reason APIs: file timestamps (`C617.1`) in both, since the cache +sorts App Group files by date, and `UserDefaults` (`CA92.1`) in the app. -The store-ready branch sets `ITSAppUsesNonExemptEncryption` in -`FTW-Info.plist`; with the key set, App Store Connect asks nothing per -build. Without it, answer under **App Information → App Encryption -Documentation (+)**, or **Manage** next to a build in TestFlight. +Both answers change when FTW Cloud ships: it will hold readable data the +user turns on. -What to answer: FTWKit builds Noise IK from CryptoKit alone (X25519, -ChaCha20-Poly1305, SHA-256, HKDF). Apple's table says encryption "limited -to that within the Apple operating system" needs no documentation, and no -French declaration. +### 5. Encryption and export -**Decide with counsel:** Apple notes that an app using exempt encryption -"might alternatively be required to submit a year-end self-classification -report to the U.S. government", and that US export law applies wherever -the company sits. +#13 sets `ITSAppUsesNonExemptEncryption` to `NO` in `FTW-Info.plist`, so +App Store Connect asks nothing per build. A build without it asks under +**App Information → App Encryption Documentation (+)**, or **Manage** next +to the build in TestFlight; answer **None of the algorithms mentioned +above**. + +Why: all cryptography runs in CryptoKit, which ships with iOS (X25519, +ChaCha20-Poly1305, SHA-256 and HKDF, AES-GCM, Ed25519). The app implements +no algorithm itself; Noise IK is a published protocol built on those. +Apple's table says encryption "limited to that within the Apple operating +system" needs no documentation. No Apple page says outright that CryptoKit +counts; that is our reading. + +The rest is #13's reading, which no lawyer has reviewed; its sources are in +the PR. + +- **United States.** The source is public, so the encryption is not + subject to the EAR (15 CFR 742.15(b) and the note to 734.3(b)(2)–(3)): no + email to BIS and the NSA, and no year-end report. That holds only if every + store build comes from a published commit. The workflow passes + `FTW_SOURCE_REVISION=$GITHUB_SHA`; tag only commits pushed to this + public repository. +- **France.** Apple asks for nothing with "None". French law (Décret + 2007-663, art. 3–5) still requires a free declaration to ANSSI one month + before the app is offered in France. **Decide:** file it, or leave + France out of the first release (see [Price and + countries](#6-price-and-countries)). ### 6. Price and countries @@ -317,6 +341,7 @@ Other countries, checked for a free 4+ utility that is not a game: | Brazil | Tax forms | Only for developers in Brazil. | | Australia, Brazil, Singapore | 18+ download checks | Not for a 4+ app. | | Texas, Utah, Louisiana | Age assurance for new accounts (Declared Age Range API) | **Decide with counsel.** M1 has no accounts and no age-dependent content. | +| France | ANSSI encryption declaration one month ahead (#13's reading) | **Decide:** file it, or leave France out at first. | | Russia | — | "Sourceful Energy" is not in the Russian store. **Decide** whether FTW follows. | ### 7. External TestFlight diff --git a/appleApp/store/review-notes.txt b/appleApp/store/review-notes.txt index 6a7b371..73fcbb4 100644 --- a/appleApp/store/review-notes.txt +++ b/appleApp/store/review-notes.txt @@ -14,6 +14,8 @@ WIDGETS The price widgets show prices the app saved from a real box. The demo saves nothing, so in review a widget says "Open FTW to see prices". OTHER +- Privacy policy: "Privacy" at the foot of Now and of the first screen, and in About. +- No account to delete. "Remove this phone" at the foot of Now unpairs the phone. - Camera: used only to read the pairing QR code. - No sign-in, no in-app purchases, no ads, no analytics, no tracking. - Encryption: Noise protocol built on Apple's CryptoKit only. From 98050e50cd7480a09d5ca5d36be118d01edea483 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 19:14:52 +0200 Subject: [PATCH 23/25] Leave France out of the first release Fredrik decided that France waits until the ANSSI encryption declaration is filed. RELEASING.md now removes France under Pricing and Availability, keeps China out for want of an ICP filing, and adds a step for the declaration, with ANSSI's forms page, before France is added back. Co-Authored-By: Claude Opus 5.5 --- appleApp/RELEASING.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/appleApp/RELEASING.md b/appleApp/RELEASING.md index ea2c4e8..87a2c94 100644 --- a/appleApp/RELEASING.md +++ b/appleApp/RELEASING.md @@ -313,9 +313,9 @@ the PR. public repository. - **France.** Apple asks for nothing with "None". French law (Décret 2007-663, art. 3–5) still requires a free declaration to ANSSI one month - before the app is offered in France. **Decide:** file it, or leave - France out of the first release (see [Price and - countries](#6-price-and-countries)). + before the app is offered in France. Fredrik decided on 12 October 2026 + that France waits: the first release leaves it out, and the declaration + comes later ([Before launching in France](#before-launching-in-france)). ### 6. Price and countries @@ -330,7 +330,9 @@ the PR. "ICP Filing Number Missing". (The iTunes lookup shows "Sourceful Energy" in the Chinese store. If Sourceful holds a filing, enter it and keep China.) -4. Untick **Apple silicon Mac** and **Apple Vision Pro**. Apple publishes +4. Remove **France** until the ANSSI declaration is filed and a month has + passed ([Before launching in France](#before-launching-in-france)). +5. Untick **Apple silicon Mac** and **Apple Vision Pro**. Apple publishes iPhone apps there by default; M1 is iPhone only. Other countries, checked for a free 4+ utility that is not a game: @@ -341,7 +343,7 @@ Other countries, checked for a free 4+ utility that is not a game: | Brazil | Tax forms | Only for developers in Brazil. | | Australia, Brazil, Singapore | 18+ download checks | Not for a 4+ app. | | Texas, Utah, Louisiana | Age assurance for new accounts (Declared Age Range API) | **Decide with counsel.** M1 has no accounts and no age-dependent content. | -| France | ANSSI encryption declaration one month ahead (#13's reading) | **Decide:** file it, or leave France out at first. | +| France | ANSSI encryption declaration one month ahead (#13's reading) | Left out of the first release (step 4). | | Russia | — | "Sourceful Energy" is not in the Russian store. **Decide** whether FTW follows. | ### 7. External TestFlight @@ -414,6 +416,25 @@ summary says whether Xcode or altool uploaded the build. | Agreement missing or expired | The Account Holder must accept it under Business. | | Xcode upload failed, then altool succeeded | Nothing to do; Apple got the build. | +## Before launching in France + +French law asks the supplier of an encryption product to declare it to +ANSSI, France's cybersecurity agency, one month before offering it there +(#13's reading of Décret 2007-663; no lawyer has reviewed it). The +declaration is free. + +1. Take the declaration form (Annex I of the decree) from ANSSI's [forms + page](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/controle-reglementaire-cryptographie-formulaires/). + [Contrôle relatif à un moyen de + cryptologie](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/) + explains what to send. Describe the app's cryptography as in + [Encryption and export](#5-encryption-and-export). ANSSI asks the + supplier to request a "grand public" (consumer) classification in the + same declaration. +2. Send it to ANSSI as those pages describe, and keep the receipt. +3. A month after filing: **Pricing and Availability → App Availability → + add France**. + ## Later - **M3, push:** an APNs key (**Certificates, Identifiers & Profiles → Keys From 515965e53cc50cb63e94134ffe3126116d5ba17d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 19:22:16 +0200 Subject: [PATCH 24/25] Reflow the license header so the app shows it as one paragraph Co-Authored-By: Claude Opus 5.5 --- LICENSE | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index b812ef6..e9a3955 100644 --- a/LICENSE +++ b/LICENSE @@ -3,10 +3,10 @@ Copyright 2026 Sourceful Labs AB and contributors. This version of the project, as a whole, is licensed under the GNU Affero General Public License, version 3 only, with the two additional permissions -below. -Separately identified third-party material and Energyplan binaries retain -their own licenses. NOTICE preserves earlier licenses and attributions. -See LICENSING.md for scope, source access and distribution instructions. +below. Separately identified third-party material and Energyplan binaries +retain their own licenses. NOTICE preserves earlier licenses and +attributions. See LICENSING.md for scope, source access and distribution +instructions. GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 From a85f5dff65a15e73cfadefcde17590ff0e7e740b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 19:34:23 +0200 Subject: [PATCH 25/25] Link the privacy, support and marketing pages on ftw.sourceful.energy The website lives at ftw.sourceful.energy (srcfl/ftw-web README). ftw.energy has no A record, so the About links and the store URLs pointed nowhere. The pages from srcfl/ftw-web#18 answer 200 there in both languages. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift | 2 +- appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift | 8 ++++---- appleApp/RELEASING.md | 4 ++-- appleApp/store/en-US/marketing-url.txt | 2 +- appleApp/store/en-US/privacy-url.txt | 2 +- appleApp/store/en-US/support-url.txt | 2 +- appleApp/store/sv-SE/marketing-url.txt | 2 +- appleApp/store/sv-SE/privacy-url.txt | 2 +- appleApp/store/sv-SE/support-url.txt | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift index 937eec1..a18dcb7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift @@ -31,6 +31,6 @@ public enum Origin { } private static func site(_ language: AboutWords.Language, _ page: String) -> URL { - URL(string: "https://ftw.energy/\(language == .sv ? "sv/" : "")\(page)")! + URL(string: "https://ftw.sourceful.energy/\(language == .sv ? "sv/" : "")\(page)")! } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift index a51a89c..00d3dd9 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift @@ -48,10 +48,10 @@ import Testing /// App Review asks for the privacy policy inside the app. The pages are /// srcfl/ftw-web's, in English and Swedish. @Test func privacyAndSupportFollowTheLanguage() { - #expect(Origin.privacyURL(.en).absoluteString == "https://ftw.energy/privacy") - #expect(Origin.privacyURL(.sv).absoluteString == "https://ftw.energy/sv/privacy") - #expect(Origin.supportURL(.en).absoluteString == "https://ftw.energy/support") - #expect(Origin.supportURL(.sv).absoluteString == "https://ftw.energy/sv/support") + #expect(Origin.privacyURL(.en).absoluteString == "https://ftw.sourceful.energy/privacy") + #expect(Origin.privacyURL(.sv).absoluteString == "https://ftw.sourceful.energy/sv/privacy") + #expect(Origin.supportURL(.en).absoluteString == "https://ftw.sourceful.energy/support") + #expect(Origin.supportURL(.sv).absoluteString == "https://ftw.sourceful.energy/sv/support") AboutWords.$language.withValue(.sv) { #expect(Origin.privacyURL() == Origin.privacyURL(.sv)) } diff --git a/appleApp/RELEASING.md b/appleApp/RELEASING.md index 87a2c94..f27df30 100644 --- a/appleApp/RELEASING.md +++ b/appleApp/RELEASING.md @@ -14,7 +14,7 @@ Apple moves menus, so trust the screen if it differs. | App Group | `group.energy.ftw.app` | | Workflow | [`.github/workflows/ios-release.yml`](../.github/workflows/ios-release.yml) | | Store texts | [`store/`](store/) | -| Privacy and support | `https://ftw.energy/privacy`, `https://ftw.energy/support` ([srcfl/ftw-web](https://github.com/srcfl/ftw-web)) | +| Privacy and support | `https://ftw.sourceful.energy/privacy`, `https://ftw.sourceful.energy/support` ([srcfl/ftw-web](https://github.com/srcfl/ftw-web)) | ## How a build travels @@ -208,7 +208,7 @@ A build expires after 90 days. domains → Set up a custom domain → `ftw.energy`**. Then check: ```bash - curl -sI https://ftw.energy/privacy + curl -sI https://ftw.sourceful.energy/privacy ``` If `ftw.energy` stays off, replace it with `ftw.sourceful.energy` in diff --git a/appleApp/store/en-US/marketing-url.txt b/appleApp/store/en-US/marketing-url.txt index 7c53c28..9a3bca2 100644 --- a/appleApp/store/en-US/marketing-url.txt +++ b/appleApp/store/en-US/marketing-url.txt @@ -1 +1 @@ -https://ftw.energy/ +https://ftw.sourceful.energy/ diff --git a/appleApp/store/en-US/privacy-url.txt b/appleApp/store/en-US/privacy-url.txt index 851268c..4e8ffa6 100644 --- a/appleApp/store/en-US/privacy-url.txt +++ b/appleApp/store/en-US/privacy-url.txt @@ -1 +1 @@ -https://ftw.energy/privacy +https://ftw.sourceful.energy/privacy diff --git a/appleApp/store/en-US/support-url.txt b/appleApp/store/en-US/support-url.txt index 464405d..56d9215 100644 --- a/appleApp/store/en-US/support-url.txt +++ b/appleApp/store/en-US/support-url.txt @@ -1 +1 @@ -https://ftw.energy/support +https://ftw.sourceful.energy/support diff --git a/appleApp/store/sv-SE/marketing-url.txt b/appleApp/store/sv-SE/marketing-url.txt index 7c53c28..9a3bca2 100644 --- a/appleApp/store/sv-SE/marketing-url.txt +++ b/appleApp/store/sv-SE/marketing-url.txt @@ -1 +1 @@ -https://ftw.energy/ +https://ftw.sourceful.energy/ diff --git a/appleApp/store/sv-SE/privacy-url.txt b/appleApp/store/sv-SE/privacy-url.txt index dabb852..0867b0e 100644 --- a/appleApp/store/sv-SE/privacy-url.txt +++ b/appleApp/store/sv-SE/privacy-url.txt @@ -1 +1 @@ -https://ftw.energy/sv/privacy +https://ftw.sourceful.energy/sv/privacy diff --git a/appleApp/store/sv-SE/support-url.txt b/appleApp/store/sv-SE/support-url.txt index 7b6156c..ba6abf7 100644 --- a/appleApp/store/sv-SE/support-url.txt +++ b/appleApp/store/sv-SE/support-url.txt @@ -1 +1 @@ -https://ftw.energy/sv/support +https://ftw.sourceful.energy/sv/support